FN-7471: fix desktop update version fallback

Use packaged runtime metadata so desktop update checks do not compare npm releases against an unresolved current version.

- Resolve @fusion/desktop package metadata as a fallback when dashboard code runs from a desktop deployment.
- Fail closed when the current version is the 0.0.0 sentinel in both update-check paths.
- Cover CLI, desktop, and unresolved-version update-check behavior with regression tests.
- Add a patch changeset for the published Fusion CLI package.

Files changed:
 .changeset/fn-7471-desktop-update-version.md       |  7 ++
 .../src/__tests__/cli-package-version.test.ts      | 59 +++++++++++++++-
 .../src/__tests__/update-check-route.test.ts       | 78 +++++++++++++++++++++-
 .../dashboard/src/__tests__/update-check.test.ts   | 15 +++++
 packages/dashboard/src/cli-package-version.ts      | 37 +++++++++-
 packages/dashboard/src/server.ts                   | 12 +++-
 packages/dashboard/src/update-check.ts             | 15 +++++
 7 files changed, 216 insertions(+), 7 deletions(-)

Fusion-Task-Id: FN-7471

Fusion-Task-Lineage: ee633b8c-766b-43f3-855d-3ce7abd41550

Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
This commit is contained in:
gsxdsm
2026-07-03 15:32:52 -07:00
parent a048ac9694
commit 9dc248eae2
7 changed files with 216 additions and 7 deletions

View File

@@ -0,0 +1,7 @@
---
"@runfusion/fusion": patch
---
summary: Prevent Desktop update banners from using 0.0.0 as the current version.
category: fix
dev: Dashboard update checks now resolve packaged @fusion/desktop metadata and fail closed for unresolved versions.

View File

