fix(FN-XXX): align tui startup update check

This commit is contained in:
gsxdsm
2026-04-29 07:54:45 -07:00
parent bd14cf84e3
commit 858e24468f
7 changed files with 177 additions and 36 deletions

View File

@@ -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.

View File

@@ -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();
});
});

View File

@@ -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;
}
});
});

View File

@@ -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) ────────────────────────────────────────

View File

@@ -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<StartupUpdateStatus | null> {
try {
const updateCheckEnabled = await Promise.race<boolean>([
isUpdateCheckEnabled(),
new Promise<boolean>((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<typeof setTimeout> | 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<string | null> => {
try {
const updateCheckEnabled = await Promise.race<boolean>([
isUpdateCheckEnabled(),
new Promise<boolean>((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 ───────────────────────────────
//

View File

@@ -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,

View File

@@ -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,