From dca806cd962a3a4d7fa28446e4cf246aa4e47c2b Mon Sep 17 00:00:00 2001 From: Hunter Caron Date: Thu, 12 Mar 2026 09:53:56 +0100 Subject: [PATCH 1/7] Derive ATA pins from framer metadata --- .../src/helpers/installer.test.ts | 188 ++++++++++++++++ .../code-link-cli/src/helpers/installer.ts | 200 ++++++++++-------- 2 files changed, 305 insertions(+), 83 deletions(-) create mode 100644 packages/code-link-cli/src/helpers/installer.test.ts diff --git a/packages/code-link-cli/src/helpers/installer.test.ts b/packages/code-link-cli/src/helpers/installer.test.ts new file mode 100644 index 000000000..666c0da3d --- /dev/null +++ b/packages/code-link-cli/src/helpers/installer.test.ts @@ -0,0 +1,188 @@ +import fs from "fs/promises" +import os from "os" +import path from "path" +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest" + +vi.mock("./skills.ts", () => ({ + installSkills: vi.fn().mockResolvedValue(undefined), +})) + +const mockAta = vi.fn<(code: string) => Promise>>().mockResolvedValue(new Map()) +vi.mock("@typescript/ata", () => ({ + setupTypeAcquisition: vi.fn(() => mockAta), +})) + +const mockFetch = vi.fn< + (input: string | URL | Request) => Promise<{ ok: boolean; status?: number; json: () => Promise }> +>() +vi.stubGlobal("fetch", mockFetch) + +import { Installer } from "./installer.ts" + +let tmpDir: string + +beforeEach(async () => { + tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "cl-installer-")) + mockAta.mockClear() + mockFetch.mockReset() + mockFetch.mockImplementation(async input => { + const url = typeof input === "string" ? input : input instanceof URL ? input.href : input.url + + if (url === "https://registry.npmjs.org/framer/latest") { + return { + ok: true, + status: 200, + json: async () => ({ + version: "3.0.2", + peerDependencies: { + "framer-motion": "12.34.3", + "react": "^18.2.0", + }, + }), + } + } + + throw new Error(`Unexpected fetch: ${url}`) + }) +}) + +afterEach(async () => { + await fs.rm(tmpDir, { recursive: true, force: true }) +}) + +describe("Installer", () => { + describe("version pinning", () => { + it("pins core imports using framer's manifest", async () => { + const installer = new Installer({ projectDir: tmpDir }) + await installer.initialize() + + await vi.waitFor(() => { + expect(mockAta).toHaveBeenCalled() + }) + + const coreCall = mockAta.mock.calls[0][0] + expect(coreCall).toContain(`import "framer"; // types: 3.0.2`) + expect(coreCall).toContain(`import "framer-motion"; // types: 12.34.3`) + expect(coreCall).toContain(`import "react"; // types: 18.2.0`) + expect(coreCall).toContain(`import "react-dom"; // types: 18.2.0`) + expect(coreCall).toContain(`import "@types/react"; // types: 18.2.0`) + }) + + it("falls back to default pins when framer metadata fetch fails", async () => { + mockFetch.mockRejectedValueOnce(new Error("network down")) + + const installer = new Installer({ projectDir: tmpDir }) + await installer.initialize() + + await vi.waitFor(() => { + expect(mockAta).toHaveBeenCalled() + }) + + const coreCall = mockAta.mock.calls[0][0] + expect(coreCall).toContain(`import "framer";`) + expect(coreCall).toContain(`import "framer-motion"; // types: 12.34.3`) + expect(coreCall).toContain(`import "react"; // types: 18.2.0`) + expect(coreCall).toContain(`import "react-dom"; // types: 18.2.0`) + }) + }) + + describe("project scaffolding", () => { + it("creates tsconfig.json", async () => { + const installer = new Installer({ projectDir: tmpDir }) + await installer.initialize() + + const tsconfig = JSON.parse(await fs.readFile(path.join(tmpDir, "tsconfig.json"), "utf-8")) + expect(tsconfig.compilerOptions.jsx).toBe("react-jsx") + expect(tsconfig.compilerOptions.moduleResolution).toBe("bundler") + }) + + it("creates package.json", async () => { + const installer = new Installer({ projectDir: tmpDir }) + await installer.initialize() + + const pkg = JSON.parse(await fs.readFile(path.join(tmpDir, "package.json"), "utf-8")) + expect(pkg.private).toBe(true) + }) + + it("creates .prettierrc", async () => { + const installer = new Installer({ projectDir: tmpDir }) + await installer.initialize() + + const config = JSON.parse(await fs.readFile(path.join(tmpDir, ".prettierrc"), "utf-8")) + expect(config.tabWidth).toBe(4) + expect(config.semi).toBe(false) + }) + + it("creates framer-modules.d.ts", async () => { + const installer = new Installer({ projectDir: tmpDir }) + await installer.initialize() + + const content = await fs.readFile(path.join(tmpDir, "framer-modules.d.ts"), "utf-8") + expect(content).toContain('declare module "https://framer.com/m/*"') + }) + + it("creates .gitignore", async () => { + const installer = new Installer({ projectDir: tmpDir }) + await installer.initialize() + + const content = await fs.readFile(path.join(tmpDir, ".gitignore"), "utf-8") + expect(content).toContain("node_modules/") + }) + + it("does not overwrite existing files", async () => { + await fs.writeFile(path.join(tmpDir, "tsconfig.json"), '{"custom": true}') + + const installer = new Installer({ projectDir: tmpDir }) + await installer.initialize() + + const tsconfig = JSON.parse(await fs.readFile(path.join(tmpDir, "tsconfig.json"), "utf-8")) + expect(tsconfig.custom).toBe(true) + }) + }) + + describe("process()", () => { + async function initAndClearAta(installer: InstanceType) { + await installer.initialize() + await vi.waitFor(() => { + expect(mockAta).toHaveBeenCalled() + }) + await new Promise(resolve => setTimeout(resolve, 200)) + mockAta.mockClear() + } + + it("ignores JSON files", async () => { + const installer = new Installer({ projectDir: tmpDir }) + await initAndClearAta(installer) + + installer.process("data.json", '{"key": "value"}') + + await new Promise(resolve => setTimeout(resolve, 100)) + expect(mockAta).not.toHaveBeenCalled() + }) + + it("ignores empty content", async () => { + const installer = new Installer({ projectDir: tmpDir }) + await initAndClearAta(installer) + + installer.process("component.tsx", "") + + await new Promise(resolve => setTimeout(resolve, 100)) + expect(mockAta).not.toHaveBeenCalled() + }) + + it("deduplicates identical import sets", async () => { + const installer = new Installer({ projectDir: tmpDir }) + await initAndClearAta(installer) + + const code = `import { motion } from "framer-motion"` + installer.process("a.tsx", code) + installer.process("b.tsx", code) + + await vi.waitFor(() => { + expect(mockAta).toHaveBeenCalled() + }) + + expect(mockAta).toHaveBeenCalledTimes(1) + }) + }) +}) diff --git a/packages/code-link-cli/src/helpers/installer.ts b/packages/code-link-cli/src/helpers/installer.ts index 38ebcb6f5..54c2ba064 100644 --- a/packages/code-link-cli/src/helpers/installer.ts +++ b/packages/code-link-cli/src/helpers/installer.ts @@ -22,11 +22,21 @@ interface NpmExportValue { types?: string } +interface NpmDependencyMap { + [name: string]: string | undefined +} + /** npm registry API response for a single package version */ interface NpmPackageVersion { exports?: Record } +interface NpmPackageManifest extends NpmPackageVersion { + version?: string + dependencies?: NpmDependencyMap + peerDependencies?: NpmDependencyMap +} + /** npm registry API response */ interface NpmRegistryResponse { "dist-tags"?: { latest?: string } @@ -36,9 +46,17 @@ interface NpmRegistryResponse { const FETCH_TIMEOUT_MS = 60_000 const MAX_FETCH_RETRIES = 3 const MAX_CONSECUTIVE_FAILURES = 10 -const REACT_TYPES_VERSION = "18.3.12" -const REACT_DOM_TYPES_VERSION = "18.3.1" -const CORE_LIBRARIES = ["framer-motion", "framer"] +const FRAMER_PACKAGE_NAME = "framer" +const CORE_LIBRARIES = ["framer-motion", "framer", "react", "react-dom"] + +/** Packages with pinned type versions — used by ATA's `// types:` comment syntax */ +const DEFAULT_PINNED_TYPE_VERSIONS: Record = { + "framer-motion": "12.34.3", + "react": "18.2.0", + "react-dom": "18.2.0", + "@types/react": "18.2.0", + "@types/react-dom": "18.2.0", +} const JSON_EXTENSION_REGEX = /\.json$/i /** @@ -49,6 +67,7 @@ const SUPPORTED_PACKAGES = new Set([ "framer", "framer-motion", "react", + "react-dom", "@types/react", "eventemitter3", "csstype", @@ -65,6 +84,8 @@ export class Installer { private ata: ReturnType private processedImports = new Set() private initializationPromise: Promise | null = null + private pinnedTypeVersions: Record = { ...DEFAULT_PINNED_TYPE_VERSIONS } + private pinnedTypeVersionsPromise: Promise | null = null constructor(config: InstallerConfig) { this.projectDir = config.projectDir @@ -178,12 +199,15 @@ export class Installer { this.ensureGitignore(), ]) + this.pinnedTypeVersionsPromise = this.resolvePinnedTypeVersions() + // Fire-and-forget type installation - don't block initialization Promise.resolve() .then(async () => { - await this.ensureReact18Types() - - const coreImports = CORE_LIBRARIES.map(lib => `import "${lib}";`).join("\n") + const coreImports = [ + ...(await this.buildPinnedImports(CORE_LIBRARIES)), + ...(await this.buildPinnedImports(["@types/react"])), + ].join("\n") await this.ata(coreImports) }) .catch((err: unknown) => { @@ -209,6 +233,8 @@ export class Installer { return } + await this.pinnedTypeVersionsPromise + const hash = imports .map(imp => imp.name) .sort() @@ -222,7 +248,7 @@ export class Installer { debug(`Processing imports for ${fileName} (${imports.length} packages)`) // Build filtered content with only supported imports for ATA - const filteredContent = this.allowUnsupportedNpm ? content : this.buildFilteredImports(imports) + const filteredContent = this.allowUnsupportedNpm ? content : await this.buildFilteredImports(imports) try { await this.ata(filteredContent) @@ -251,8 +277,55 @@ export class Installer { /** * Build synthetic import statements for ATA from filtered imports */ - private buildFilteredImports(imports: { name: string }[]): string { - return imports.map(imp => `import "${imp.name}";`).join("\n") + private async buildFilteredImports(imports: { name: string }[]): Promise { + return (await this.buildPinnedImports(imports.map(imp => imp.name))).join("\n") + } + + private async buildPinnedImports(imports: string[]): Promise { + await this.pinnedTypeVersionsPromise + return imports.map(name => this.pinImport(name)) + } + + private async resolvePinnedTypeVersions(): Promise { + try { + const framerManifest = await fetchNpmPackageManifest(FRAMER_PACKAGE_NAME) + const framerVersion = normalizePinnedVersion(framerManifest.version) + const framerMotionVersion = + getManifestDependencyVersion(framerManifest, "framer-motion") ?? DEFAULT_PINNED_TYPE_VERSIONS["framer-motion"] + const reactVersion = + normalizePinnedVersion(getManifestDependencyVersion(framerManifest, "react")) ?? + DEFAULT_PINNED_TYPE_VERSIONS["react"] + const reactDomVersion = + normalizePinnedVersion(getManifestDependencyVersion(framerManifest, "react-dom")) ?? reactVersion + + if (framerVersion) { + this.pinnedTypeVersions.framer = framerVersion + } + + this.pinnedTypeVersions["framer-motion"] = framerMotionVersion + this.pinnedTypeVersions.react = reactVersion + this.pinnedTypeVersions["react-dom"] = reactDomVersion + this.pinnedTypeVersions["@types/react"] = reactVersion + this.pinnedTypeVersions["@types/react-dom"] = reactDomVersion + + debug( + `Resolved ATA pins from ${FRAMER_PACKAGE_NAME}@${framerVersion ?? "latest"} ` + + `(framer-motion ${framerMotionVersion}, react ${reactVersion})` + ) + } catch (err) { + debug(`Falling back to default ATA pins for ${FRAMER_PACKAGE_NAME}`, err) + } + } + + /** + * Build an import statement with an optional `// types:` version pin for ATA. + * Resolves the base package name for subpath imports (e.g., "framer-motion/dist" -> "framer-motion"). + */ + private pinImport(name: string): string { + const base = name.startsWith("@") ? name.split("/").slice(0, 2).join("/") : name.split("/")[0] + const version = this.pinnedTypeVersions[base] + if (version) return `import "${name}"; // types: ${version}` + return `import "${name}";` } private async writeTypeFile(receivedPath: string, code: string): Promise { @@ -285,7 +358,12 @@ export class Installer { if (!response.ok) return const npmData = (await response.json()) as NpmRegistryResponse - const version = npmData["dist-tags"]?.latest + + // Use pinned version if available, otherwise fall back to latest + const pinnedVersion = this.pinnedTypeVersions[pkgName] + const version = pinnedVersion + ? this.findMatchingVersion(Object.keys(npmData.versions ?? {}), pinnedVersion) + : npmData["dist-tags"]?.latest if (!version || !npmData.versions?.[version]) return const pkg = npmData.versions[version] @@ -302,6 +380,21 @@ export class Installer { } } + /** + * Find the best matching version from a list of available versions. + * Supports exact versions ("18.2.0") — returns exact match if available. + */ + private findMatchingVersion(versions: string[], pinned: string): string | undefined { + // Exact match + if (versions.includes(pinned)) return pinned + + // For exact pins that don't match, find the closest version with matching major.minor + const [major, minor] = pinned.split(".") + const prefix = `${major}.${minor}.` + const matching = versions.filter(v => v.startsWith(prefix)) + return matching.length > 0 ? matching[matching.length - 1] : undefined + } + private async ensureTsConfig(): Promise { const tsconfigPath = path.join(this.projectDir, "tsconfig.json") try { @@ -417,87 +510,28 @@ declare module "*.json" debug("Created .gitignore") } - // Code components in Framer use React 18 - private async ensureReact18Types(): Promise { - const reactTypesDir = path.join(this.projectDir, "node_modules/@types/react") - - const reactFiles = ["package.json", "index.d.ts", "global.d.ts", "jsx-runtime.d.ts", "jsx-dev-runtime.d.ts"] - - if (await this.hasTypePackage(reactTypesDir, REACT_TYPES_VERSION, reactFiles)) { - debug("📦 React types (from cache)") - } else { - debug("Downloading React 18 types...") - await this.downloadTypePackage("@types/react", REACT_TYPES_VERSION, reactTypesDir, reactFiles) - } - - const reactDomDir = path.join(this.projectDir, "node_modules/@types/react-dom") - - const reactDomFiles = ["package.json", "index.d.ts", "client.d.ts"] - - if (await this.hasTypePackage(reactDomDir, REACT_DOM_TYPES_VERSION, reactDomFiles)) { - debug("📦 React DOM types (from cache)") - } else { - await this.downloadTypePackage("@types/react-dom", REACT_DOM_TYPES_VERSION, reactDomDir, reactDomFiles) - } - } +} - private async hasTypePackage(destinationDir: string, version: string, files: string[]): Promise { - try { - const pkgJsonPath = path.join(destinationDir, "package.json") - const pkgJson = await fs.readFile(pkgJsonPath, "utf-8") - const parsed = JSON.parse(pkgJson) as { version?: string } +function getManifestDependencyVersion(manifest: NpmPackageManifest, packageName: string): string | undefined { + return manifest.peerDependencies?.[packageName] ?? manifest.dependencies?.[packageName] +} - if (parsed.version !== version) { - return false - } +function normalizePinnedVersion(version: string | undefined): string | undefined { + if (!version) return undefined + const match = /\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?/.exec(version) + return match?.[0] +} - for (const file of files) { - if (file === "package.json") continue - await fs.access(path.join(destinationDir, file)) - } +async function fetchNpmPackageManifest(packageName: string): Promise { + const response = await fetchWithRetry(`https://registry.npmjs.org/${packageName}/latest`) - return true - } catch { - return false - } + if (!response.ok) { + throw new Error(`Failed to fetch ${packageName} manifest: ${response.status}`) } - private async downloadTypePackage( - pkgName: string, - version: string, - destinationDir: string, - files: string[] - ): Promise { - const baseUrl = `https://unpkg.com/${pkgName}@${version}` - await fs.mkdir(destinationDir, { recursive: true }) - - await Promise.all( - files.map(async file => { - const destination = path.join(destinationDir, file) - - // Check if file already exists - try { - await fs.access(destination) - return // Skip if exists - } catch { - // File doesn't exist, download it - } - - try { - const response = await fetch(`${baseUrl}/${file}`) - if (!response.ok) return - const content = await response.text() - await fs.writeFile(destination, content) - } catch { - // ignore per-file failures - } - }) - ) - } + return (await response.json()) as NpmPackageManifest } -// Helpers - /** * Transform package.json exports to include .d.ts type paths */ From 880e3672573d82dc8171957188e8bea2f0f96098 Mon Sep 17 00:00:00 2001 From: Hunter Caron Date: Thu, 12 Mar 2026 10:13:52 +0100 Subject: [PATCH 2/7] Normalize framer-motion ATA version pin --- packages/code-link-cli/src/helpers/installer.test.ts | 2 +- packages/code-link-cli/src/helpers/installer.ts | 3 ++- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/packages/code-link-cli/src/helpers/installer.test.ts b/packages/code-link-cli/src/helpers/installer.test.ts index 666c0da3d..8da0c868c 100644 --- a/packages/code-link-cli/src/helpers/installer.test.ts +++ b/packages/code-link-cli/src/helpers/installer.test.ts @@ -35,7 +35,7 @@ beforeEach(async () => { json: async () => ({ version: "3.0.2", peerDependencies: { - "framer-motion": "12.34.3", + "framer-motion": "^12.34.3", "react": "^18.2.0", }, }), diff --git a/packages/code-link-cli/src/helpers/installer.ts b/packages/code-link-cli/src/helpers/installer.ts index 54c2ba064..4bf6c11c4 100644 --- a/packages/code-link-cli/src/helpers/installer.ts +++ b/packages/code-link-cli/src/helpers/installer.ts @@ -291,7 +291,8 @@ export class Installer { const framerManifest = await fetchNpmPackageManifest(FRAMER_PACKAGE_NAME) const framerVersion = normalizePinnedVersion(framerManifest.version) const framerMotionVersion = - getManifestDependencyVersion(framerManifest, "framer-motion") ?? DEFAULT_PINNED_TYPE_VERSIONS["framer-motion"] + normalizePinnedVersion(getManifestDependencyVersion(framerManifest, "framer-motion")) ?? + DEFAULT_PINNED_TYPE_VERSIONS["framer-motion"] const reactVersion = normalizePinnedVersion(getManifestDependencyVersion(framerManifest, "react")) ?? DEFAULT_PINNED_TYPE_VERSIONS["react"] From c646b1e4ba529bd2cdb0deb97f81b1e62f98e856 Mon Sep 17 00:00:00 2001 From: Hunter Caron Date: Thu, 12 Mar 2026 12:20:24 +0100 Subject: [PATCH 3/7] Remove react/react-dom from ATA core libraries to fix duplicate type definitions Sending both `import "react"` and `import "@types/react"` through ATA caused two separate ReactElement type resolutions, breaking addPropertyControls and other APIs expecting ComponentType. --- packages/code-link-cli/src/helpers/installer.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/code-link-cli/src/helpers/installer.ts b/packages/code-link-cli/src/helpers/installer.ts index 4bf6c11c4..acf82ddbe 100644 --- a/packages/code-link-cli/src/helpers/installer.ts +++ b/packages/code-link-cli/src/helpers/installer.ts @@ -47,7 +47,7 @@ const FETCH_TIMEOUT_MS = 60_000 const MAX_FETCH_RETRIES = 3 const MAX_CONSECUTIVE_FAILURES = 10 const FRAMER_PACKAGE_NAME = "framer" -const CORE_LIBRARIES = ["framer-motion", "framer", "react", "react-dom"] +const CORE_LIBRARIES = ["framer-motion", "framer"] /** Packages with pinned type versions — used by ATA's `// types:` comment syntax */ const DEFAULT_PINNED_TYPE_VERSIONS: Record = { From 07696a54e5afaecd00e20dc13b614d36293f76aa Mon Sep 17 00:00:00 2001 From: Hunter Caron Date: Thu, 12 Mar 2026 14:36:25 +0100 Subject: [PATCH 4/7] Preload React runtime imports for ATA --- .../code-link-cli/src/helpers/installer.test.ts | 16 +++++++++++++++- packages/code-link-cli/src/helpers/installer.ts | 7 ++----- 2 files changed, 17 insertions(+), 6 deletions(-) diff --git a/packages/code-link-cli/src/helpers/installer.test.ts b/packages/code-link-cli/src/helpers/installer.test.ts index 8da0c868c..de8bede4e 100644 --- a/packages/code-link-cli/src/helpers/installer.test.ts +++ b/packages/code-link-cli/src/helpers/installer.test.ts @@ -65,7 +65,6 @@ describe("Installer", () => { expect(coreCall).toContain(`import "framer-motion"; // types: 12.34.3`) expect(coreCall).toContain(`import "react"; // types: 18.2.0`) expect(coreCall).toContain(`import "react-dom"; // types: 18.2.0`) - expect(coreCall).toContain(`import "@types/react"; // types: 18.2.0`) }) it("falls back to default pins when framer metadata fetch fails", async () => { @@ -184,5 +183,20 @@ describe("Installer", () => { expect(mockAta).toHaveBeenCalledTimes(1) }) + + it("pins React runtime imports when components reference them", async () => { + const installer = new Installer({ projectDir: tmpDir }) + await initAndClearAta(installer) + + installer.process("component.tsx", `import React from "react"\nimport { createRoot } from "react-dom/client"`) + + await vi.waitFor(() => { + expect(mockAta).toHaveBeenCalled() + }) + + const processedCall = mockAta.mock.calls[0][0] + expect(processedCall).toContain(`import "react"; // types: 18.2.0`) + expect(processedCall).toContain(`import "react-dom"; // types: 18.2.0`) + }) }) }) diff --git a/packages/code-link-cli/src/helpers/installer.ts b/packages/code-link-cli/src/helpers/installer.ts index acf82ddbe..6e8589069 100644 --- a/packages/code-link-cli/src/helpers/installer.ts +++ b/packages/code-link-cli/src/helpers/installer.ts @@ -47,7 +47,7 @@ const FETCH_TIMEOUT_MS = 60_000 const MAX_FETCH_RETRIES = 3 const MAX_CONSECUTIVE_FAILURES = 10 const FRAMER_PACKAGE_NAME = "framer" -const CORE_LIBRARIES = ["framer-motion", "framer"] +const CORE_LIBRARIES = ["framer-motion", "framer", "react", "react-dom"] /** Packages with pinned type versions — used by ATA's `// types:` comment syntax */ const DEFAULT_PINNED_TYPE_VERSIONS: Record = { @@ -204,10 +204,7 @@ export class Installer { // Fire-and-forget type installation - don't block initialization Promise.resolve() .then(async () => { - const coreImports = [ - ...(await this.buildPinnedImports(CORE_LIBRARIES)), - ...(await this.buildPinnedImports(["@types/react"])), - ].join("\n") + const coreImports = (await this.buildPinnedImports(CORE_LIBRARIES)).join("\n") await this.ata(coreImports) }) .catch((err: unknown) => { From ca950ee0956ab61f17a94e408fe5b91d69186783 Mon Sep 17 00:00:00 2001 From: Hunter Caron Date: Thu, 12 Mar 2026 14:44:59 +0100 Subject: [PATCH 5/7] Derive ATA pin assignments dynamically from DEFAULT_PINNED_TYPE_VERSIONS --- .../code-link-cli/src/helpers/installer.ts | 26 ++++++------------- 1 file changed, 8 insertions(+), 18 deletions(-) diff --git a/packages/code-link-cli/src/helpers/installer.ts b/packages/code-link-cli/src/helpers/installer.ts index 6e8589069..faf43c3c9 100644 --- a/packages/code-link-cli/src/helpers/installer.ts +++ b/packages/code-link-cli/src/helpers/installer.ts @@ -22,9 +22,7 @@ interface NpmExportValue { types?: string } -interface NpmDependencyMap { - [name: string]: string | undefined -} +type NpmDependencyMap = Record /** npm registry API response for a single package version */ interface NpmPackageVersion { @@ -57,6 +55,7 @@ const DEFAULT_PINNED_TYPE_VERSIONS: Record = { "@types/react": "18.2.0", "@types/react-dom": "18.2.0", } + const JSON_EXTENSION_REGEX = /\.json$/i /** @@ -287,28 +286,19 @@ export class Installer { try { const framerManifest = await fetchNpmPackageManifest(FRAMER_PACKAGE_NAME) const framerVersion = normalizePinnedVersion(framerManifest.version) - const framerMotionVersion = - normalizePinnedVersion(getManifestDependencyVersion(framerManifest, "framer-motion")) ?? - DEFAULT_PINNED_TYPE_VERSIONS["framer-motion"] - const reactVersion = - normalizePinnedVersion(getManifestDependencyVersion(framerManifest, "react")) ?? - DEFAULT_PINNED_TYPE_VERSIONS["react"] - const reactDomVersion = - normalizePinnedVersion(getManifestDependencyVersion(framerManifest, "react-dom")) ?? reactVersion - if (framerVersion) { this.pinnedTypeVersions.framer = framerVersion } - this.pinnedTypeVersions["framer-motion"] = framerMotionVersion - this.pinnedTypeVersions.react = reactVersion - this.pinnedTypeVersions["react-dom"] = reactDomVersion - this.pinnedTypeVersions["@types/react"] = reactVersion - this.pinnedTypeVersions["@types/react-dom"] = reactDomVersion + for (const [pkg, defaultVersion] of Object.entries(DEFAULT_PINNED_TYPE_VERSIONS)) { + const manifestDep = pkg.replace(/^@types\//, "") + this.pinnedTypeVersions[pkg] = + normalizePinnedVersion(getManifestDependencyVersion(framerManifest, manifestDep)) ?? defaultVersion + } debug( `Resolved ATA pins from ${FRAMER_PACKAGE_NAME}@${framerVersion ?? "latest"} ` + - `(framer-motion ${framerMotionVersion}, react ${reactVersion})` + `(framer-motion ${this.pinnedTypeVersions["framer-motion"]}, react ${this.pinnedTypeVersions.react})` ) } catch (err) { debug(`Falling back to default ATA pins for ${FRAMER_PACKAGE_NAME}`, err) From 1ee8662dffdf8aaa61c3cb8fa32f03c369e7dc47 Mon Sep 17 00:00:00 2001 From: Hunter Caron Date: Thu, 12 Mar 2026 15:20:39 +0100 Subject: [PATCH 6/7] Use package.json dependency versions as ATA pins when --unsupported-npm is enabled --- .../code-link-cli/src/helpers/installer.ts | 46 +++++++++++++++++-- 1 file changed, 43 insertions(+), 3 deletions(-) diff --git a/packages/code-link-cli/src/helpers/installer.ts b/packages/code-link-cli/src/helpers/installer.ts index faf43c3c9..f91fabace 100644 --- a/packages/code-link-cli/src/helpers/installer.ts +++ b/packages/code-link-cli/src/helpers/installer.ts @@ -35,6 +35,11 @@ interface NpmPackageManifest extends NpmPackageVersion { peerDependencies?: NpmDependencyMap } +interface ProjectPackageJson { + dependencies?: NpmDependencyMap + devDependencies?: NpmDependencyMap +} + /** npm registry API response */ interface NpmRegistryResponse { "dist-tags"?: { latest?: string } @@ -203,8 +208,15 @@ export class Installer { // Fire-and-forget type installation - don't block initialization Promise.resolve() .then(async () => { - const coreImports = (await this.buildPinnedImports(CORE_LIBRARIES)).join("\n") - await this.ata(coreImports) + const coreImports = await this.buildPinnedImports(CORE_LIBRARIES) + + // After pins are resolved, also include package.json deps + const packageJsonDeps = this.allowUnsupportedNpm + ? Object.keys(this.pinnedTypeVersions).filter(name => !SUPPORTED_PACKAGES.has(name)) + : [] + + const imports = [...coreImports, ...(await this.buildPinnedImports(packageJsonDeps))].join("\n") + await this.ata(imports) }) .catch((err: unknown) => { debug("Type installation failed", err) @@ -231,8 +243,12 @@ export class Installer { await this.pinnedTypeVersionsPromise + if (this.allowUnsupportedNpm) { + await this.resolvePackageJsonPins() + } + const hash = imports - .map(imp => imp.name) + .map(imp => this.pinImport(imp.name)) .sort() .join(",") @@ -252,6 +268,7 @@ export class Installer { warn(`Type fetching failed for ${fileName}`) debug(`ATA error for ${fileName}:`, err) } + } /** @@ -303,6 +320,29 @@ export class Installer { } catch (err) { debug(`Falling back to default ATA pins for ${FRAMER_PACKAGE_NAME}`, err) } + + if (this.allowUnsupportedNpm) { + await this.resolvePackageJsonPins() + } + } + + private async resolvePackageJsonPins(): Promise { + try { + const pkgPath = path.join(this.projectDir, "package.json") + const raw = await fs.readFile(pkgPath, "utf-8") + const parsed: unknown = JSON.parse(raw) + const pkg = parsed as ProjectPackageJson + const allDeps: NpmDependencyMap = { ...(pkg.dependencies ?? {}), ...(pkg.devDependencies ?? {}) } + for (const [name, range] of Object.entries(allDeps)) { + const version = normalizePinnedVersion(range) + if (version) { + this.pinnedTypeVersions[name] = version + } + } + debug(`Resolved ${Object.keys(allDeps).length} package.json version pins`) + } catch { + // package.json may not exist yet + } } /** From ea0e0a9f1189dfa51b36cac23e720f7311b2236d Mon Sep 17 00:00:00 2001 From: Hunter Caron Date: Thu, 12 Mar 2026 15:58:21 +0100 Subject: [PATCH 7/7] Log warning when package.json cannot be read for ATA version pinning --- packages/code-link-cli/src/helpers/installer.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/code-link-cli/src/helpers/installer.ts b/packages/code-link-cli/src/helpers/installer.ts index f91fabace..a15541eff 100644 --- a/packages/code-link-cli/src/helpers/installer.ts +++ b/packages/code-link-cli/src/helpers/installer.ts @@ -341,7 +341,7 @@ export class Installer { } debug(`Resolved ${Object.keys(allDeps).length} package.json version pins`) } catch { - // package.json may not exist yet + warn("Could not read package.json for version pinning") } }