feat(FN-3310): add fn update command to CLI reference

Adds a changeset for a new `fn` command and updates the CLI reference documentation with the command details.

Fusion-Task-Id: FN-3310
This commit is contained in:
Fusion
2026-05-04 14:12:18 -07:00
committed by gsxdsm
parent 88171eb5b3
commit 9dc7275970
5 changed files with 424 additions and 0 deletions

View File

@@ -136,6 +136,7 @@ async function loadCommandHandlers() {
const { runPluginCreate } = await import("./commands/plugin-scaffold.js");
const { runSkillsSearch, runSkillsInstall } = await import("./commands/skills.js");
const { runResearchCreate, runResearchList, runResearchShow, runResearchExport, runResearchCancel, runResearchRetry } = await import("./commands/research.js");
const { runUpdate } = await import("./commands/update.js");
return {
runDashboard,
@@ -222,6 +223,7 @@ async function loadCommandHandlers() {
runResearchExport,
runResearchCancel,
runResearchRetry,
runUpdate,
};
}
@@ -243,6 +245,8 @@ Usage:
fn desktop Launch the Fusion desktop app (Electron)
fn desktop --dev Launch with hot-reload (connects to Vite dev server)
fn desktop --paused Launch with automation paused
fn update [--check] [--global] [--json] Update Fusion to the latest version
fn upgrade Alias for fn update
fn task create [desc] [opts] Create a new task (goes to triage; supports --node <name>)
fn task plan [description] [opts] Create task via AI-guided planning
fn task list List all tasks
@@ -558,6 +562,7 @@ async function main() {
runResearchExport,
runResearchCancel,
runResearchRetry,
runUpdate,
} = await loadCommandHandlers();
try {
@@ -636,6 +641,16 @@ async function main() {
break;
}
case "update":
case "upgrade": {
await runUpdate({
check: args.includes("--check"),
global: args.includes("--global") ? true : undefined,
json: args.includes("--json"),
});
break;
}
case "project": {
const subcommand = args[1];
switch (subcommand) {

View File

@@ -0,0 +1,160 @@
import { beforeEach, afterEach, describe, expect, it, vi } from "vitest";
const { execAsyncMock, existsSyncMock, readFileSyncMock, getCachedUpdateStatusMock } = vi.hoisted(() => ({
execAsyncMock: vi.fn<(...args: unknown[]) => Promise<{ stdout: string; stderr: string }>>(),
existsSyncMock: vi.fn<(path: string) => boolean>(),
readFileSyncMock: vi.fn<(path: string, encoding: BufferEncoding) => string>(),
getCachedUpdateStatusMock: vi.fn<(currentVersion?: string) => {
updateAvailable: boolean;
latestVersion: string;
currentVersion: string;
} | null>(),
}));
vi.mock("node:child_process", async () => {
const { promisify } = await import("node:util");
const execFn: Record<PropertyKey, unknown> = vi.fn();
execFn[promisify.custom] = execAsyncMock;
return { exec: execFn };
});
vi.mock("node:fs", () => ({
existsSync: existsSyncMock,
readFileSync: readFileSyncMock,
}));
vi.mock("../../update-cache.js", () => ({
getCachedUpdateStatus: getCachedUpdateStatusMock,
}));
import { runUpdate } from "../update.js";
describe("runUpdate", () => {
let logSpy: ReturnType<typeof vi.spyOn>;
let errorSpy: ReturnType<typeof vi.spyOn>;
let exitSpy: ReturnType<typeof vi.spyOn>;
beforeEach(() => {
vi.clearAllMocks();
process.exitCode = 0;
existsSyncMock.mockImplementation((path: string) => path.endsWith("package.json"));
readFileSyncMock.mockReturnValue(JSON.stringify({ name: "@runfusion/fusion", version: "1.2.3" }));
getCachedUpdateStatusMock.mockReturnValue(null);
logSpy = vi.spyOn(console, "log").mockImplementation(() => {});
errorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
exitSpy = vi.spyOn(process, "exit").mockImplementation((code?: number | string | null) => {
throw new Error(`process.exit:${code ?? 0}`);
});
});
afterEach(() => {
logSpy.mockRestore();
errorSpy.mockRestore();
exitSpy.mockRestore();
});
it("reports already up to date when current version matches latest", async () => {
vi.stubGlobal("fetch", vi.fn().mockResolvedValue({ json: vi.fn().mockResolvedValue({ "dist-tags": { latest: "1.2.3" } }) }));
await runUpdate();
expect(execAsyncMock).not.toHaveBeenCalled();
expect(logSpy).toHaveBeenCalledWith("Already up to date.");
});
it("installs when update is available", async () => {
vi.stubGlobal("fetch", vi.fn().mockResolvedValue({ json: vi.fn().mockResolvedValue({ "dist-tags": { latest: "1.2.4" } }) }));
execAsyncMock.mockResolvedValue({ stdout: "ok", stderr: "" });
await runUpdate();
expect(execAsyncMock).toHaveBeenCalledWith("npm install -g @runfusion/fusion@latest", expect.objectContaining({ timeout: 120_000 }));
expect(logSpy).toHaveBeenCalledWith("Update complete.");
});
it("check mode reports availability without installing and sets exit code", async () => {
vi.stubGlobal("fetch", vi.fn().mockResolvedValue({ json: vi.fn().mockResolvedValue({ "dist-tags": { latest: "1.2.4" } }) }));
await runUpdate({ check: true });
expect(execAsyncMock).not.toHaveBeenCalled();
expect(logSpy).toHaveBeenCalledWith("Update available.");
expect(process.exitCode).toBe(1);
});
it("json mode outputs expected payload", async () => {
vi.stubGlobal("fetch", vi.fn().mockResolvedValue({ json: vi.fn().mockResolvedValue({ "dist-tags": { latest: "1.2.3" } }) }));
await runUpdate({ json: true });
const output = logSpy.mock.calls[0]?.[0] as string;
const parsed = JSON.parse(output) as {
currentVersion: string;
latestVersion: string;
updateAvailable: boolean;
updated: boolean;
};
expect(parsed).toEqual({
currentVersion: "1.2.3",
latestVersion: "1.2.3",
updateAvailable: false,
updated: false,
});
});
it("returns helpful error on network failure without cache", async () => {
vi.stubGlobal("fetch", vi.fn().mockRejectedValue(new Error("network down")));
await expect(runUpdate({ check: true })).rejects.toThrow("process.exit:1");
expect(errorSpy).toHaveBeenCalledWith("Error checking for updates: network down");
});
it("uses cached version when network fails in check mode", async () => {
vi.stubGlobal("fetch", vi.fn().mockRejectedValue(new Error("network down")));
getCachedUpdateStatusMock.mockReturnValue({
updateAvailable: true,
currentVersion: "1.2.3",
latestVersion: "1.2.5",
});
await runUpdate({ check: true });
expect(logSpy).toHaveBeenCalledWith("Warning: npm registry unreachable, using cached update metadata.");
expect(logSpy).toHaveBeenCalledWith("Latest version: 1.2.5");
expect(process.exitCode).toBe(1);
});
it("returns helpful error when npm install fails", async () => {
vi.stubGlobal("fetch", vi.fn().mockResolvedValue({ json: vi.fn().mockResolvedValue({ "dist-tags": { latest: "1.2.4" } }) }));
execAsyncMock.mockRejectedValue(new Error("permission denied"));
await expect(runUpdate()).rejects.toThrow("process.exit:1");
expect(errorSpy).toHaveBeenCalledWith("Error installing update: permission denied");
});
it("handles semver comparisons for major, minor, and patch", async () => {
vi.stubGlobal("fetch", vi.fn().mockResolvedValue({ json: vi.fn().mockResolvedValue({ "dist-tags": { latest: "2.0.0" } }) }));
execAsyncMock.mockResolvedValue({ stdout: "ok", stderr: "" });
readFileSyncMock.mockReturnValueOnce(JSON.stringify({ name: "@runfusion/fusion", version: "1.9.9" }));
await runUpdate({ check: true });
expect(process.exitCode).toBe(1);
process.exitCode = 0;
vi.stubGlobal("fetch", vi.fn().mockResolvedValue({ json: vi.fn().mockResolvedValue({ "dist-tags": { latest: "1.3.0" } }) }));
readFileSyncMock.mockReturnValueOnce(JSON.stringify({ name: "@runfusion/fusion", version: "1.2.9" }));
await runUpdate({ check: true });
expect(process.exitCode).toBe(1);
process.exitCode = 0;
vi.stubGlobal("fetch", vi.fn().mockResolvedValue({ json: vi.fn().mockResolvedValue({ "dist-tags": { latest: "1.2.4" } }) }));
readFileSyncMock.mockReturnValueOnce(JSON.stringify({ name: "@runfusion/fusion", version: "1.2.3" }));
await runUpdate({ check: true });
expect(process.exitCode).toBe(1);
});
});

View File

@@ -0,0 +1,222 @@
import { exec } from "node:child_process";
import { existsSync, readFileSync } from "node:fs";
import { promisify } from "node:util";
import { dirname, resolve } from "node:path";
import { fileURLToPath } from "node:url";
import { getCachedUpdateStatus } from "../update-cache.js";
const execAsync = promisify(exec);
const REGISTRY_URL = "https://registry.npmjs.org/@runfusion%2Ffusion";
const INSTALL_COMMAND = "npm install -g @runfusion/fusion@latest";
export type RunUpdateOptions = {
check?: boolean;
global?: boolean;
json?: boolean;
};
type UpdateStatus = {
currentVersion: string;
latestVersion: string;
updateAvailable: boolean;
updated: boolean;
};
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;
}
function parseVersion(version: string): number[] {
return version
.split(".")
.slice(0, 3)
.map((part) => Number.parseInt(part, 10))
.map((part) => (Number.isFinite(part) ? part : 0));
}
function isRemoteNewer(remoteVersion: string, currentVersion: string): boolean {
const remote = parseVersion(remoteVersion);
const current = parseVersion(currentVersion);
const maxLength = Math.max(remote.length, current.length, 3);
for (let i = 0; i < maxLength; i += 1) {
const remotePart = remote[i] ?? 0;
const currentPart = current[i] ?? 0;
if (remotePart > currentPart) return true;
if (remotePart < currentPart) return false;
}
return false;
}
async function fetchLatestVersion(): Promise<string> {
const response = await fetch(REGISTRY_URL);
const payload = (await response.json()) as {
"dist-tags"?: {
latest?: string;
};
};
const latestVersion = payload?.["dist-tags"]?.latest;
if (typeof latestVersion !== "string" || latestVersion.length === 0) {
throw new Error("Could not determine latest version from npm registry response.");
}
return latestVersion;
}
async function installLatest(globalInstall: boolean): Promise<void> {
const command = globalInstall ? INSTALL_COMMAND : "npm install @runfusion/fusion@latest";
await execAsync(command, {
timeout: 120_000,
maxBuffer: 10 * 1024 * 1024,
});
}
function printStatus(status: UpdateStatus, checkOnly: boolean): void {
console.log(`Current version: ${status.currentVersion}`);
console.log(`Latest version: ${status.latestVersion}`);
if (!status.updateAvailable) {
console.log("Already up to date.");
return;
}
if (checkOnly) {
console.log("Update available.");
return;
}
if (status.updated) {
console.log("Update complete.");
}
}
function printJson(status: UpdateStatus): void {
console.log(JSON.stringify(status));
}
function getLatestVersionFallback(currentVersion: string): string | null {
const cached = getCachedUpdateStatus(currentVersion);
if (!cached) return null;
return cached.latestVersion;
}
export async function runUpdate(options: RunUpdateOptions = {}): Promise<void> {
const checkOnly = options.check === true;
const globalInstall = options.global !== false;
const jsonOutput = options.json === true;
const currentVersion = readOwnCliVersion();
if (!currentVersion) {
console.error("Error: Could not determine current Fusion CLI version.");
process.exit(1);
return;
}
let latestVersion: string;
try {
latestVersion = await fetchLatestVersion();
} catch (error) {
const fallbackVersion = getLatestVersionFallback(currentVersion);
if (!fallbackVersion) {
const message = error instanceof Error ? error.message : String(error);
console.error(`Error checking for updates: ${message}`);
process.exit(1);
return;
}
latestVersion = fallbackVersion;
if (!jsonOutput) {
console.log("Warning: npm registry unreachable, using cached update metadata.");
}
}
const updateAvailable = isRemoteNewer(latestVersion, currentVersion);
if (checkOnly) {
const checkStatus: UpdateStatus = {
currentVersion,
latestVersion,
updateAvailable,
updated: false,
};
if (jsonOutput) {
printJson(checkStatus);
} else {
printStatus(checkStatus, true);
}
if (updateAvailable) {
process.exitCode = 1;
}
return;
}
if (!updateAvailable) {
const status: UpdateStatus = {
currentVersion,
latestVersion,
updateAvailable: false,
updated: false,
};
if (jsonOutput) {
printJson(status);
} else {
printStatus(status, false);
}
return;
}
try {
await installLatest(globalInstall);
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
console.error(`Error installing update: ${message}`);
process.exit(1);
return;
}
const updatedStatus: UpdateStatus = {
currentVersion,
latestVersion,
updateAvailable: true,
updated: true,
};
if (jsonOutput) {
printJson(updatedStatus);
return;
}
printStatus(updatedStatus, false);
}