diff --git a/packages/cli/src/__tests__/cli-version.test.ts b/packages/cli/src/__tests__/cli-version.test.ts new file mode 100644 index 0000000000..1960d2d32f --- /dev/null +++ b/packages/cli/src/__tests__/cli-version.test.ts @@ -0,0 +1,136 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { dirname, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; + +const fsMock = vi.hoisted(() => ({ + existsSync: vi.fn(), + readFileSync: vi.fn(), +})); + +vi.mock("node:fs", () => fsMock); + +import { + CLI_PACKAGE_NAME, + MAX_PACKAGE_LOOKUP_DEPTH, + readOwnCliVersion, +} from "../cli-version.js"; + +const startUrl = "file:///a/b/c/d/e/module.js"; +const depthStartUrl = "file:///a/b/c/d/e/f/g/h/i/j/module.js"; +const startDir = dirname(fileURLToPath(startUrl)); + +type Manifest = string | Error; + +function manifestPath(index: number, directory = startDir): string { + let currentDirectory = directory; + for (let current = 0; current < index; current += 1) { + currentDirectory = resolve(currentDirectory, ".."); + } + return resolve(currentDirectory, "package.json"); +} + +function depthManifestPath(index: number): string { + let directory = dirname(fileURLToPath(depthStartUrl)); + for (let current = 0; current < index; current += 1) { + directory = resolve(directory, ".."); + } + return resolve(directory, "package.json"); +} + +function configureManifests(manifests: Map): void { + fsMock.existsSync.mockImplementation((path: string) => manifests.has(String(path))); + fsMock.readFileSync.mockImplementation((path: string) => { + const manifest = manifests.get(String(path)); + if (manifest instanceof Error) throw manifest; + return manifest; + }); +} + +function fusionManifest(version: string | number): string { + return JSON.stringify({ name: CLI_PACKAGE_NAME, version }); +} + +describe("readOwnCliVersion", () => { + beforeEach(() => { + fsMock.existsSync.mockReset(); + fsMock.readFileSync.mockReset(); + configureManifests(new Map()); + }); + + it("returns an immediate matching manifest without extra walking", () => { + configureManifests(new Map([[manifestPath(0), fusionManifest("1.2.3")]])); + + expect(readOwnCliVersion(startUrl)).toBe("1.2.3"); + expect(fsMock.readFileSync).toHaveBeenCalledOnce(); + expect(fsMock.existsSync).toHaveBeenCalledWith(manifestPath(0)); + }); + + it("walks ancestors one level at a time", () => { + configureManifests(new Map([[manifestPath(2), fusionManifest("2.3.4")]])); + + expect(readOwnCliVersion(startUrl)).toBe("2.3.4"); + expect(fsMock.existsSync.mock.calls.map(([path]) => path)).toEqual([ + manifestPath(0), + manifestPath(1), + manifestPath(2), + ]); + }); + + it("does not adopt a foreign package version", () => { + const manifests = new Map(); + for (let index = 0; index < MAX_PACKAGE_LOOKUP_DEPTH; index += 1) { + manifests.set(manifestPath(index), JSON.stringify({ name: "@fusion/dashboard", version: "1.0.0" })); + } + configureManifests(manifests); + + expect(readOwnCliVersion(startUrl)).toBeUndefined(); + }); + + it("requires a string version", () => { + configureManifests(new Map([[manifestPath(0), fusionManifest(5)]])); + + expect(readOwnCliVersion(startUrl)).toBeUndefined(); + }); + + it("continues after a malformed manifest", () => { + configureManifests(new Map([ + [manifestPath(0), "{not json"], + [manifestPath(1), fusionManifest("3.4.5")], + ])); + + expect(readOwnCliVersion(startUrl)).toBe("3.4.5"); + }); + + it("continues after a manifest read error", () => { + configureManifests(new Map([ + [manifestPath(0), new Error("EACCES")], + [manifestPath(1), fusionManifest("4.5.6")], + ])); + + expect(readOwnCliVersion(startUrl)).toBe("4.5.6"); + }); + + it("does not inspect the ninth ancestor", () => { + configureManifests(new Map([[depthManifestPath(MAX_PACKAGE_LOOKUP_DEPTH), fusionManifest("9.9.9")]])); + + expect(readOwnCliVersion(depthStartUrl)).toBeUndefined(); + expect(fsMock.existsSync).toHaveBeenCalledTimes(MAX_PACKAGE_LOOKUP_DEPTH); + }); + + it("inspects the final ancestor inside the depth limit", () => { + configureManifests(new Map([[depthManifestPath(MAX_PACKAGE_LOOKUP_DEPTH - 1), fusionManifest("8.8.8")]])); + + expect(readOwnCliVersion(depthStartUrl)).toBe("8.8.8"); + }); + + it("terminates at the filesystem root", () => { + expect(readOwnCliVersion("file:///module.js")).toBeUndefined(); + expect(fsMock.existsSync).toHaveBeenCalledOnce(); + }); + + it("returns undefined for an invalid URL without filesystem access", () => { + expect(readOwnCliVersion("not-a-file-url")).toBeUndefined(); + expect(fsMock.existsSync).not.toHaveBeenCalled(); + expect(fsMock.readFileSync).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/cli/src/__tests__/plugin-scaffold-caret-fallback.test.ts b/packages/cli/src/__tests__/plugin-scaffold-caret-fallback.test.ts new file mode 100644 index 0000000000..aad990b7e3 --- /dev/null +++ b/packages/cli/src/__tests__/plugin-scaffold-caret-fallback.test.ts @@ -0,0 +1,91 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { mkdirSync, readFileSync as realReadFileSync, rmSync } from "node:fs"; +import { dirname, join, resolve } from "node:path"; +import { tmpdir } from "node:os"; +import { fileURLToPath } from "node:url"; + +const manifestOverrides = vi.hoisted(() => new Map()); + +vi.mock("node:fs", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + existsSync(path: Parameters[0]) { + const pathString = String(path); + if (manifestOverrides.has(pathString)) { + return true; + } + if (pathString.endsWith("/package.json") && !pathString.includes("fn-scaffold-version-")) { + return false; + } + return actual.existsSync(path); + }, + readFileSync(path: Parameters[0], ...args: Parameters[1][]) { + const pathString = String(path); + const manifest = manifestOverrides.get(pathString); + if (manifest !== undefined) { + return manifest; + } + return actual.readFileSync(path, ...args); + }, + }; +}); + +import { runPluginNew } from "../commands/plugin-scaffold.js"; + +const scaffoldModuleDir = dirname(fileURLToPath(new URL("../commands/plugin-scaffold.ts", import.meta.url))); +const tmpBase = join(tmpdir(), `fn-scaffold-version-${Date.now()}-${Math.random().toString(36).slice(2)}`); + +function ancestorManifestPath(index: number): string { + let directory = scaffoldModuleDir; + for (let current = 0; current < index; current += 1) { + directory = resolve(directory, ".."); + } + return join(directory, "package.json"); +} + +async function scaffoldVersion(outputName: string): Promise { + const output = join(tmpBase, outputName); + await runPluginNew("version-fixture", { output }); + const manifest = JSON.parse(realReadFileSync(join(output, "package.json"), "utf-8")) as { + devDependencies: Record; + }; + return manifest.devDependencies["@runfusion/fusion"]; +} + +describe("plugin scaffold Fusion caret version characterization", () => { + beforeEach(() => { + manifestOverrides.clear(); + mkdirSync(tmpBase, { recursive: true }); + }); + + afterEach(() => { + rmSync(tmpBase, { recursive: true, force: true }); + }); + + it("stops before the ninth ancestor and reaches the eighth", async () => { + manifestOverrides.set( + ancestorManifestPath(8), + JSON.stringify({ name: "@runfusion/fusion", version: "9.9.9" }), + ); + expect(await scaffoldVersion("ninth-ancestor")).toBe("^0.39.0"); + + manifestOverrides.clear(); + manifestOverrides.set( + ancestorManifestPath(7), + JSON.stringify({ name: "@runfusion/fusion", version: "9.9.9" }), + ); + expect(await scaffoldVersion("eighth-ancestor")).toBe("^9.9.9"); + }); + + it("falls back when no matching Fusion manifest exists", async () => { + manifestOverrides.set( + ancestorManifestPath(2), + JSON.stringify({ name: "@fusion/dashboard", version: "1.0.0" }), + ); + + const version = await scaffoldVersion("foreign-manifest"); + expect(version).toBe("^0.39.0"); + expect(version).not.toBe("^1.0.0"); + }); +}); diff --git a/packages/cli/src/bin.ts b/packages/cli/src/bin.ts index 0a7c0bf7ec..5a945c14b3 100644 --- a/packages/cli/src/bin.ts +++ b/packages/cli/src/bin.ts @@ -13,11 +13,11 @@ */ import { existsSync, mkdtempSync, readFileSync, symlinkSync, writeFileSync } from "node:fs"; import { createRequire } from "node:module"; -import { join, dirname, resolve } from "node:path"; +import { join, dirname } from "node:path"; import { tmpdir } from "node:os"; import { performance } from "node:perf_hooks"; import { Readable } from "node:stream"; -import { fileURLToPath } from "node:url"; +import { readOwnCliVersion } from "./cli-version.js"; import { installQuietGate, resolveQuietMode, setQuietMode, uninstallQuietGate } from "./output.js"; // @ts-expect-error -- Bun-only global; undefined in Node @@ -644,41 +644,6 @@ function parsePrCreateOptions(args: string[]) { }; } -/** - * Locate `@runfusion/fusion`'s own version by walking up from the running - * `bin.js`. Mirrors `packages/dashboard/src/cli-package-version.ts` but is - * inlined here to avoid pulling the dashboard barrel into the bin's static - * import graph (bin keeps app imports dynamic until env bootstrap is done). - */ -function readOwnCliVersion(): string | undefined { - let currentDir: string; - try { - currentDir = dirname(fileURLToPath(import.meta.url)); - } catch { - return undefined; - } - for (let i = 0; i < 8; i += 1) { - const pkgPath = resolve(currentDir, "package.json"); - if (existsSync(pkgPath)) { - try { - const parsed = JSON.parse(readFileSync(pkgPath, "utf-8")) as { - name?: string; - version?: string; - }; - if (parsed.name === "@runfusion/fusion" && typeof parsed.version === "string") { - return parsed.version; - } - } catch { - // Ignore malformed manifest and keep walking. - } - } - const parentDir = resolve(currentDir, ".."); - if (parentDir === currentDir) break; - currentDir = parentDir; - } - return undefined; -} - async function main() { const { cleanedArgs: args, projectName, skipOnboarding, quiet } = extractGlobalProjectFlag(process.argv.slice(2)); const hasJsonFlag = args.includes("--json"); @@ -694,12 +659,14 @@ async function main() { if (effectiveQuiet) installQuietGate(); else uninstallQuietGate(); - // Print version and exit before any application imports. This is what the + // Print version and exit before any application imports. The leaf resolver +// keeps this static graph built-ins-only rather than importing the dashboard +// resolver. This is what the // dashboard's CLI Binary panel probes via ` --version`; without an // early exit, the flag falls through to the default `dashboard` command and // boots the full server. if (args.includes("--version") || args.includes("-v")) { - console.log(readOwnCliVersion() ?? "unknown"); + console.log(readOwnCliVersion(import.meta.url) ?? "unknown"); process.exit(0); } diff --git a/packages/cli/src/cli-version.ts b/packages/cli/src/cli-version.ts new file mode 100644 index 0000000000..8df53936dc --- /dev/null +++ b/packages/cli/src/cli-version.ts @@ -0,0 +1,51 @@ +import { existsSync, readFileSync } from "node:fs"; +import { dirname, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; + +export const CLI_PACKAGE_NAME = "@runfusion/fusion"; +export const MAX_PACKAGE_LOOKUP_DEPTH = 8; + +/** + * FNXC:CliVersionResolution 2026-08-11-08:34: + * The CLI bin, update command, and plugin scaffold formerly kept byte-equivalent + * self-version walkers. Centralizing them prevents drift while each caller passes + * its own import.meta.url so its resolution start directory remains unchanged. + * + * This must remain a built-ins-only leaf: bin resolves --version before app imports + * so the dashboard CLI Binary probe cannot bootstrap the application. Its node:fs + * surface is intentionally limited to existsSync/readFileSync because update tests + * replace that module with only those APIs. The eight-level bound is pinned in both + * directions by plugin-scaffold-caret-fallback.test.ts. Do not merge the dashboard + * resolver or self-extension resolver: they have richer and different contracts. + */ +export function readOwnCliVersion(importMetaUrl: string = import.meta.url): string | undefined { + let currentDir: string; + try { + currentDir = dirname(fileURLToPath(importMetaUrl)); + } catch { + return undefined; + } + + for (let i = 0; i < MAX_PACKAGE_LOOKUP_DEPTH; i += 1) { + const pkgPath = resolve(currentDir, "package.json"); + if (existsSync(pkgPath)) { + try { + const parsed = JSON.parse(readFileSync(pkgPath, "utf-8")) as { + name?: string; + version?: string; + }; + if (parsed.name === CLI_PACKAGE_NAME && typeof parsed.version === "string") { + return parsed.version; + } + } catch { + // Ignore malformed manifest and keep walking. + } + } + + const parentDir = resolve(currentDir, ".."); + if (parentDir === currentDir) break; + currentDir = parentDir; + } + + return undefined; +} diff --git a/packages/cli/src/commands/__tests__/update.test.ts b/packages/cli/src/commands/__tests__/update.test.ts index d8299046f2..8119c8dcd7 100644 --- a/packages/cli/src/commands/__tests__/update.test.ts +++ b/packages/cli/src/commands/__tests__/update.test.ts @@ -52,7 +52,7 @@ vi.mock("../../update-cache.js", () => ({ // channel helpers, but importing the @fusion/core barrel would drag core's // git-binary through this file's node:child_process mock. Substitute the // barrel with the actual app-version source module (the only part used here). -vi.mock("@fusion/core", async () => await vi.importActual("../../../../core/src/app-version.js")); +vi.mock("@fusion/core", async () => await vi.importActual("../../../../core/src/i18n/app-version.js")); import { runUpdate } from "../update.js"; diff --git a/packages/cli/src/commands/plugin-scaffold.ts b/packages/cli/src/commands/plugin-scaffold.ts index 4dc61ae4ce..21f11cb604 100644 --- a/packages/cli/src/commands/plugin-scaffold.ts +++ b/packages/cli/src/commands/plugin-scaffold.ts @@ -5,9 +5,10 @@ * Usage: fn plugin create */ -import { mkdirSync, writeFileSync, existsSync, readFileSync } from "node:fs"; -import { join, dirname, resolve } from "node:path"; -import { fileURLToPath } from "node:url"; +import { mkdirSync, writeFileSync, existsSync } from "node:fs"; +import { join, resolve } from "node:path"; + +import { readOwnCliVersion } from "../cli-version.js"; // Valid plugin name pattern: kebab-case const PLUGIN_NAME_REGEX = /^[a-z0-9][a-z0-9-]*[a-z0-9]$/; @@ -50,39 +51,8 @@ function ensureTargetPathAvailable(targetDir: string, targetPath: string): void } } -function readOwnCliVersion(): string | undefined { - let currentDir: string; - try { - currentDir = dirname(fileURLToPath(import.meta.url)); - } catch { - return undefined; - } - - for (let i = 0; i < 8; i += 1) { - const pkgPath = resolve(currentDir, "package.json"); - if (existsSync(pkgPath)) { - try { - const parsed = JSON.parse(readFileSync(pkgPath, "utf-8")) as { - name?: string; - version?: string; - }; - if (parsed.name === "@runfusion/fusion" && typeof parsed.version === "string") { - return parsed.version; - } - } catch { - // Ignore malformed package.json and continue walking. - } - } - const parentDir = resolve(currentDir, ".."); - if (parentDir === currentDir) break; - currentDir = parentDir; - } - - return undefined; -} - function resolveFusionCaretVersion(): string { - const version = readOwnCliVersion() ?? DEFAULT_RUNFUSION_VERSION; + const version = readOwnCliVersion(import.meta.url) ?? DEFAULT_RUNFUSION_VERSION; return `^${version}`; } diff --git a/packages/cli/src/commands/update.ts b/packages/cli/src/commands/update.ts index e08fecafed..63d080a55e 100644 --- a/packages/cli/src/commands/update.ts +++ b/packages/cli/src/commands/update.ts @@ -1,9 +1,7 @@ import { exec } from "node:child_process"; +import { readOwnCliVersion } from "../cli-version.js"; import { result } from "../output.js"; -import { existsSync, readFileSync } from "node:fs"; import { promisify } from "node:util"; -import { dirname, resolve } from "node:path"; -import { fileURLToPath } from "node:url"; import { isVersionNewer, resolveUpdateTargetVersion } from "@fusion/core"; import type { UpdateChannel } from "@fusion/core"; import { getCachedUpdateStatus, getConfiguredUpdateChannel, persistUpdateChannel } from "../update-cache.js"; @@ -129,37 +127,6 @@ type UpdateStatus = { channel: UpdateChannel; }; -function readOwnCliVersion(): string | undefined { - let currentDir: string; - try { - currentDir = dirname(fileURLToPath(import.meta.url)); - } catch { - return undefined; - } - - for (let i = 0; i < 8; i += 1) { - const pkgPath = resolve(currentDir, "package.json"); - if (existsSync(pkgPath)) { - try { - const parsed = JSON.parse(readFileSync(pkgPath, "utf-8")) as { name?: string; version?: string }; - if (parsed.name === "@runfusion/fusion" && typeof parsed.version === "string") { - return parsed.version; - } - } catch { - // Ignore parse errors and keep walking. - } - } - - const parentDir = resolve(currentDir, ".."); - if (parentDir === currentDir) { - break; - } - currentDir = parentDir; - } - - return undefined; -} - type UpdateDistTags = { latest?: string; beta?: string; @@ -406,7 +373,7 @@ export async function runUpdate(options: RunUpdateOptions = {}): Promise { channel = await getConfiguredUpdateChannel(); } - const currentVersion = readOwnCliVersion(); + const currentVersion = readOwnCliVersion(import.meta.url); if (!currentVersion) { console.error("Error: Could not determine current Fusion CLI version."); process.exit(1);