diff --git a/.changeset/fn-7471-desktop-update-version.md b/.changeset/fn-7471-desktop-update-version.md new file mode 100644 index 0000000000..127e445d57 --- /dev/null +++ b/.changeset/fn-7471-desktop-update-version.md @@ -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. diff --git a/packages/dashboard/src/__tests__/cli-package-version.test.ts b/packages/dashboard/src/__tests__/cli-package-version.test.ts index 8d432cf4ba..61feb13d4c 100644 --- a/packages/dashboard/src/__tests__/cli-package-version.test.ts +++ b/packages/dashboard/src/__tests__/cli-package-version.test.ts @@ -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; diff --git a/packages/dashboard/src/__tests__/update-check-route.test.ts b/packages/dashboard/src/__tests__/update-check-route.test.ts index dd9d81dd56..a36838348b 100644 --- a/packages/dashboard/src/__tests__/update-check-route.test.ts +++ b/packages/dashboard/src/__tests__/update-check-route.test.ts @@ -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("../cli-package-version.js"); + return { + ...actual, + getCliPackageVersion: (importMetaUrl?: string) => actual.getCliPackageVersion(updateCheckMocks.cliPackageImportMetaUrl ?? importMetaUrl), + }; +}); + vi.mock("@fusion/core", async () => { const actual = await vi.importActual("@fusion/core"); return { @@ -88,11 +98,18 @@ function createMockStore(overrides: Partial = {}): 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"))); diff --git a/packages/dashboard/src/__tests__/update-check.test.ts b/packages/dashboard/src/__tests__/update-check.test.ts index 26a1aa6c79..3998c5fea4 100644 --- a/packages/dashboard/src/__tests__/update-check.test.ts +++ b/packages/dashboard/src/__tests__/update-check.test.ts @@ -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"))); diff --git a/packages/dashboard/src/cli-package-version.ts b/packages/dashboard/src/cli-package-version.ts index c9cd4a7aaa..a7099ffb53 100644 --- a/packages/dashboard/src/cli-package-version.ts +++ b/packages/dashboard/src/cli-package-version.ts @@ -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): 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"; diff --git a/packages/dashboard/src/server.ts b/packages/dashboard/src/server.ts index 7796753b97..9652202869 100644 --- a/packages/dashboard/src/server.ts +++ b/packages/dashboard/src/server.ts @@ -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); diff --git a/packages/dashboard/src/update-check.ts b/packages/dashboard/src/update-check.ts index 4fee36a0a0..dc58922589 100644 --- a/packages/dashboard/src/update-check.ts +++ b/packages/dashboard/src/update-check.ts @@ -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 { 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);