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:
gsxdsm
2026-05-28 14:49:23 -07:00
parent 6b27ab5aab
commit b96b0bc79a
4 changed files with 146 additions and 9 deletions

View File

@@ -0,0 +1,5 @@
---
"@runfusion/fusion": patch
---
Fix `fn update` npm EEXIST bin-link collisions by retrying once with `--force` and showing manual recovery guidance when the retry fails.

View File

@@ -54,6 +54,24 @@ fn --help
fusion --help fusion --help
``` ```
### Troubleshooting `fn update`
If `fn update` fails with an npm bin-link collision (for example `EEXIST` / `File exists` mentioning `fn` or `fusion`), Fusion now retries once with `--force` automatically.
If update still fails, run the manual recovery commands:
```bash
npm uninstall -g runfusion.ai
rm -f $(command -v fn) $(command -v fusion)
npm install -g @runfusion/fusion@latest
```
If you installed via Homebrew and links are still broken, reinstall the formula:
```bash
brew uninstall fusion && brew install runfusion/tap/fusion
```
## Initialize a Project ## Initialize a Project
In each repository you want Fusion to manage, run: In each repository you want Fusion to manage, run:

View File

@@ -128,13 +128,60 @@ describe("runUpdate", () => {
expect(process.exitCode).toBe(1); 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" } }) })); 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"); 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 () => { it("handles semver comparisons for major, minor, and patch", async () => {

View File

@@ -8,6 +8,7 @@ import { getCachedUpdateStatus } from "../update-cache.js";
const execAsync = promisify(exec); const execAsync = promisify(exec);
const REGISTRY_URL = "https://registry.npmjs.org/@runfusion%2Ffusion"; const REGISTRY_URL = "https://registry.npmjs.org/@runfusion%2Ffusion";
const INSTALL_COMMAND = "npm install -g @runfusion/fusion@latest"; const INSTALL_COMMAND = "npm install -g @runfusion/fusion@latest";
const LOCAL_INSTALL_COMMAND = "npm install @runfusion/fusion@latest";
export type RunUpdateOptions = { export type RunUpdateOptions = {
check?: boolean; check?: boolean;
@@ -92,12 +93,78 @@ async function fetchLatestVersion(): Promise<string> {
return latestVersion; return latestVersion;
} }
async function installLatest(globalInstall: boolean): Promise<void> { function getInstallCommand(globalInstall: boolean, force = false): string {
const command = globalInstall ? INSTALL_COMMAND : "npm install @runfusion/fusion@latest"; const baseCommand = globalInstall ? INSTALL_COMMAND : LOCAL_INSTALL_COMMAND;
await execAsync(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, timeout: 120_000,
maxBuffer: 10 * 1024 * 1024, 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 { function printStatus(status: UpdateStatus, checkOnly: boolean): void {