FN-8985: centralize CLI version resolution

Centralize CLI self-version discovery without widening the early boot import graph.

- Reuse one bounded manifest resolver for the CLI binary, updater, and plugin scaffold.
- Add regression coverage for manifest lookup bounds, malformed manifests, and scaffold version fallback.
- Preserve update command test isolation after the core i18n module relocation.

Files changed:
 packages/cli/src/__tests__/cli-version.test.ts     | 136 +++++++++++++++++++++
 .../plugin-scaffold-caret-fallback.test.ts         |  91 ++++++++++++++
 packages/cli/src/bin.ts                            |  45 +------
 packages/cli/src/cli-version.ts                    |  51 ++++++++
 packages/cli/src/commands/__tests__/update.test.ts |   2 +-
 packages/cli/src/commands/plugin-scaffold.ts       |  40 +-----
 packages/cli/src/commands/update.ts                |  37 +-----
 7 files changed, 292 insertions(+), 110 deletions(-)

Fusion-Task-Id: FN-8985

Fusion-Task-Lineage: dd95b651-3578-4c5f-957a-5af7b85129d7

Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
This commit is contained in:
gsxdsm
2026-08-11 01:51:22 -07:00
parent 8f6190910d
commit 62979d8796
7 changed files with 292 additions and 110 deletions

View File

@@ -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<string, Manifest>): 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<string, Manifest>();
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();
});
});

View File

@@ -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<string, string>());
vi.mock("node:fs", async (importOriginal) => {
const actual = await importOriginal<typeof import("node:fs")>();
return {
...actual,
existsSync(path: Parameters<typeof actual.existsSync>[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<typeof actual.readFileSync>[0], ...args: Parameters<typeof actual.readFileSync>[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<string> {
const output = join(tmpBase, outputName);
await runPluginNew("version-fixture", { output });
const manifest = JSON.parse(realReadFileSync(join(output, "package.json"), "utf-8")) as {
devDependencies: Record<string, string>;
};
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");
});
});

View File

@@ -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 `<bin> --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);
}

View File

@@ -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;
}

View File

@@ -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";

View File

@@ -5,9 +5,10 @@
* Usage: fn plugin create <name>
*/
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}`;
}

View File

@@ -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<void> {
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);