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:
@@ -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) {
|
||||
|
||||
160
packages/cli/src/commands/__tests__/update.test.ts
Normal file
160
packages/cli/src/commands/__tests__/update.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
222
packages/cli/src/commands/update.ts
Normal file
222
packages/cli/src/commands/update.ts
Normal 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);
|
||||
}
|
||||
Reference in New Issue
Block a user