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:
5
.changeset/fn-5614-fn-update-bin-collision.md
Normal file
5
.changeset/fn-5614-fn-update-bin-collision.md
Normal 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.
|
||||||
@@ -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:
|
||||||
|
|||||||
@@ -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 () => {
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
Reference in New Issue
Block a user