fix(FN-XXX): align tui startup update check
This commit is contained in:
5
.changeset/fix-tui-update-check.md
Normal file
5
.changeset/fix-tui-update-check.md
Normal 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.
|
||||
57
packages/cli/src/__tests__/update-cache.test.ts
Normal file
57
packages/cli/src/__tests__/update-cache.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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) ────────────────────────────────────────
|
||||
|
||||
@@ -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 ───────────────────────────────
|
||||
//
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user