feat(FN-5632): add collision retry logic to fn update command
Adds collision retry logic to the `fn update` CLI command to handle binary path conflicts, including test coverage and a getting-started guide update. Fusion-Task-Id: FN-5632 Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai> Fusion-Task-Id: FN-5632
This commit is contained in:
@@ -128,13 +128,60 @@ describe("runUpdate", () => {
|
||||
expect(process.exitCode).toBe(1);
|
||||
});
|
||||
|
||||
it("returns helpful error when npm install fails", async () => {
|
||||
it("retries once with --force when EEXIST bin collision is detected", async () => {
|
||||
vi.stubGlobal("fetch", vi.fn().mockResolvedValue({ json: vi.fn().mockResolvedValue({ "dist-tags": { latest: "1.2.4" } }) }));
|
||||
execAsyncMock.mockRejectedValue(new Error("permission denied"));
|
||||
execAsyncMock
|
||||
.mockRejectedValueOnce(new Error("npm ERR! code EEXIST\nnpm ERR! path /usr/local/bin/fn\nnpm ERR! File exists"))
|
||||
.mockResolvedValueOnce({ stdout: "ok", stderr: "" });
|
||||
|
||||
await runUpdate();
|
||||
|
||||
expect(execAsyncMock).toHaveBeenCalledTimes(2);
|
||||
expect((execAsyncMock.mock.calls[1] ?? [""])[0]).toContain("npm install --force -g @runfusion/fusion@latest");
|
||||
expect(errorSpy).toHaveBeenCalledWith("Detected legacy runfusion.ai bin symlinks; retrying update with --force.");
|
||||
expect(logSpy).toHaveBeenCalledWith("Update complete.");
|
||||
});
|
||||
|
||||
it("retries local install with --force when collision detected", async () => {
|
||||
vi.stubGlobal("fetch", vi.fn().mockResolvedValue({ json: vi.fn().mockResolvedValue({ "dist-tags": { latest: "1.2.4" } }) }));
|
||||
execAsyncMock
|
||||
.mockRejectedValueOnce(new Error("npm ERR! code EEXIST\nnpm ERR! path /usr/local/bin/fusion\nnpm ERR! File exists"))
|
||||
.mockResolvedValueOnce({ stdout: "ok", stderr: "" });
|
||||
|
||||
await runUpdate({ global: false });
|
||||
|
||||
expect(execAsyncMock).toHaveBeenCalledTimes(2);
|
||||
expect((execAsyncMock.mock.calls[1] ?? [""])[0]).toContain("npm install --force @runfusion/fusion@latest");
|
||||
expect((execAsyncMock.mock.calls[1] ?? [""])[0]).not.toContain(" -g ");
|
||||
});
|
||||
|
||||
it("shows remediation when forced retry also fails", async () => {
|
||||
vi.stubGlobal("fetch", vi.fn().mockResolvedValue({ json: vi.fn().mockResolvedValue({ "dist-tags": { latest: "1.2.4" } }) }));
|
||||
execAsyncMock
|
||||
.mockRejectedValueOnce(new Error("npm ERR! code EEXIST\nnpm ERR! path /opt/homebrew/bin/fn\nnpm ERR! File exists"))
|
||||
.mockRejectedValueOnce(new Error("npm ERR! code EEXIST\nnpm ERR! path /opt/homebrew/bin/fn\nnpm ERR! File exists"));
|
||||
|
||||
const argvSpy = vi.spyOn(process, "argv", "get").mockReturnValue(["node", "/opt/homebrew/bin/fn"]);
|
||||
|
||||
await expect(runUpdate()).rejects.toThrow("process.exit:1");
|
||||
|
||||
expect(errorSpy).toHaveBeenCalledWith("Error installing update: permission denied");
|
||||
argvSpy.mockRestore();
|
||||
expect(execAsyncMock).toHaveBeenCalledTimes(2);
|
||||
expect(errorSpy).toHaveBeenCalledWith("Legacy runfusion.ai bin links blocked automatic update. Run:");
|
||||
expect(errorSpy).toHaveBeenCalledWith(" npm uninstall -g runfusion.ai");
|
||||
expect(errorSpy).toHaveBeenCalledWith(" rm -f $(command -v fn) $(command -v fusion)");
|
||||
expect(errorSpy).toHaveBeenCalledWith(" npm install -g @runfusion/fusion@latest");
|
||||
expect(errorSpy).toHaveBeenCalledWith(" brew uninstall fusion && brew install runfusion/tap/fusion");
|
||||
});
|
||||
|
||||
it("returns helpful error when npm install fails without collision", async () => {
|
||||
vi.stubGlobal("fetch", vi.fn().mockResolvedValue({ json: vi.fn().mockResolvedValue({ "dist-tags": { latest: "1.2.4" } }) }));
|
||||
execAsyncMock.mockRejectedValue(new Error("network down"));
|
||||
|
||||
await expect(runUpdate()).rejects.toThrow("process.exit:1");
|
||||
|
||||
expect(execAsyncMock).toHaveBeenCalledTimes(1);
|
||||
expect(errorSpy).toHaveBeenCalledWith("Error installing update: network down");
|
||||
});
|
||||
|
||||
it("handles semver comparisons for major, minor, and patch", async () => {
|
||||
|
||||
@@ -8,6 +8,7 @@ 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";
|
||||
const LOCAL_INSTALL_COMMAND = "npm install @runfusion/fusion@latest";
|
||||
|
||||
export type RunUpdateOptions = {
|
||||
check?: boolean;
|
||||
@@ -92,12 +93,78 @@ async function fetchLatestVersion(): Promise<string> {
|
||||
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 getInstallCommand(globalInstall: boolean, force = false): string {
|
||||
const baseCommand = globalInstall ? INSTALL_COMMAND : LOCAL_INSTALL_COMMAND;
|
||||
return force ? baseCommand.replace("npm install", "npm install --force") : baseCommand;
|
||||
}
|
||||
|
||||
type InstallError = Error & { stdout?: string; stderr?: string };
|
||||
|
||||
function isBinCollisionInstallError(error: unknown): boolean {
|
||||
const installError = error as InstallError;
|
||||
const message = [installError?.message, installError?.stderr, installError?.stdout]
|
||||
.filter((part): part is string => typeof part === "string" && part.length > 0)
|
||||
.join("\n");
|
||||
|
||||
const hasBinHint = /\/(fn|fusion)\b|runfusion\.ai/i.test(message);
|
||||
if (!hasBinHint) return false;
|
||||
|
||||
return /EEXIST|ENOENT|File exists/i.test(message);
|
||||
}
|
||||
|
||||
function detectRunningBinaryPath(): string | null {
|
||||
const argvPath = process.argv[1];
|
||||
if (typeof argvPath === "string" && argvPath.length > 0) {
|
||||
return argvPath;
|
||||
}
|
||||
return typeof process.execPath === "string" ? process.execPath : null;
|
||||
}
|
||||
|
||||
function shouldSuggestHomebrewFix(binaryPath: string | null): boolean {
|
||||
if (!binaryPath) return false;
|
||||
return (
|
||||
binaryPath.startsWith("/opt/homebrew/") ||
|
||||
binaryPath.startsWith("/usr/local/Homebrew/") ||
|
||||
binaryPath.startsWith("/home/linuxbrew/")
|
||||
);
|
||||
}
|
||||
|
||||
function printCollisionRemediation(binaryPath: string | null): void {
|
||||
console.error("Legacy runfusion.ai bin links blocked automatic update. Run:");
|
||||
console.error(" npm uninstall -g runfusion.ai");
|
||||
console.error(" rm -f $(command -v fn) $(command -v fusion)");
|
||||
console.error(" npm install -g @runfusion/fusion@latest");
|
||||
if (shouldSuggestHomebrewFix(binaryPath)) {
|
||||
console.error("If installed via Homebrew, reinstall with:");
|
||||
console.error(" brew uninstall fusion && brew install runfusion/tap/fusion");
|
||||
}
|
||||
}
|
||||
|
||||
async function installLatest(globalInstall: boolean, resolveBinaryPath: () => string | null = detectRunningBinaryPath): Promise<void> {
|
||||
try {
|
||||
await execAsync(getInstallCommand(globalInstall), {
|
||||
timeout: 120_000,
|
||||
maxBuffer: 10 * 1024 * 1024,
|
||||
});
|
||||
return;
|
||||
} catch (error) {
|
||||
if (!isBinCollisionInstallError(error)) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
console.error("Detected legacy runfusion.ai bin symlinks; retrying update with --force.");
|
||||
|
||||
try {
|
||||
await execAsync(getInstallCommand(globalInstall, true), {
|
||||
timeout: 120_000,
|
||||
maxBuffer: 10 * 1024 * 1024,
|
||||
});
|
||||
return;
|
||||
} catch (forceError) {
|
||||
printCollisionRemediation(resolveBinaryPath());
|
||||
throw forceError;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function printStatus(status: UpdateStatus, checkOnly: boolean): void {
|
||||
|
||||
Reference in New Issue
Block a user