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:
136
packages/cli/src/__tests__/cli-version.test.ts
Normal file
136
packages/cli/src/__tests__/cli-version.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
51
packages/cli/src/cli-version.ts
Normal file
51
packages/cli/src/cli-version.ts
Normal 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;
|
||||
}
|
||||
@@ -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";
|
||||
|
||||
|
||||
@@ -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}`;
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user