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:
7
.changeset/fn-7471-desktop-update-version.md
Normal file
7
.changeset/fn-7471-desktop-update-version.md
Normal 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.
|
||||
@@ -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;
|
||||
|
||||
@@ -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")));
|
||||
|
||||
|
||||
@@ -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")));
|
||||
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user