From 597295e74920f87e1c0b918c834c9e3a2c94f81b Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Wed, 29 Apr 2026 07:54:45 -0700 Subject: [PATCH] fix(FN-XXX): align tui startup update check --- .changeset/fix-tui-update-check.md | 5 ++ .../cli/src/__tests__/update-cache.test.ts | 57 +++++++++++++++ .../src/commands/__tests__/dashboard.test.ts | 56 +++++++++++++++ .../src/commands/dashboard-tui/controller.ts | 12 ---- packages/cli/src/commands/dashboard.ts | 72 +++++++++++++------ packages/cli/src/update-cache.ts | 10 ++- packages/dashboard/src/index.ts | 1 + 7 files changed, 177 insertions(+), 36 deletions(-) create mode 100644 .changeset/fix-tui-update-check.md create mode 100644 packages/cli/src/__tests__/update-cache.test.ts diff --git a/.changeset/fix-tui-update-check.md b/.changeset/fix-tui-update-check.md new file mode 100644 index 000000000..33dee25a4 --- /dev/null +++ b/.changeset/fix-tui-update-check.md @@ -0,0 +1,5 @@ +--- +"@runfusion/fusion": patch +--- + +Fix the TUI startup update notice to use the same version source and cached update gating as the rest of the CLI. diff --git a/packages/cli/src/__tests__/update-cache.test.ts b/packages/cli/src/__tests__/update-cache.test.ts new file mode 100644 index 000000000..37ecbfe0c --- /dev/null +++ b/packages/cli/src/__tests__/update-cache.test.ts @@ -0,0 +1,57 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { mkdirSync, rmSync, writeFileSync } from "node:fs"; +import { readFileSync } from "node:fs"; + +const CLI_PACKAGE_VERSION = ( + JSON.parse(readFileSync(new URL("../../package.json", import.meta.url), "utf-8")) as { version: string } +).version; + +const cacheDir = "/tmp/fusion-update-cache-test"; + +const { mockResolveGlobalDir } = vi.hoisted(() => ({ + mockResolveGlobalDir: vi.fn().mockReturnValue("/tmp/fusion-update-cache-test"), +})); + +vi.mock("@fusion/core", () => ({ + resolveGlobalDir: mockResolveGlobalDir, + GlobalSettingsStore: vi.fn(), +})); + +const { getCachedUpdateStatus } = await import("../update-cache.js"); + +function writeUpdateCache(payload: { updateAvailable: boolean; latestVersion: string; currentVersion: string }): void { + mkdirSync(cacheDir, { recursive: true }); + writeFileSync(`${cacheDir}/update-check.json`, JSON.stringify(payload), "utf-8"); +} + +beforeEach(() => { + rmSync(cacheDir, { recursive: true, force: true }); + mockResolveGlobalDir.mockReset(); + mockResolveGlobalDir.mockReturnValue(cacheDir); +}); + +describe("getCachedUpdateStatus", () => { + it("returns the cached update when it matches the installed CLI version", () => { + writeUpdateCache({ + updateAvailable: true, + currentVersion: CLI_PACKAGE_VERSION, + latestVersion: "9.9.9", + }); + + expect(getCachedUpdateStatus(CLI_PACKAGE_VERSION)).toEqual({ + updateAvailable: true, + currentVersion: CLI_PACKAGE_VERSION, + latestVersion: "9.9.9", + }); + }); + + it("ignores stale cached updates from a different installed CLI version", () => { + writeUpdateCache({ + updateAvailable: true, + currentVersion: "0.0.1", + latestVersion: "9.9.9", + }); + + expect(getCachedUpdateStatus(CLI_PACKAGE_VERSION)).toBeNull(); + }); +}); diff --git a/packages/cli/src/commands/__tests__/dashboard.test.ts b/packages/cli/src/commands/__tests__/dashboard.test.ts index 4bc0531dd..26ed28655 100644 --- a/packages/cli/src/commands/__tests__/dashboard.test.ts +++ b/packages/cli/src/commands/__tests__/dashboard.test.ts @@ -1,5 +1,10 @@ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; import { EventEmitter } from "node:events"; +import { mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs"; + +const CLI_PACKAGE_VERSION = ( + JSON.parse(readFileSync(new URL("../../../package.json", import.meta.url), "utf-8")) as { version: string } +).version; // ── Capture instances & arguments ─────────────────────────────────── @@ -19,6 +24,7 @@ const { mockGlobalSettingsGetSettings, mockGlobalSettingsUpdateSettings, mockDaemonTokenGetOrCreate, + mockGetCliPackageVersion, } = vi.hoisted(() => { delete process.env.FUSION_DASHBOARD_TOKEN; delete process.env.FUSION_DAEMON_TOKEN; @@ -43,6 +49,7 @@ const { mockGlobalSettingsGetSettings: vi.fn().mockResolvedValue({}), mockGlobalSettingsUpdateSettings: vi.fn().mockResolvedValue({}), mockDaemonTokenGetOrCreate: vi.fn().mockResolvedValue("fn_test_dashboard_token"), + mockGetCliPackageVersion: vi.fn(), }; }); @@ -173,6 +180,7 @@ vi.mock("@fusion/core", () => ({ getEnabledPiExtensionPaths: vi.fn(() => []), resolveGlobalDir: mockResolveGlobalDir, GlobalSettingsStore: vi.fn().mockImplementation(() => ({ + init: vi.fn().mockResolvedValue(undefined), getSettings: mockGlobalSettingsGetSettings, updateSettings: mockGlobalSettingsUpdateSettings, })), @@ -275,6 +283,7 @@ vi.mock("@fusion/dashboard", () => ({ mergePr: mockMergePr, })), createSkillsAdapter: vi.fn().mockReturnValue(undefined), + getCliPackageVersion: mockGetCliPackageVersion, getProjectSettingsPath: vi.fn().mockReturnValue("/tmp/project/.fusion/settings.json"), loadTlsCredentialsFromEnv: vi.fn().mockReturnValue(undefined), })); @@ -755,6 +764,14 @@ function resetGitHubMocks() { }); } +const updateCacheDir = "/tmp/test-global"; +const updateCachePath = `${updateCacheDir}/update-check.json`; + +function writeUpdateCache(payload: { updateAvailable: boolean; latestVersion: string; currentVersion: string }): void { + mkdirSync(updateCacheDir, { recursive: true }); + writeFileSync(updateCachePath, JSON.stringify(payload), "utf-8"); +} + beforeEach(() => { delete process.env.FUSION_DASHBOARD_TOKEN; delete process.env.FUSION_DAEMON_TOKEN; @@ -774,10 +791,14 @@ beforeEach(() => { mockGlobalSettingsUpdateSettings.mockResolvedValue({}); mockDaemonTokenGetOrCreate.mockReset(); mockDaemonTokenGetOrCreate.mockResolvedValue("fn_test_dashboard_token"); + mockGetCliPackageVersion.mockReset(); + mockGetCliPackageVersion.mockReturnValue(CLI_PACKAGE_VERSION); + rmSync(updateCacheDir, { recursive: true, force: true }); }); afterEach(() => { disposeTrackedDashboards(); + rmSync(updateCacheDir, { recursive: true, force: true }); }); describe("PR merge helpers", () => { @@ -2903,3 +2924,38 @@ describe("runDashboard runtime logger wiring", () => { } }); }); + +describe("runDashboard update check wiring", () => { + it("suppresses stale cached update status in the TUI after the installed CLI version changes", async () => { + process.env.FUSION_DASHBOARD_TOKEN = "fn_test_dashboard_token"; + writeUpdateCache({ + updateAvailable: true, + currentVersion: "0.0.1", + latestVersion: "9.9.9", + }); + + const { DashboardTUI } = await import("../dashboard-tui/index.js"); + const originalStdoutIsTTY = process.stdout.isTTY; + const originalStdinIsTTY = process.stdin.isTTY; + + Object.defineProperty(process.stdout, "isTTY", { value: true, configurable: true }); + Object.defineProperty(process.stdin, "isTTY", { value: true, configurable: true }); + + const tuiStartSpy = vi.spyOn(DashboardTUI.prototype, "start").mockResolvedValue(undefined); + const tuiStopSpy = vi.spyOn(DashboardTUI.prototype, "stop").mockResolvedValue(undefined); + const setUpdateStatusSpy = vi.spyOn(DashboardTUI.prototype, "setUpdateStatus"); + + try { + await runDashboard(0, { open: false, dev: true }); + + expect(setUpdateStatusSpy).toHaveBeenCalledWith(null); + } finally { + Object.defineProperty(process.stdout, "isTTY", { value: originalStdoutIsTTY, configurable: true }); + Object.defineProperty(process.stdin, "isTTY", { value: originalStdinIsTTY, configurable: true }); + tuiStartSpy.mockRestore(); + tuiStopSpy.mockRestore(); + setUpdateStatusSpy.mockRestore(); + delete process.env.FUSION_DASHBOARD_TOKEN; + } + }); +}); diff --git a/packages/cli/src/commands/dashboard-tui/controller.ts b/packages/cli/src/commands/dashboard-tui/controller.ts index 5b0af64c4..a59a15d63 100644 --- a/packages/cli/src/commands/dashboard-tui/controller.ts +++ b/packages/cli/src/commands/dashboard-tui/controller.ts @@ -2,7 +2,6 @@ import os from "node:os"; import v8 from "node:v8"; import { execSync } from "node:child_process"; import { appendFileSync } from "node:fs"; -import { getCachedUpdateStatus } from "../../update-cache.js"; // `os.freemem()` on macOS only counts truly-free pages and excludes the large // "inactive"/cached pool that the OS will reclaim on demand — so total-free @@ -130,17 +129,6 @@ export class DashboardTUI { constructor() { this.logBuffer = new LogRingBuffer(); - // Read the update-check cache synchronously at construction time so the - // splash screen and status bar can render the notice immediately, without - // any network access. - const cached = getCachedUpdateStatus(); - if (cached) { - this.updateStatus = { - updateAvailable: cached.updateAvailable, - currentVersion: cached.currentVersion, - latestVersion: cached.latestVersion, - }; - } } // ── Subscription API (for Ink App) ──────────────────────────────────────── diff --git a/packages/cli/src/commands/dashboard.ts b/packages/cli/src/commands/dashboard.ts index 5acece6de..4734fa939 100644 --- a/packages/cli/src/commands/dashboard.ts +++ b/packages/cli/src/commands/dashboard.ts @@ -21,6 +21,7 @@ import { createServer, GitHubClient, createSkillsAdapter, + getCliPackageVersion, getProjectSettingsPath, loadTlsCredentialsFromEnv, stopAllDevServers, @@ -92,6 +93,49 @@ function createDashboardRuntimeLogger(logSink: DashboardLogSink, scope: string): }; } +type StartupUpdateStatus = { + updateAvailable: true; + latestVersion: string; + currentVersion: string; +}; + +async function resolveCachedStartupUpdateStatus(importMetaUrl: string): Promise { + try { + const updateCheckEnabled = await Promise.race([ + isUpdateCheckEnabled(), + new Promise((resolve) => { + setTimeout(() => resolve(false), 3_000); + }), + ]); + + if (!updateCheckEnabled) { + return null; + } + + const currentVersion = getCliPackageVersion(importMetaUrl); + const cachedUpdate = getCachedUpdateStatus(currentVersion); + if (!cachedUpdate?.updateAvailable) { + return null; + } + + return { + updateAvailable: true, + currentVersion: cachedUpdate.currentVersion, + latestVersion: cachedUpdate.latestVersion, + }; + } catch { + return null; + } +} + +function formatUpdateMessage(updateStatus: StartupUpdateStatus | null): string | null { + if (!updateStatus) { + return null; + } + + return `⬆ Update available: v${updateStatus.latestVersion} (current: v${updateStatus.currentVersion})`; +} + export class StreamedLogBuffer { private pending = ""; private flushTimer: ReturnType | null = null; @@ -666,6 +710,7 @@ export async function runDashboard(port: number, opts: { paused?: boolean; dev?: const isTTY = isTTYAvailable(); let tui: DashboardTUI | undefined; const dashboardStartedAt = Date.now(); + const startupUpdateStatusPromise = resolveCachedStartupUpdateStatus(import.meta.url); // Declare store and agentStore early so callbacks can safely reference them // (they're assigned after initialization, but the variables exist from the start). @@ -678,6 +723,9 @@ export async function runDashboard(port: number, opts: { paused?: boolean; dev?: if (isTTY) { tui = new DashboardTUI(); + void startupUpdateStatusPromise.then((updateStatus) => { + tui?.setUpdateStatus(updateStatus); + }); // Set up callbacks for utility actions tui.setCallbacks({ onRefreshStats: async () => { @@ -1825,29 +1873,7 @@ export async function runDashboard(port: number, opts: { paused?: boolean; dev?: ? `${baseUrl}/?token=${encodeURIComponent(dashboardAuthToken)}` : baseUrl; - const updateMessage = await (async (): Promise => { - try { - const updateCheckEnabled = await Promise.race([ - isUpdateCheckEnabled(), - new Promise((resolve) => { - setTimeout(() => resolve(false), 3_000); - }), - ]); - - if (!updateCheckEnabled) { - return null; - } - - const cachedUpdate = getCachedUpdateStatus(); - if (!cachedUpdate?.updateAvailable) { - return null; - } - - return `⬆ Update available: v${cachedUpdate.latestVersion} (current: v${cachedUpdate.currentVersion})`; - } catch { - return null; - } - })(); + const updateMessage = formatUpdateMessage(await startupUpdateStatusPromise); // ── TTY Mode: Set system info on TUI ─────────────────────────────── // diff --git a/packages/cli/src/update-cache.ts b/packages/cli/src/update-cache.ts index ec0214882..ec087c4a8 100644 --- a/packages/cli/src/update-cache.ts +++ b/packages/cli/src/update-cache.ts @@ -14,7 +14,7 @@ type UpdateCachePayload = { currentVersion?: unknown; }; -export function getCachedUpdateStatus(): CachedUpdateStatus | null { +export function getCachedUpdateStatus(currentVersion?: string): CachedUpdateStatus | null { try { const cachePath = join(resolveGlobalDir(), "update-check.json"); const raw = readFileSync(cachePath, "utf-8"); @@ -27,6 +27,14 @@ export function getCachedUpdateStatus(): CachedUpdateStatus | null { typeof parsed.currentVersion === "string" && parsed.currentVersion.length > 0 ) { + if ( + typeof currentVersion === "string" && + currentVersion.length > 0 && + parsed.currentVersion !== currentVersion + ) { + return null; + } + return { updateAvailable: true, latestVersion: parsed.latestVersion, diff --git a/packages/dashboard/src/index.ts b/packages/dashboard/src/index.ts index b1fa4f0a0..11b20cd6a 100644 --- a/packages/dashboard/src/index.ts +++ b/packages/dashboard/src/index.ts @@ -15,6 +15,7 @@ export { GitHubClient, isPrMergeReady, type PrMergeStatus, type PrCheckStatus, t export { rateLimit, RATE_LIMITS, type RateLimitOptions } from "./rate-limit.js"; export { GitHubPollingService, type GitHubPollingServiceOptions, type TaskWatchInput, type WatchedBadgeType } from "./github-poll.js"; export { GitHubIssueCommentService, DEFAULT_COMMENT_TEMPLATE } from "./github-issue-comment.js"; +export { getCliPackageVersion, resolveCliPackageVersionInfo, type CliPackageVersionInfo } from "./cli-package-version.js"; export { ApiError, type ApiErrorResponse,