@@ -1,13 +1,19 @@
// @vitest-environment node
import { readFileSync } from "node:fs";
import { mkdtempSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
import { pathToFileURL, fileURLToPath } from "node:url";
import { describe, expect, it } from "vitest";
import { getCliPackageVersion, resolveCliPackageVersionInfo } from "../cli-package-version.js";
const __dirname = dirname(fileURLToPath(import.meta.url));
function writePackageJson(dir: string, manifest: { name: string; version?: string }): void {
mkdirSync(dir, { recursive: true });
writeFileSync(join(dir, "package.json"), JSON.stringify(manifest, null, 2), "utf-8");
}
describe("cli-package-version", () => {
it("resolves the published CLI package from dashboard source directories", () => {
const versionInfo = resolveCliPackageVersionInfo(join(__dirname, ".."));
@@ -21,6 +27,55 @@ describe("cli-package-version", () => {
expect(versionInfo?.packageJsonPath).not.toBe(dashboardPackageJson);
});
it("resolves the published CLI package from an installed CLI ancestor", () => {
const root = mkdtempSync(join(tmpdir(), "fusion-cli-version-installed-"));
try {
const cliRoot = join(root, "node_modules", "@runfusion", "fusion");
const startDir = join(cliRoot, "dist", "dashboard");
writePackageJson(cliRoot, { name: "@runfusion/fusion", version: "8.7.6" });
mkdirSync(startDir, { recursive: true });
expect(resolveCliPackageVersionInfo(startDir)).toEqual({
packageJsonPath: join(cliRoot, "package.json"),
version: "8.7.6",
});
} finally {
rmSync(root, { recursive: true, force: true });
}
});
it("resolves packaged desktop metadata when no CLI manifest is present", () => {
const root = mkdtempSync(join(tmpdir(), "fusion-cli-version-desktop-"));
try {
const dashboardDist = join(root, "node_modules", "@fusion", "dashboard", "dist");
writePackageJson(root, { name: "@fusion/desktop", version: "5.4.3" });
writePackageJson(join(root, "node_modules", "@fusion", "dashboard"), { name: "@fusion/dashboard", version: "0.0.0" });
mkdirSync(dashboardDist, { recursive: true });
expect(resolveCliPackageVersionInfo(dashboardDist)).toEqual({
packageJsonPath: join(root, "package.json"),
version: "5.4.3",
});
expect(getCliPackageVersion(pathToFileURL(join(dashboardDist, "server.js")).href)).toBe("5.4.3");
} finally {
rmSync(root, { recursive: true, force: true });
}
});
it("does not treat missing or malformed metadata as a resolved version", () => {
const root = mkdtempSync(join(tmpdir(), "fusion-cli-version-missing-"));
try {
const dashboardDist = join(root, "node_modules", "@fusion", "dashboard", "dist");
writePackageJson(root, { name: "@fusion/desktop" });
writePackageJson(join(root, "node_modules", "@fusion", "dashboard"), { name: "@fusion/dashboard", version: "0.0.0" });
mkdirSync(dashboardDist, { recursive: true });
expect(resolveCliPackageVersionInfo(dashboardDist)).toBeNull();
} finally {
rmSync(root, { recursive: true, force: true });
}
});
it("returns the published CLI version for dashboard consumers", () => {
const expectedCliPackageJson = join(__dirname, "..", "..", "..", "cli", "package.json");
const expectedVersion = JSON.parse(readFileSync(expectedCliPackageJson, "utf-8")).version;

View File

@@ -1,8 +1,9 @@
// @vitest-environment node
import { readFileSync } from "node:fs";
import { mkdtempSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
import { fileURLToPath, pathToFileURL } from "node:url";
import { describe, it, expect, vi, afterEach } from "vitest";
import type { TaskStore } from "@fusion/core";
import { createServer } from "../server.js";
@@ -11,6 +12,7 @@ import { get as performGet, request as performRequest } from "../test-request.js
const updateCheckMocks = vi.hoisted(() => ({
performUpdateCheck: vi.fn(),
performUpdateInstall: vi.fn(),
cliPackageImportMetaUrl: undefined as string | undefined,
}));
vi.mock("../update-check.js", async () => {
@@ -22,6 +24,14 @@ vi.mock("../update-check.js", async () => {
};
});
vi.mock("../cli-package-version.js", async () => {
const actual = await vi.importActual<typeof import("../cli-package-version.js")>("../cli-package-version.js");
return {
...actual,
getCliPackageVersion: (importMetaUrl?: string) => actual.getCliPackageVersion(updateCheckMocks.cliPackageImportMetaUrl ?? importMetaUrl),
};
});
vi.mock("@fusion/core", async () => {
const actual = await vi.importActual<typeof import("@fusion/core")>("@fusion/core");
return {
@@ -88,11 +98,18 @@ function createMockStore(overrides: Partial<TaskStore> = {}): TaskStore {
} as unknown as TaskStore;
}
function writePackageJson(dir: string, manifest: { name: string; version?: string }): void {
mkdirSync(dir, { recursive: true });
writeFileSync(join(dir, "package.json"), JSON.stringify(manifest, null, 2), "utf-8");
}
afterEach(() => {
updateCheckMocks.performUpdateCheck.mockReset();
updateCheckMocks.performUpdateInstall.mockReset();
updateCheckMocks.cliPackageImportMetaUrl = undefined;
vi.restoreAllMocks();
vi.unstubAllGlobals();
vi.unstubAllEnvs();
});
describe("POST /api/update-check/install", () => {
@@ -190,6 +207,63 @@ describe("GET /api/updates/check", () => {
});
});
it("returns updateAvailable=false for packaged desktop when registry latest matches desktop version", async () => {
const root = mkdtempSync(join(tmpdir(), "fusion-update-route-desktop-"));
try {
const dashboardDist = join(root, "node_modules", "@fusion", "dashboard", "dist");
writePackageJson(root, { name: "@fusion/desktop", version: "5.4.3" });
writePackageJson(join(root, "node_modules", "@fusion", "dashboard"), { name: "@fusion/dashboard", version: "0.0.0" });
mkdirSync(dashboardDist, { recursive: true });
updateCheckMocks.cliPackageImportMetaUrl = pathToFileURL(join(dashboardDist, "server.js")).href;
vi.stubGlobal(
"fetch",
vi.fn().mockResolvedValue({
ok: true,
json: async () => ({ version: "5.4.3" }),
}),
);
const app = createServer(createMockStore());
const response = await performGet(app, "/api/updates/check");
expect(response.status).toBe(200);
expect(response.body).toEqual({
currentVersion: "5.4.3",
latestVersion: "5.4.3",
updateAvailable: false,
});
} finally {
rmSync(root, { recursive: true, force: true });
}
});
it("fails closed when the current version is unresolved", async () => {
const root = mkdtempSync(join(tmpdir(), "fusion-update-route-unresolved-"));
try {
const dashboardDist = join(root, "node_modules", "@fusion", "dashboard", "dist");
writePackageJson(join(root, "node_modules", "@fusion", "dashboard"), { name: "@fusion/dashboard", version: "0.0.0" });
mkdirSync(dashboardDist, { recursive: true });
updateCheckMocks.cliPackageImportMetaUrl = pathToFileURL(join(dashboardDist, "server.js")).href;
vi.stubEnv("npm_package_version", undefined);
const fetchSpy = vi.fn();
vi.stubGlobal("fetch", fetchSpy);
const app = createServer(createMockStore());
const response = await performGet(app, "/api/updates/check");
expect(response.status).toBe(200);
expect(fetchSpy).not.toHaveBeenCalled();
expect(response.body).toEqual({
currentVersion: "0.0.0",
latestVersion: null,
updateAvailable: false,
error: "Current Fusion version is unavailable",
});
} finally {
rmSync(root, { recursive: true, force: true });
}
});
it("gracefully returns an error payload when npm registry is unreachable", async () => {
vi.stubGlobal("fetch", vi.fn().mockRejectedValue(new Error("network down")));

View File

@@ -125,6 +125,21 @@ describe("update-check", () => {
expect(olderResult.updateAvailable).toBe(false);
});
it("fails closed without fetching when current version is unresolved", async () => {
const fetchSpy = vi.fn();
vi.stubGlobal("fetch", fetchSpy);
await expect(performUpdateCheck(fusionDir, "0.0.0", { force: true })).resolves.toEqual(
expect.objectContaining({
currentVersion: "0.0.0",
latestVersion: null,
updateAvailable: false,
error: "Current Fusion version is unavailable",
}),
);
expect(fetchSpy).not.toHaveBeenCalled();
});
it("returns a non-throwing error result when network fetch fails", async () => {
vi.stubGlobal("fetch", vi.fn().mockRejectedValue(new Error("network down")));

View File

@@ -3,20 +3,23 @@ import { dirname, resolve } from "node:path";
import { fileURLToPath } from "node:url";
const CLI_PACKAGE_NAME = "@runfusion/fusion";
const DESKTOP_PACKAGE_NAME = "@fusion/desktop";
const CLI_PACKAGE_NAMES = new Set([CLI_PACKAGE_NAME]);
const DESKTOP_PACKAGE_NAMES = new Set([DESKTOP_PACKAGE_NAME]);
export interface CliPackageVersionInfo {
packageJsonPath: string;
version: string;
}
function readCliPackageVersion(pkgPath: string): CliPackageVersionInfo | null {
function readPackageVersion(pkgPath: string, packageNames: ReadonlySet<string>): CliPackageVersionInfo | null {
if (!existsSync(pkgPath)) {
return null;
}
try {
const parsed = JSON.parse(readFileSync(pkgPath, "utf-8")) as { name?: string; version?: string };
if (parsed.name === CLI_PACKAGE_NAME && typeof parsed.version === "string" && parsed.version.length > 0) {
if (parsed.name && packageNames.has(parsed.name) && typeof parsed.version === "string" && parsed.version.length > 0) {
return {
packageJsonPath: pkgPath,
version: parsed.version,
@@ -29,6 +32,14 @@ function readCliPackageVersion(pkgPath: string): CliPackageVersionInfo | null {
return null;
}
function readCliPackageVersion(pkgPath: string): CliPackageVersionInfo | null {
return readPackageVersion(pkgPath, CLI_PACKAGE_NAMES);
}
function readDesktopPackageVersion(pkgPath: string): CliPackageVersionInfo | null {
return readPackageVersion(pkgPath, DESKTOP_PACKAGE_NAMES);
}
/**
* Resolve the published CLI package version from dashboard code.
*
@@ -36,6 +47,7 @@ function readCliPackageVersion(pkgPath: string): CliPackageVersionInfo | null {
* - Monorepo source: `packages/dashboard/src/...` with sibling `packages/cli/package.json`
* - Installed dependency: `node_modules/@runfusion/fusion/dist/...`
* - Bundled CLI: dashboard code inlined into `dist/bin.js` next to the CLI manifest
* - Packaged Desktop: `node_modules/@fusion/dashboard/dist/...` under the staged `@fusion/desktop` app manifest
*/
export function resolveCliPackageVersionInfo(startDir: string): CliPackageVersionInfo | null {
// First pass: walk ancestors looking for the @runfusion/fusion package.json directly.
@@ -71,9 +83,30 @@ export function resolveCliPackageVersionInfo(startDir: string): CliPackageVersio
currentDir = parentDir;
}
/*
* FNXC:DesktopUpdates 2026-07-03-15:30:
* Desktop embeds @fusion/dashboard inside a staged @fusion/desktop deploy tree, not inside the published @runfusion/fusion package. The dashboard npm update banner still compares against @runfusion/fusion releases, so use the desktop app manifest as the deterministic packaged-runtime version fallback after CLI-specific probes fail; never use the private @fusion/dashboard version for user-facing update availability.
*/
currentDir = startDir;
for (let i = 0; i < 8; i += 1) {
const versionInfo = readDesktopPackageVersion(resolve(currentDir, "package.json"));
if (versionInfo) {
return versionInfo;
}
const parentDir = resolve(currentDir, "..");
if (parentDir === currentDir) {
break;
}
currentDir = parentDir;
}
return null;
}
export function isUnresolvedCliPackageVersion(version: string): boolean {
return version === "0.0.0";
}
export function getCliPackageVersion(importMetaUrl: string = import.meta.url): string {
const startDir = dirname(fileURLToPath(importMetaUrl));
return resolveCliPackageVersionInfo(startDir)?.version ?? process.env.npm_package_version ?? "0.0.0";

View File

@@ -72,7 +72,7 @@ import { createCliSessionsRouter } from "./routes/cli-sessions.js";
import { getProjectIdFromRequest } from "./routes/context.js";
import type { CliRelaunchRegistry } from "./cli-session-transport.js";
import { validateRemoteAuthToken } from "./remote-auth.js";
import { getCliPackageVersion } from "./cli-package-version.js";
import { getCliPackageVersion, isUnresolvedCliPackageVersion } from "./cli-package-version.js";
import {
dayHasSamples,
fileScopeInvariantFailuresPerDay,
@@ -1719,6 +1719,16 @@ export function createServer(store: TaskStore, options?: ServerOptions): ReturnT
const currentVersion = cliPackageVersion;
res.set("Cache-Control", "no-store");
if (isUnresolvedCliPackageVersion(currentVersion)) {
res.status(200).json({
currentVersion,
latestVersion: null,
updateAvailable: false,
error: "Current Fusion version is unavailable",
});
return;
}
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 10_000);

View File

@@ -14,6 +14,7 @@ const INSTALL_MAX_BUFFER = 10 * 1024 * 1024;
const DAY_MS = 24 * 60 * 60 * 1000;
const execAsync = promisify(exec);
const UNRESOLVED_VERSION = "0.0.0";
/** Allowed update-check cadences from GlobalSettings. */
export type UpdateCheckFrequency = "manual" | "on-startup" | "daily" | "weekly";
@@ -202,6 +203,20 @@ export async function performUpdateCheck(
options: { frequency?: UpdateCheckFrequency; force?: boolean } = {},
): Promise<UpdateCheckResult> {
const now = Date.now();
/*
* FNXC:DesktopUpdates 2026-07-03-15:35:
* `0.0.0` is a resolver sentinel, not an installed Fusion version. If packaged/runtime metadata is missing, update surfaces must fail closed instead of comparing npm's latest release against zero and showing a false-positive desktop update banner.
*/
if (currentVersion === UNRESOLVED_VERSION) {
return {
currentVersion,
latestVersion: null,
updateAvailable: false,
lastChecked: now,
error: "Current Fusion version is unavailable",
};
}
const cached = readCachedUpdateCheck(fusionDir);
const cacheMatchesCurrentVersion = !cached || cached.currentVersion === currentVersion;
const ttl = ttlForFrequency(options.frequency);