FN-9042: fix dashboard update installation feedback
Make dashboard update actions report clear terminal outcomes and avoid unsupported installs. - Distinguish update checks, no-op responses, installation failures, and unsupported installation methods. - Block source-checkout, Homebrew, and missing-npm global installation attempts with actionable guidance. - Surface update results in dashboard controls and cover API, updater, and UI behavior with tests. Files changed: .changeset/fn-9042-update-now-outcomes.md | 7 ++ docs/dashboard-guide.md | 2 +- docs/settings-reference.md | 2 +- packages/dashboard/app/api/settings/settings.ts | 2 + .../dashboard/app/components/SettingsModal.tsx | 18 +-- .../app/components/UpdateAvailableBanner.tsx | 11 +- .../__tests__/SettingsModal.general.test.tsx | 20 ++++ .../__tests__/UpdateAvailableBanner.test.tsx | 19 ++++ .../dashboard/src/__tests__/auto-update.test.ts | 38 ++++++- .../dashboard/src/__tests__/update-check.test.ts | 19 +++- packages/dashboard/src/auto-update.ts | 33 +++++- .../__tests__/register-update-check-routes.test.ts | 104 ++++++++++++++++++ .../src/routes/register-update-check-routes.ts | 28 +++-- packages/dashboard/src/server.ts | 9 +- packages/dashboard/src/update-check.ts | 122 ++++++++++----------- 15 files changed, 338 insertions(+), 96 deletions(-) Fusion-Task-Id: FN-9042 Fusion-Task-Lineage: 22ae5540-e5b5-43cf-ad16-c7aa05782593 Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
This commit is contained in:
7
.changeset/fn-9042-update-now-outcomes.md
Normal file
7
.changeset/fn-9042-update-now-outcomes.md
Normal file
@@ -0,0 +1,7 @@
|
||||
---
|
||||
"@runfusion/fusion": patch
|
||||
---
|
||||
|
||||
summary: Show clear outcomes when dashboard updates cannot install.
|
||||
category: fix
|
||||
dev: Distinguishes failed checks from no-op updates and skips unsupported hosts.
|
||||
@@ -13,7 +13,7 @@ The Fusion dashboard is the main control plane for tasks, agents, missions, sett
|
||||
|
||||
## Dashboard Updates
|
||||
|
||||
When Fusion detects a newer `@runfusion/fusion` release, the Settings modal footer shows the available version with **Learn more** and **Update now** actions. **Update now** installs the latest global package with npm; after it succeeds, both the Settings update-success state and dashboard update banner offer a one-click **Restart Fusion** action because the already-running dashboard server is unchanged until restart. When Fusion is unsupervised (for example, started with `--no-supervise`), either action remains disabled and explains that Fusion must be restarted manually.
|
||||
When Fusion detects a newer `@runfusion/fusion` release, the Settings modal footer shows the available version with **Learn more** and **Update now** actions. Every Update now result remains visible: install success offers **Restart Fusion**, a current version reports no update, failed checks and installs show errors, and unsupported source-checkout, Homebrew, or missing-npm hosts show guidance instead of running a meaningless global install. The unattended updater likewise skips unsupported hosts without restarting. When Fusion is unsupervised (for example, started with `--no-supervise`), the restart action remains available so the server can explain the refusal; restart Fusion manually when it cannot be scheduled.
|
||||
|
||||
### Supervised source-checkout rebuilds
|
||||
|
||||
|
||||
@@ -279,7 +279,7 @@ Disable daily update checks globally:
|
||||
fn settings set updateCheckEnabled false
|
||||
```
|
||||
|
||||
When the dashboard footer reports that a newer `@runfusion/fusion` version is available, **Update now** runs the same global npm install as `fn update` (`npm install -g @runfusion/fusion@latest`) and retries once with `--force` for the legacy `fn`/`fusion` binary-collision case. A successful install updates the global package on disk, but the currently running Fusion server is not hot-swapped; restart Fusion to run the newly installed version.
|
||||
When the dashboard footer reports that a newer `@runfusion/fusion` version is available, **Update now** uses a pinned global npm install and retries once with `--force` for the legacy `fn`/`fusion` binary-collision case. Every request reports an explicit outcome: `installed`, `no-update-available`, `check-failed`, `unsupported-install-method`, or `failed`. A failed registry check is reported as a failure, never as “already up to date”. Source checkouts, Homebrew installs, and hosts without `npm` are refused before installation with actionable guidance; source-checkout auto-update logs a skip and never requests a restart. A successful install updates the global package on disk, but the currently running Fusion server is not hot-swapped; restart Fusion to run the newly installed version.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -45,6 +45,8 @@ export interface UpdateInstallResponse {
|
||||
currentVersion: string;
|
||||
latestVersion: string | null;
|
||||
updated: boolean;
|
||||
outcome?: "installed" | "no-update-available" | "check-failed" | "unsupported-install-method" | "failed";
|
||||
message?: string;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
|
||||
@@ -1773,11 +1773,6 @@ export function SettingsModal({
|
||||
const result = await installUpdate(projectId);
|
||||
setUpdateInstallResult(result);
|
||||
|
||||
if (result.error) {
|
||||
addToast(result.error, "error");
|
||||
return;
|
||||
}
|
||||
|
||||
if (result.updated) {
|
||||
addToast(t("settings.general.updateSuccessToast", "Update installed. Restart Fusion to apply it."), "success");
|
||||
/*
|
||||
@@ -1791,6 +1786,9 @@ export function SettingsModal({
|
||||
.catch(() => {
|
||||
// Keep whatever the mount probe resolved; the guidance text covers it.
|
||||
});
|
||||
} else {
|
||||
const message = result.message ?? result.error ?? t("settings.general.updateUnknown", "Update did not complete — see the Fusion logs");
|
||||
addToast(message, result.outcome === "check-failed" || result.outcome === "failed" || Boolean(result.error) ? "error" : "info");
|
||||
}
|
||||
} catch (error) {
|
||||
const message = getErrorMessage(error) || t("settings.general.updateFailed", "Update failed");
|
||||
@@ -1799,6 +1797,8 @@ export function SettingsModal({
|
||||
latestVersion: updateCheckResult?.latestVersion ?? null,
|
||||
updated: false,
|
||||
error: message,
|
||||
outcome: "failed",
|
||||
message,
|
||||
});
|
||||
addToast(message, "error");
|
||||
} finally {
|
||||
@@ -1876,6 +1876,8 @@ export function SettingsModal({
|
||||
if (updateCheckResult.updateAvailable && updateCheckResult.latestVersion) {
|
||||
const installSucceeded = updateInstallResult?.updated === true;
|
||||
const installError = updateInstallResult?.error;
|
||||
const installMessage = updateInstallResult?.message ?? installError ?? (updateInstallResult && !updateInstallResult.updated ? t("settings.general.updateUnknown", "Update did not complete — see the Fusion logs") : undefined);
|
||||
const installIsError = updateInstallResult?.outcome === "check-failed" || updateInstallResult?.outcome === "failed" || Boolean(installError && updateInstallResult?.outcome !== "unsupported-install-method");
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -1960,9 +1962,9 @@ export function SettingsModal({
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
{installError && (
|
||||
<span className="settings-update-install-status settings-update-install-status--error" aria-live="polite">
|
||||
{t("settings.general.updateFailedWithMessage", "Update failed: {{message}}", { message: installError })}
|
||||
{installMessage && !installSucceeded && (
|
||||
<span className={`settings-update-install-status${installIsError ? " settings-update-install-status--error" : ""}`} aria-live="polite">
|
||||
{installIsError ? t("settings.general.updateFailedWithMessage", "Update failed: {{message}}", { message: installMessage }) : installMessage}
|
||||
</span>
|
||||
)}
|
||||
</>
|
||||
|
||||
@@ -50,6 +50,8 @@ export function UpdateAvailableBanner({ latestVersion, currentVersion, onDismiss
|
||||
latestVersion,
|
||||
updated: false,
|
||||
error: getErrorMessage(error) || t("updateBanner.updateFailed", "Update failed"),
|
||||
outcome: "failed",
|
||||
message: getErrorMessage(error) || t("updateBanner.updateFailed", "Update failed"),
|
||||
});
|
||||
} finally {
|
||||
setInstallLoading(false);
|
||||
@@ -84,8 +86,11 @@ export function UpdateAvailableBanner({ latestVersion, currentVersion, onDismiss
|
||||
}
|
||||
};
|
||||
|
||||
/* FNXC:UpdateBanner 2026-08-14-19:31: an Update now result always remains visible; failed checks are errors, never up-to-date reassurance. */
|
||||
const installSucceeded = installResult?.updated === true;
|
||||
const installError = installResult?.error;
|
||||
const installMessage = installResult?.message ?? installError ?? (installResult && !installResult.updated ? t("updateBanner.updateUnknown", "Update did not complete — see the Fusion logs") : undefined);
|
||||
const installIsError = installResult?.outcome === "check-failed" || installResult?.outcome === "failed" || Boolean(installError && installResult?.outcome !== "unsupported-install-method");
|
||||
// Advisory guidance only — shown when the host explicitly reported no supervising parent.
|
||||
const restartUnavailable = restartSupported === false;
|
||||
|
||||
@@ -176,9 +181,9 @@ export function UpdateAvailableBanner({ latestVersion, currentVersion, onDismiss
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
{installError && (
|
||||
<span className="update-available-banner__install-status update-available-banner__install-status--error" aria-live="polite">
|
||||
{t("updateBanner.updateFailedWithMessage", "Update failed: {{message}}", { message: installError })}
|
||||
{installMessage && !installSucceeded && (
|
||||
<span className={`update-available-banner__install-status${installIsError ? " update-available-banner__install-status--error" : ""}`} aria-live="polite">
|
||||
{installIsError ? t("updateBanner.updateFailedWithMessage", "Update failed: {{message}}", { message: installMessage }) : installMessage}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -427,6 +427,26 @@ describe("SettingsModal", () => {
|
||||
expect(screen.queryByText(/Needs a supervising parent/)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it.each([
|
||||
["installed", { currentVersion: "1.2.3", latestVersion: "2.0.0", updated: true, outcome: "installed" }, /Updated to v2\.0\.0/, true],
|
||||
["no-update-available", { currentVersion: "1.2.3", latestVersion: "2.0.0", updated: false, outcome: "no-update-available", message: "Fusion is already up to date." }, /already up to date/i, false],
|
||||
["check-failed", { currentVersion: "1.2.3", latestVersion: null, updated: false, outcome: "check-failed", error: "registry unavailable", message: "Could not check for updates: registry unavailable" }, /Update failed: Could not check for updates: registry unavailable/, false],
|
||||
["unsupported-install-method", { currentVersion: "1.2.3", latestVersion: "2.0.0", updated: false, outcome: "unsupported-install-method", message: "Use pull and rebuild for this source checkout." }, /pull and rebuild/i, false],
|
||||
["failed", { currentVersion: "1.2.3", latestVersion: "2.0.0", updated: false, outcome: "failed", error: "npm failed", message: "npm failed" }, /Update failed: npm failed/, false],
|
||||
])("renders the %s install outcome in the Settings live status", async (_outcome, response, expected, restarts) => {
|
||||
mockCheckForUpdates.mockResolvedValue(availableUpdate);
|
||||
mockInstallUpdate.mockResolvedValue(response);
|
||||
renderModal();
|
||||
await waitForSettingsModalReady();
|
||||
await settingsModalUser.click(screen.getByRole("button", { name: "Check for updates" }));
|
||||
await settingsModalUser.click(await screen.findByRole("button", { name: "Update now" }));
|
||||
|
||||
const status = await screen.findByText(expected);
|
||||
expect(status).toHaveAttribute("aria-live", "polite");
|
||||
if (_outcome === "check-failed") expect(status).not.toHaveTextContent(/up to date/i);
|
||||
expect(screen.queryByRole("button", { name: "Restart Fusion" })).toBe(restarts ? screen.getByRole("button", { name: "Restart Fusion" }) : null);
|
||||
});
|
||||
|
||||
it("clears stale unsupported guidance by re-probing after a successful install", async () => {
|
||||
// Mount probe fails (fails closed to "unsupported"); the post-install re-probe
|
||||
// proves the host is supervised after all.
|
||||
|
||||
@@ -204,6 +204,25 @@ describe("UpdateAvailableBanner", () => {
|
||||
expect(screen.queryByRole("button", { name: "Restart Fusion" })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it.each([
|
||||
["installed", { ...successfulInstall, outcome: "installed" }, /Updated to v0\.7\.0/],
|
||||
["no-update-available", { currentVersion: "0.6.0", latestVersion: "0.7.0", updated: false, outcome: "no-update-available", message: "Fusion is already up to date." }, /already up to date/i],
|
||||
["check-failed", { currentVersion: "0.6.0", latestVersion: null, updated: false, outcome: "check-failed", error: "registry unavailable", message: "Could not check for updates: registry unavailable" }, /Could not check for updates: registry unavailable/],
|
||||
["unsupported-install-method", { currentVersion: "0.6.0", latestVersion: "0.7.0", updated: false, outcome: "unsupported-install-method", message: "Use pull and rebuild for this source checkout." }, /pull and rebuild/i],
|
||||
["failed", { currentVersion: "0.6.0", latestVersion: "0.7.0", updated: false, outcome: "failed", error: "npm failed", message: "npm failed" }, /Update failed: npm failed/],
|
||||
["legacy fallback", { currentVersion: "0.6.0", latestVersion: "0.7.0", updated: false }, /Update did not complete — see the Fusion logs/],
|
||||
])("renders the %s install outcome in a live status", async (_outcome, response, expected) => {
|
||||
mockInstallUpdate.mockResolvedValueOnce(response);
|
||||
renderBanner();
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Update now" }));
|
||||
|
||||
const status = await screen.findByText(expected);
|
||||
expect(status).toHaveAttribute("aria-live", "polite");
|
||||
if (_outcome === "check-failed") expect(status).not.toHaveTextContent(/up to date/i);
|
||||
if (_outcome === "installed") expect(screen.getByRole("button", { name: "Restart Fusion" })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it.each([
|
||||
["supported", true],
|
||||
["unsupported", false],
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { runAutoUpdateCycle, startAutoUpdateWatcher } from "../auto-update.js";
|
||||
import { buildAutoUpdateDeps, runAutoUpdateCycle, startAutoUpdateWatcher } from "../auto-update.js";
|
||||
import type { AutoUpdateDeps } from "../auto-update.js";
|
||||
|
||||
/*
|
||||
@@ -43,7 +44,7 @@ describe("runAutoUpdateCycle", () => {
|
||||
|
||||
await expect(runAutoUpdateCycle(deps)).resolves.toBe("restarting");
|
||||
|
||||
expect(deps.installUpdate).toHaveBeenCalledWith("1.0.0", "2.0.0", { fusionDir: deps.fusionDir });
|
||||
expect(deps.installUpdate).toHaveBeenCalledWith("1.0.0", "2.0.0", { fusionDir: deps.fusionDir, installMethod: { sourceWorkspaceRoot: undefined } });
|
||||
expect(deps.requestRestart).toHaveBeenCalledWith("auto-update");
|
||||
});
|
||||
|
||||
@@ -126,6 +127,20 @@ describe("runAutoUpdateCycle", () => {
|
||||
expect(deps.requestRestart).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not restart when the install discovers no update remains", async () => {
|
||||
const deps = makeDeps();
|
||||
deps.installUpdate.mockResolvedValue({
|
||||
currentVersion: "1.0.0",
|
||||
latestVersion: "2.0.0",
|
||||
updated: false,
|
||||
outcome: "no-update-available",
|
||||
message: "Fusion is already up to date.",
|
||||
});
|
||||
|
||||
await expect(runAutoUpdateCycle(deps)).resolves.toBe("up-to-date");
|
||||
expect(deps.requestRestart).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("reports a rejected install without throwing", async () => {
|
||||
const deps = makeDeps();
|
||||
deps.installUpdate.mockRejectedValue(new Error("EACCES"));
|
||||
@@ -223,4 +238,23 @@ describe("startAutoUpdateWatcher", () => {
|
||||
releaseInstall?.();
|
||||
stop();
|
||||
});
|
||||
it("skips unsupported source-checkout installs without restart", async () => {
|
||||
const deps = makeDeps({ sourceWorkspaceRoot: "/repo/fusion" });
|
||||
deps.installUpdate.mockResolvedValue({ currentVersion: "1.0.0", latestVersion: "2.0.0", updated: false, outcome: "unsupported-install-method", message: "source checkout" });
|
||||
await expect(runAutoUpdateCycle(deps)).resolves.toBe("unsupported-install-method");
|
||||
expect(deps.installUpdate).toHaveBeenCalledWith("1.0.0", "2.0.0", { fusionDir: deps.fusionDir, installMethod: { sourceWorkspaceRoot: "/repo/fusion" } });
|
||||
expect(deps.requestRestart).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("ratchets production watcher wiring through buildAutoUpdateDeps", () => {
|
||||
const serverSource = readFileSync(new URL("../server.ts", import.meta.url), "utf8");
|
||||
expect(serverSource).toContain("startAutoUpdateWatcher(buildAutoUpdateDeps(");
|
||||
expect(serverSource).not.toMatch(/startAutoUpdateWatcher\(\s*\{\s*getSettings:/s);
|
||||
});
|
||||
|
||||
it("buildAutoUpdateDeps preserves host system-control context", () => { const systemControl = { supervised: true, requestRestart: vi.fn(), sourceWorkspaceRoot: "/repo/fusion" };
|
||||
const deps = buildAutoUpdateDeps({ getSettings: async () => ({}), currentVersion: "1.0.0", systemControl, log: { info: vi.fn(), warn: vi.fn(), error: vi.fn() } });
|
||||
expect(deps).toMatchObject({ supervised: true, requestRestart: systemControl.requestRestart, sourceWorkspaceRoot: "/repo/fusion" });
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
@@ -197,7 +197,7 @@ describe("update-check", () => {
|
||||
timeout: 300_000,
|
||||
maxBuffer: 10 * 1024 * 1024,
|
||||
});
|
||||
expect(result).toEqual({ currentVersion: "1.0.0", latestVersion: "2.0.0", updated: true });
|
||||
expect(result).toEqual({ currentVersion: "1.0.0", latestVersion: "2.0.0", updated: true, outcome: "installed" });
|
||||
expect(existsSync(cachePath)).toBe(false);
|
||||
});
|
||||
|
||||
@@ -215,7 +215,7 @@ describe("update-check", () => {
|
||||
expect(execFake).toHaveBeenCalledTimes(2);
|
||||
expect(execFake).toHaveBeenNthCalledWith(1, "npm install -g @runfusion/fusion@2.0.0", expect.any(Object));
|
||||
expect(execFake).toHaveBeenNthCalledWith(2, "npm install --force -g @runfusion/fusion@2.0.0", expect.any(Object));
|
||||
expect(result).toEqual({ currentVersion: "1.0.0", latestVersion: "2.0.0", updated: true });
|
||||
expect(result).toEqual({ currentVersion: "1.0.0", latestVersion: "2.0.0", updated: true, outcome: "installed" });
|
||||
});
|
||||
|
||||
it("performUpdateInstall returns an error result for non-collision install failures", async () => {
|
||||
@@ -225,6 +225,8 @@ describe("update-check", () => {
|
||||
currentVersion: "1.0.0",
|
||||
latestVersion: "2.0.0",
|
||||
updated: false,
|
||||
outcome: "failed",
|
||||
message: "registry down",
|
||||
error: "registry down",
|
||||
});
|
||||
expect(execFake).toHaveBeenCalledTimes(1);
|
||||
@@ -245,6 +247,8 @@ describe("update-check", () => {
|
||||
currentVersion: "1.0.0",
|
||||
latestVersion: "2.0.0",
|
||||
updated: false,
|
||||
outcome: "failed",
|
||||
message: expect.stringMatching(/timed out after 5 minutes.*terminal/i),
|
||||
error: expect.stringMatching(/timed out after 5 minutes.*terminal/i),
|
||||
});
|
||||
expect(result.error).toContain("npm install -g @runfusion/fusion@2.0.0");
|
||||
@@ -623,4 +627,15 @@ describe("update-check", () => {
|
||||
expect(result.error).toContain("No valid update target version");
|
||||
});
|
||||
});
|
||||
|
||||
it("refuses source checkouts and missing npm before meaningful install work", async () => {
|
||||
const execFake = vi.fn();
|
||||
const source = await performUpdateInstall("1.0.0", "2.0.0", { exec: execFake, fusionDir, installMethod: { sourceWorkspaceRoot: "/repo/fusion" } });
|
||||
expect(source.outcome).toBe("unsupported-install-method");
|
||||
expect(source.message).toContain("source checkout");
|
||||
expect(execFake).not.toHaveBeenCalled();
|
||||
const missing = await performUpdateInstall("1.0.0", "2.0.0", { exec: vi.fn().mockRejectedValue(Object.assign(new Error("spawn npm ENOENT"), { code: "ENOENT" })), fusionDir });
|
||||
expect(missing.outcome).toBe("unsupported-install-method");
|
||||
expect(missing.message).toContain("npm");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -45,6 +45,7 @@ export type AutoUpdateOutcome =
|
||||
| "up-to-date"
|
||||
| "check-failed"
|
||||
| "install-failed"
|
||||
| "unsupported-install-method"
|
||||
| "restart-unavailable"
|
||||
| "restarting";
|
||||
|
||||
@@ -63,11 +64,29 @@ export interface AutoUpdateDeps {
|
||||
requestRestart: (reason: string) => boolean;
|
||||
log: AutoUpdateLogger;
|
||||
fusionDir?: string;
|
||||
/** Host-injected checkout root: global npm cannot update this running program. */
|
||||
sourceWorkspaceRoot?: string;
|
||||
/** Test seams. */
|
||||
checkForUpdate?: typeof performUpdateCheck;
|
||||
installUpdate?: typeof performUpdateInstall;
|
||||
}
|
||||
|
||||
/*
|
||||
FNXC:AutoUpdate 2026-08-14-19:31:
|
||||
Only the dashboard host knows whether Fusion runs from a source checkout. Keep
|
||||
that context in this exported builder so unattended installs cannot restart a
|
||||
program that a global npm install could never change.
|
||||
*/
|
||||
export function buildAutoUpdateDeps(input: {
|
||||
getSettings: () => Promise<AutoUpdateSettings>;
|
||||
currentVersion: string;
|
||||
systemControl: { supervised: boolean; requestRestart: (reason: string) => boolean; sourceWorkspaceRoot?: string };
|
||||
log: AutoUpdateLogger;
|
||||
fusionDir?: string;
|
||||
}): AutoUpdateDeps {
|
||||
return { getSettings: input.getSettings, currentVersion: input.currentVersion, supervised: input.systemControl.supervised, requestRestart: input.systemControl.requestRestart, sourceWorkspaceRoot: input.systemControl.sourceWorkspaceRoot, log: input.log, fusionDir: input.fusionDir };
|
||||
}
|
||||
|
||||
/**
|
||||
* Run one auto-update cycle. Exported for tests and for callers that want a
|
||||
* single deterministic pass instead of the timer loop.
|
||||
@@ -120,14 +139,22 @@ export async function runAutoUpdateCycle(deps: AutoUpdateDeps): Promise<AutoUpda
|
||||
|
||||
let installed: UpdateInstallResult;
|
||||
try {
|
||||
installed = await install(result.currentVersion, result.latestVersion, { fusionDir });
|
||||
installed = await install(result.currentVersion, result.latestVersion, {
|
||||
fusionDir,
|
||||
installMethod: { sourceWorkspaceRoot: deps.sourceWorkspaceRoot },
|
||||
});
|
||||
} catch (error) {
|
||||
deps.log.error("Auto-update install failed", { message: errorMessage(error) });
|
||||
return "install-failed";
|
||||
}
|
||||
|
||||
if (!installed.updated) {
|
||||
deps.log.error("Auto-update install failed", { message: installed.error ?? "unknown install failure" });
|
||||
if (installed.outcome === "unsupported-install-method") {
|
||||
deps.log.warn("Auto-update skipped: this host cannot be updated by a global npm install", { message: installed.message });
|
||||
return "unsupported-install-method";
|
||||
}
|
||||
if (installed.outcome === "no-update-available") return "up-to-date";
|
||||
if ((installed.outcome && installed.outcome !== "installed") || !installed.updated) {
|
||||
deps.log.error("Auto-update install failed", { message: installed.error ?? installed.message ?? "unknown install failure" });
|
||||
return "install-failed";
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
// @vitest-environment node
|
||||
|
||||
import express from "express";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { request as performRequest } from "../../test-request.js";
|
||||
import { registerUpdateCheckRoutes } from "../register-update-check-routes.js";
|
||||
|
||||
const mockPerformUpdateCheck = vi.hoisted(() => vi.fn());
|
||||
const mockPerformUpdateInstall = vi.hoisted(() => vi.fn());
|
||||
|
||||
vi.mock("../../update-check.js", () => ({
|
||||
clearUpdateCheckCache: vi.fn(),
|
||||
performUpdateCheck: (...args: unknown[]) => mockPerformUpdateCheck(...args),
|
||||
performUpdateInstall: (...args: unknown[]) => mockPerformUpdateInstall(...args),
|
||||
}));
|
||||
|
||||
vi.mock("../../cli-package-version.js", () => ({ getCliPackageVersion: () => "1.2.3" }));
|
||||
|
||||
vi.mock("@fusion/core", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("@fusion/core")>();
|
||||
return { ...actual, resolveGlobalDir: () => "/tmp/fusion-update-route-test" };
|
||||
});
|
||||
|
||||
function createApp(sourceWorkspaceRoot?: string, updateCheckEnabled?: boolean) {
|
||||
const router = express.Router();
|
||||
registerUpdateCheckRoutes({
|
||||
router,
|
||||
store: { getGlobalSettingsStore: () => ({ getSettings: async () => ({ updateCheckEnabled }) }) } as never,
|
||||
options: sourceWorkspaceRoot ? { systemControl: { sourceWorkspaceRoot } } : undefined,
|
||||
rethrowAsApiError: (error: unknown) => { throw error; },
|
||||
} as never);
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
app.use("/api", router);
|
||||
app.use((error: Error, _req: express.Request, res: express.Response, _next: express.NextFunction) => {
|
||||
res.status(500).json({ error: error.message });
|
||||
});
|
||||
return app;
|
||||
}
|
||||
|
||||
async function postInstall(app: ReturnType<typeof createApp>) {
|
||||
return performRequest(app, "POST", "/api/update-check/install", "{}", { "content-type": "application/json" });
|
||||
}
|
||||
|
||||
const updateAvailable = { currentVersion: "1.2.3", latestVersion: "2.0.0", updateAvailable: true, lastChecked: 0 };
|
||||
|
||||
/*
|
||||
FNXC:UpdateInstall 2026-08-14-19:44:
|
||||
The production registrar is the symptom boundary: it must preserve failed re-checks
|
||||
instead of returning the old indistinguishable `{ updated: false }` body.
|
||||
*/
|
||||
describe("registerUpdateCheckRoutes", () => {
|
||||
beforeEach(() => {
|
||||
mockPerformUpdateCheck.mockReset();
|
||||
mockPerformUpdateInstall.mockReset();
|
||||
});
|
||||
|
||||
it("returns a visible no-update outcome without installing", async () => {
|
||||
mockPerformUpdateCheck.mockResolvedValue({ ...updateAvailable, updateAvailable: false, latestVersion: "1.2.3" });
|
||||
const response = await postInstall(createApp());
|
||||
expect(response.body).toMatchObject({ outcome: "no-update-available", updated: false });
|
||||
expect(response.body.message).toMatch(/already up to date/i);
|
||||
expect(response.body.error).toBeUndefined();
|
||||
expect(mockPerformUpdateInstall).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it.each([
|
||||
["registry failure", { currentVersion: "1.2.3", latestVersion: null, updateAvailable: false, error: "fetch failed" }, /fetch failed/i],
|
||||
["unavailable version sentinel", { currentVersion: "0.0.0", latestVersion: null, updateAvailable: false, error: "Current Fusion version is unavailable" }, /Current Fusion version is unavailable/i],
|
||||
["unresolved latest version", { currentVersion: "1.2.3", latestVersion: null, updateAvailable: false }, /could not determine/i],
|
||||
])("reports %s as check-failed without installing", async (_name, result, message) => {
|
||||
mockPerformUpdateCheck.mockResolvedValue(result);
|
||||
const response = await postInstall(createApp());
|
||||
expect(response.body).toMatchObject({ outcome: "check-failed", updated: false });
|
||||
expect(response.body.message).toMatch(message);
|
||||
expect(response.body.error).toMatch(message);
|
||||
expect(response.body.message).not.toMatch(/already up to date/i);
|
||||
expect(mockPerformUpdateInstall).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("forwards the source checkout root and returns the helper outcome", async () => {
|
||||
mockPerformUpdateCheck.mockResolvedValue(updateAvailable);
|
||||
mockPerformUpdateInstall.mockResolvedValue({ ...updateAvailable, updated: false, outcome: "unsupported-install-method", message: "This Fusion is running from a source checkout" });
|
||||
const response = await postInstall(createApp("/repo/fusion"));
|
||||
expect(mockPerformUpdateInstall).toHaveBeenCalledWith("1.2.3", "2.0.0", expect.objectContaining({ installMethod: { sourceWorkspaceRoot: "/repo/fusion" } }));
|
||||
expect(response.body).toMatchObject({ outcome: "unsupported-install-method", updated: false });
|
||||
});
|
||||
|
||||
it.each([
|
||||
["installed", { ...updateAvailable, updated: true, outcome: "installed" }],
|
||||
["failed", { ...updateAvailable, updated: false, outcome: "failed", error: "npm failed", message: "npm failed" }],
|
||||
])("returns the %s install outcome", async (outcome, installResult) => {
|
||||
mockPerformUpdateCheck.mockResolvedValue(updateAvailable);
|
||||
mockPerformUpdateInstall.mockResolvedValue(installResult);
|
||||
const response = await postInstall(createApp());
|
||||
expect(response.body).toMatchObject({ outcome });
|
||||
});
|
||||
|
||||
it("keeps disabled update checks disabled", async () => {
|
||||
const response = await performRequest(createApp(undefined, false), "GET", "/api/update-check");
|
||||
expect(response.body).toMatchObject({ disabled: true, updateAvailable: false });
|
||||
expect(mockPerformUpdateCheck).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -57,16 +57,30 @@ export const registerUpdateCheckRoutes: ApiRouteRegistrar = (ctx) => {
|
||||
channel: globalSettings.updateChannel,
|
||||
});
|
||||
|
||||
if (!updateCheck.updateAvailable || !updateCheck.latestVersion) {
|
||||
res.json({
|
||||
currentVersion: updateCheck.currentVersion,
|
||||
latestVersion: updateCheck.latestVersion,
|
||||
updated: false,
|
||||
});
|
||||
/*
|
||||
FNXC:UpdateInstall 2026-08-14-19:31:
|
||||
Every terminal response has an outcome: the old early return discarded a
|
||||
failed re-check and made it look like a successful no-op to both clients.
|
||||
*/
|
||||
if (updateCheck.error?.trim()) {
|
||||
const message = `Could not check for updates: ${updateCheck.error}`;
|
||||
res.json({ currentVersion: updateCheck.currentVersion, latestVersion: updateCheck.latestVersion, updated: false, outcome: "check-failed", error: updateCheck.error, message });
|
||||
return;
|
||||
}
|
||||
if (!updateCheck.latestVersion) {
|
||||
const message = "Could not determine the latest published Fusion version.";
|
||||
res.json({ currentVersion: updateCheck.currentVersion, latestVersion: null, updated: false, outcome: "check-failed", error: message, message });
|
||||
return;
|
||||
}
|
||||
if (!updateCheck.updateAvailable) {
|
||||
res.json({ currentVersion: updateCheck.currentVersion, latestVersion: updateCheck.latestVersion, updated: false, outcome: "no-update-available", message: `Fusion is already up to date at v${updateCheck.currentVersion}.` });
|
||||
return;
|
||||
}
|
||||
|
||||
const result = await performUpdateInstall(updateCheck.currentVersion, updateCheck.latestVersion, { fusionDir });
|
||||
const result = await performUpdateInstall(updateCheck.currentVersion, updateCheck.latestVersion, {
|
||||
fusionDir,
|
||||
installMethod: { sourceWorkspaceRoot: ctx.options?.systemControl?.sourceWorkspaceRoot },
|
||||
});
|
||||
res.json(result);
|
||||
} catch (error) {
|
||||
rethrowAsApiError(error, "Failed to install update");
|
||||
|
||||
@@ -77,7 +77,7 @@ import type { CliRelaunchRegistry } from "./cli-session-transport.js";
|
||||
import { validateRemoteAuthToken } from "./remote-auth.js";
|
||||
import { getCliPackageVersion, isUnresolvedCliPackageVersion } from "./cli-package-version.js";
|
||||
import { performUpdateCheck } from "./update-check.js";
|
||||
import { startAutoUpdateWatcher } from "./auto-update.js";
|
||||
import { buildAutoUpdateDeps, startAutoUpdateWatcher } from "./auto-update.js";
|
||||
import {
|
||||
dayHasSamples,
|
||||
fileScopeInvariantFailuresPerDay,
|
||||
@@ -1779,20 +1779,19 @@ export function createServer(store: TaskStore, options?: ServerOptions): ReturnT
|
||||
if (options?.systemControl && shouldScheduleAiSessionCleanup()) {
|
||||
const systemControl = options.systemControl;
|
||||
stopAutoUpdateWatcher?.();
|
||||
stopAutoUpdateWatcher = startAutoUpdateWatcher({
|
||||
stopAutoUpdateWatcher = startAutoUpdateWatcher(buildAutoUpdateDeps({
|
||||
getSettings: async () => {
|
||||
const globalStore = store.getGlobalSettingsStore?.();
|
||||
return globalStore ? await globalStore.getSettings() : {};
|
||||
},
|
||||
currentVersion: cliPackageVersion,
|
||||
supervised: systemControl.supervised,
|
||||
requestRestart: (reason) => systemControl.requestRestart(reason),
|
||||
systemControl,
|
||||
log: {
|
||||
info: (message, context) => runtimeLogger.info(message, context),
|
||||
warn: (message, context) => runtimeLogger.warn(message, context),
|
||||
error: (message, context) => runtimeLogger.error(message, context),
|
||||
},
|
||||
});
|
||||
}));
|
||||
}
|
||||
|
||||
/*
|
||||
|
||||
@@ -30,10 +30,17 @@ export type UpdateCheckResult = {
|
||||
error?: string;
|
||||
};
|
||||
|
||||
export type UpdateInstallOutcome = "installed" | "no-update-available" | "check-failed" | "unsupported-install-method" | "failed";
|
||||
|
||||
/** `check-failed` is reserved for a route pre-install re-check; this helper never returns it. */
|
||||
export type UpdateInstallResult = {
|
||||
currentVersion: string;
|
||||
latestVersion: string | null;
|
||||
/** True exactly when outcome is `installed`, retained for older consumers. */
|
||||
updated: boolean;
|
||||
outcome: UpdateInstallOutcome;
|
||||
/** Human-readable for every non-installed terminal outcome. */
|
||||
message?: string;
|
||||
error?: string;
|
||||
};
|
||||
|
||||
@@ -150,7 +157,7 @@ function isPermissionInstallError(error: unknown): boolean {
|
||||
}
|
||||
|
||||
/** Best-effort path of the running Fusion binary, used to tailor remediation. */
|
||||
function detectRunningBinaryPath(): string | null {
|
||||
export 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;
|
||||
@@ -166,7 +173,7 @@ brew's own git repo, not where formulae install). So resolve the symlink and mat
|
||||
real Cellar/opt install roots — checking only `/usr/local/Homebrew/` missed Intel Macs.
|
||||
`/usr/local/bin` is deliberately NOT matched: it is shared with npm-global bins.
|
||||
*/
|
||||
function isHomebrewInstall(binaryPath: string | null): boolean {
|
||||
export function isHomebrewInstall(binaryPath: string | null): boolean {
|
||||
if (!binaryPath) return false;
|
||||
let resolved = binaryPath;
|
||||
try {
|
||||
@@ -183,6 +190,30 @@ function isHomebrewInstall(binaryPath: string | null): boolean {
|
||||
);
|
||||
}
|
||||
|
||||
export function detectUnsupportedInstallMethod(input: {
|
||||
sourceWorkspaceRoot?: string;
|
||||
binaryPath?: string | null;
|
||||
hasNpm?: boolean;
|
||||
}): { reason: "source-checkout" | "homebrew" | "npm-missing"; message: string } | null {
|
||||
if (input.sourceWorkspaceRoot) {
|
||||
return { reason: "source-checkout", message: `This Fusion is running from a source checkout at ${input.sourceWorkspaceRoot}; a global npm install will not change it — pull and rebuild the checkout instead.` };
|
||||
}
|
||||
const binaryPath = input.binaryPath ?? detectRunningBinaryPath();
|
||||
if (isHomebrewInstall(binaryPath)) {
|
||||
return { reason: "homebrew", message: "This Fusion install is managed by Homebrew and cannot be updated with npm. Update it from a terminal with: brew upgrade fusion" };
|
||||
}
|
||||
if (input.hasNpm === false) {
|
||||
return { reason: "npm-missing", message: "`npm` was not found on this process's PATH; update from a terminal with `npm install -g @runfusion/fusion@<version>`." };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function isNpmMissingError(error: unknown): boolean {
|
||||
const installError = error as InstallError;
|
||||
const message = [installError?.message, installError?.stderr, installError?.stdout].filter((part): part is string => typeof part === "string").join("\n");
|
||||
return installError?.code === "ENOENT" || /command not found|not recognized as an internal/i.test(message);
|
||||
}
|
||||
|
||||
function getPermissionRemediationMessage(binaryPath: string | null): string {
|
||||
if (isHomebrewInstall(binaryPath)) {
|
||||
return (
|
||||
@@ -250,82 +281,45 @@ export async function clearUpdateCheckCache(fusionDir: string): Promise<void> {
|
||||
export async function performUpdateInstall(
|
||||
currentVersion: string,
|
||||
latestVersion: string | null,
|
||||
options: { exec?: ExecInstall; fusionDir?: string } = {},
|
||||
options: { exec?: ExecInstall; fusionDir?: string; installMethod?: { sourceWorkspaceRoot?: string } } = {},
|
||||
): Promise<UpdateInstallResult> {
|
||||
const runExec = options.exec ?? execAsync;
|
||||
const fusionDir = options.fusionDir ?? resolveGlobalDir();
|
||||
|
||||
// No resolved target → nothing safe to install. Callers guard this today;
|
||||
// the guard here keeps the exec path unreachable if one ever stops.
|
||||
/*
|
||||
FNXC:UpdateInstall 2026-08-14-19:31:
|
||||
A silent updated:false response was indistinguishable from a dead button. Failed
|
||||
checks and an up-to-date re-check must never share an outcome; host-only source
|
||||
checkout context is injected here so unsupported installs are refused pre-flight.
|
||||
*/
|
||||
const unsupported = detectUnsupportedInstallMethod({ sourceWorkspaceRoot: options.installMethod?.sourceWorkspaceRoot });
|
||||
if (unsupported) return { currentVersion, latestVersion, updated: false, outcome: "unsupported-install-method", message: unsupported.message, error: unsupported.message };
|
||||
if (!latestVersion || !SAFE_VERSION_RE.test(latestVersion)) {
|
||||
return {
|
||||
currentVersion,
|
||||
latestVersion,
|
||||
updated: false,
|
||||
error: `No valid update target version to install${latestVersion ? ` ('${latestVersion}')` : ""}.`,
|
||||
};
|
||||
const error = `No valid update target version to install${latestVersion ? ` ('${latestVersion}')` : ""}.`;
|
||||
return { currentVersion, latestVersion, updated: false, outcome: "failed", message: error, error };
|
||||
}
|
||||
|
||||
const failed = (error: string): UpdateInstallResult => ({ currentVersion, latestVersion, updated: false, outcome: "failed", message: error, error });
|
||||
try {
|
||||
await runExec(buildInstallCommand(latestVersion), getInstallOptions());
|
||||
await clearUpdateCheckCache(fusionDir);
|
||||
return {
|
||||
currentVersion,
|
||||
latestVersion,
|
||||
updated: true,
|
||||
};
|
||||
return { currentVersion, latestVersion, updated: true, outcome: "installed" };
|
||||
} catch (error) {
|
||||
/*
|
||||
FNXC:UpdateInstall 2026-07-19-09:50:
|
||||
Native npm dependencies can take longer than two minutes to install on Windows. Allow five minutes, and when exec kills a slow install, report the timeout metadata before npm's preceding deprecation warnings.
|
||||
*/
|
||||
if (isInstallTimeoutError(error)) {
|
||||
return {
|
||||
currentVersion,
|
||||
latestVersion,
|
||||
updated: false,
|
||||
error: getInstallTimeoutMessage(latestVersion),
|
||||
};
|
||||
if (isNpmMissingError(error)) {
|
||||
const message = detectUnsupportedInstallMethod({ hasNpm: false })!.message;
|
||||
return { currentVersion, latestVersion, updated: false, outcome: "unsupported-install-method", message, error: message };
|
||||
}
|
||||
|
||||
// FNXC:UpdateInstallPermissions 2026-07-10-14:00: a root-owned global dir
|
||||
// (from `sudo npm i -g`) yields EACCES/EPERM the non-root updater cannot
|
||||
// recover from — return actionable guidance rather than raw npm stderr.
|
||||
if (isPermissionInstallError(error)) {
|
||||
return {
|
||||
currentVersion,
|
||||
latestVersion,
|
||||
updated: false,
|
||||
error: getPermissionRemediationMessage(detectRunningBinaryPath()),
|
||||
};
|
||||
}
|
||||
|
||||
if (!isBinCollisionInstallError(error)) {
|
||||
return {
|
||||
currentVersion,
|
||||
latestVersion,
|
||||
updated: false,
|
||||
error: getInstallErrorMessage(error),
|
||||
};
|
||||
}
|
||||
|
||||
if (isInstallTimeoutError(error)) return failed(getInstallTimeoutMessage(latestVersion));
|
||||
if (isPermissionInstallError(error)) return failed(getPermissionRemediationMessage(detectRunningBinaryPath()));
|
||||
if (!isBinCollisionInstallError(error)) return failed(getInstallErrorMessage(error));
|
||||
try {
|
||||
await runExec(buildInstallCommand(latestVersion, true), getInstallOptions());
|
||||
await clearUpdateCheckCache(fusionDir);
|
||||
return {
|
||||
currentVersion,
|
||||
latestVersion,
|
||||
updated: true,
|
||||
};
|
||||
return { currentVersion, latestVersion, updated: true, outcome: "installed" };
|
||||
} catch (forceError) {
|
||||
return {
|
||||
currentVersion,
|
||||
latestVersion,
|
||||
updated: false,
|
||||
error: isInstallTimeoutError(forceError)
|
||||
? getInstallTimeoutMessage(latestVersion, true)
|
||||
: getInstallErrorMessage(forceError),
|
||||
};
|
||||
if (isNpmMissingError(forceError)) {
|
||||
const message = detectUnsupportedInstallMethod({ hasNpm: false })!.message;
|
||||
return { currentVersion, latestVersion, updated: false, outcome: "unsupported-install-method", message, error: message };
|
||||
}
|
||||
return failed(isInstallTimeoutError(forceError) ? getInstallTimeoutMessage(latestVersion, true) : getInstallErrorMessage(forceError));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user