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..de8bede4e --- /dev/null +++ b/packages/code-link-cli/src/helpers/installer.test.ts @@ -0,0 +1,202 @@ +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`) + }) + + 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) + }) + + 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 38ebcb6f5..a15541eff 100644 --- a/packages/code-link-cli/src/helpers/installer.ts +++ b/packages/code-link-cli/src/helpers/installer.ts @@ -22,11 +22,24 @@ interface NpmExportValue { types?: string } +type NpmDependencyMap = Record + /** npm registry API response for a single package version */ interface NpmPackageVersion { exports?: Record } +interface NpmPackageManifest extends NpmPackageVersion { + version?: string + dependencies?: NpmDependencyMap + peerDependencies?: NpmDependencyMap +} + +interface ProjectPackageJson { + dependencies?: NpmDependencyMap + devDependencies?: NpmDependencyMap +} + /** npm registry API response */ interface NpmRegistryResponse { "dist-tags"?: { latest?: string } @@ -36,9 +49,18 @@ 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 +71,7 @@ const SUPPORTED_PACKAGES = new Set([ "framer", "framer-motion", "react", + "react-dom", "@types/react", "eventemitter3", "csstype", @@ -65,6 +88,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,13 +203,20 @@ 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 = 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 coreImports = CORE_LIBRARIES.map(lib => `import "${lib}";`).join("\n") - await this.ata(coreImports) + const imports = [...coreImports, ...(await this.buildPinnedImports(packageJsonDeps))].join("\n") + await this.ata(imports) }) .catch((err: unknown) => { debug("Type installation failed", err) @@ -209,8 +241,14 @@ export class Installer { return } + await this.pinnedTypeVersionsPromise + + if (this.allowUnsupportedNpm) { + await this.resolvePackageJsonPins() + } + const hash = imports - .map(imp => imp.name) + .map(imp => this.pinImport(imp.name)) .sort() .join(",") @@ -222,7 +260,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) @@ -230,6 +268,7 @@ export class Installer { warn(`Type fetching failed for ${fileName}`) debug(`ATA error for ${fileName}:`, err) } + } /** @@ -251,8 +290,70 @@ 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) + if (framerVersion) { + this.pinnedTypeVersions.framer = framerVersion + } + + 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 ${this.pinnedTypeVersions["framer-motion"]}, react ${this.pinnedTypeVersions.react})` + ) + } 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 { + warn("Could not read package.json for version pinning") + } + } + + /** + * 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 +386,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 +408,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 +538,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 */