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 { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||||
import { EventEmitter } from "node:events";
|
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 ───────────────────────────────────
|
// ── Capture instances & arguments ───────────────────────────────────
|
||||||
|
|
||||||
@@ -19,6 +24,7 @@ const {
|
|||||||
mockGlobalSettingsGetSettings,
|
mockGlobalSettingsGetSettings,
|
||||||
mockGlobalSettingsUpdateSettings,
|
mockGlobalSettingsUpdateSettings,
|
||||||
mockDaemonTokenGetOrCreate,
|
mockDaemonTokenGetOrCreate,
|
||||||
|
mockGetCliPackageVersion,
|
||||||
} = vi.hoisted(() => {
|
} = vi.hoisted(() => {
|
||||||
delete process.env.FUSION_DASHBOARD_TOKEN;
|
delete process.env.FUSION_DASHBOARD_TOKEN;
|
||||||
delete process.env.FUSION_DAEMON_TOKEN;
|
delete process.env.FUSION_DAEMON_TOKEN;
|
||||||
@@ -43,6 +49,7 @@ const {
|
|||||||
mockGlobalSettingsGetSettings: vi.fn().mockResolvedValue({}),
|
mockGlobalSettingsGetSettings: vi.fn().mockResolvedValue({}),
|
||||||
mockGlobalSettingsUpdateSettings: vi.fn().mockResolvedValue({}),
|
mockGlobalSettingsUpdateSettings: vi.fn().mockResolvedValue({}),
|
||||||
mockDaemonTokenGetOrCreate: vi.fn().mockResolvedValue("fn_test_dashboard_token"),
|
mockDaemonTokenGetOrCreate: vi.fn().mockResolvedValue("fn_test_dashboard_token"),
|
||||||
|
mockGetCliPackageVersion: vi.fn(),
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -173,6 +180,7 @@ vi.mock("@fusion/core", () => ({
|
|||||||
getEnabledPiExtensionPaths: vi.fn(() => []),
|
getEnabledPiExtensionPaths: vi.fn(() => []),
|
||||||
resolveGlobalDir: mockResolveGlobalDir,
|
resolveGlobalDir: mockResolveGlobalDir,
|
||||||
GlobalSettingsStore: vi.fn().mockImplementation(() => ({
|
GlobalSettingsStore: vi.fn().mockImplementation(() => ({
|
||||||
|
init: vi.fn().mockResolvedValue(undefined),
|
||||||
getSettings: mockGlobalSettingsGetSettings,
|
getSettings: mockGlobalSettingsGetSettings,
|
||||||
updateSettings: mockGlobalSettingsUpdateSettings,
|
updateSettings: mockGlobalSettingsUpdateSettings,
|
||||||
})),
|
})),
|
||||||
@@ -275,6 +283,7 @@ vi.mock("@fusion/dashboard", () => ({
|
|||||||
mergePr: mockMergePr,
|
mergePr: mockMergePr,
|
||||||
})),
|
})),
|
||||||
createSkillsAdapter: vi.fn().mockReturnValue(undefined),
|
createSkillsAdapter: vi.fn().mockReturnValue(undefined),
|
||||||
|
getCliPackageVersion: mockGetCliPackageVersion,
|
||||||
getProjectSettingsPath: vi.fn().mockReturnValue("/tmp/project/.fusion/settings.json"),
|
getProjectSettingsPath: vi.fn().mockReturnValue("/tmp/project/.fusion/settings.json"),
|
||||||
loadTlsCredentialsFromEnv: vi.fn().mockReturnValue(undefined),
|
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(() => {
|
beforeEach(() => {
|
||||||
delete process.env.FUSION_DASHBOARD_TOKEN;
|
delete process.env.FUSION_DASHBOARD_TOKEN;
|
||||||
delete process.env.FUSION_DAEMON_TOKEN;
|
delete process.env.FUSION_DAEMON_TOKEN;
|
||||||
@@ -774,10 +791,14 @@ beforeEach(() => {
|
|||||||
mockGlobalSettingsUpdateSettings.mockResolvedValue({});
|
mockGlobalSettingsUpdateSettings.mockResolvedValue({});
|
||||||
mockDaemonTokenGetOrCreate.mockReset();
|
mockDaemonTokenGetOrCreate.mockReset();
|
||||||
mockDaemonTokenGetOrCreate.mockResolvedValue("fn_test_dashboard_token");
|
mockDaemonTokenGetOrCreate.mockResolvedValue("fn_test_dashboard_token");
|
||||||
|
mockGetCliPackageVersion.mockReset();
|
||||||
|
mockGetCliPackageVersion.mockReturnValue(CLI_PACKAGE_VERSION);
|
||||||
|
rmSync(updateCacheDir, { recursive: true, force: true });
|
||||||
});
|
});
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
disposeTrackedDashboards();
|
disposeTrackedDashboards();
|
||||||
|
rmSync(updateCacheDir, { recursive: true, force: true });
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("PR merge helpers", () => {
|
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 v8 from "node:v8";
|
||||||
import { execSync } from "node:child_process";
|
import { execSync } from "node:child_process";
|
||||||
import { appendFileSync } from "node:fs";
|
import { appendFileSync } from "node:fs";
|
||||||
import { getCachedUpdateStatus } from "../../update-cache.js";
|
|
||||||
|
|
||||||
// `os.freemem()` on macOS only counts truly-free pages and excludes the large
|
// `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
|
// "inactive"/cached pool that the OS will reclaim on demand — so total-free
|
||||||
@@ -130,17 +129,6 @@ export class DashboardTUI {
|
|||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
this.logBuffer = new LogRingBuffer();
|
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) ────────────────────────────────────────
|
// ── Subscription API (for Ink App) ────────────────────────────────────────
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ import {
|
|||||||
createServer,
|
createServer,
|
||||||
GitHubClient,
|
GitHubClient,
|
||||||
createSkillsAdapter,
|
createSkillsAdapter,
|
||||||
|
getCliPackageVersion,
|
||||||
getProjectSettingsPath,
|
getProjectSettingsPath,
|
||||||
loadTlsCredentialsFromEnv,
|
loadTlsCredentialsFromEnv,
|
||||||
stopAllDevServers,
|
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 {
|
export class StreamedLogBuffer {
|
||||||
private pending = "";
|
private pending = "";
|
||||||
private flushTimer: ReturnType<typeof setTimeout> | null = null;
|
private flushTimer: ReturnType<typeof setTimeout> | null = null;
|
||||||
@@ -666,6 +710,7 @@ export async function runDashboard(port: number, opts: { paused?: boolean; dev?:
|
|||||||
const isTTY = isTTYAvailable();
|
const isTTY = isTTYAvailable();
|
||||||
let tui: DashboardTUI | undefined;
|
let tui: DashboardTUI | undefined;
|
||||||
const dashboardStartedAt = Date.now();
|
const dashboardStartedAt = Date.now();
|
||||||
|
const startupUpdateStatusPromise = resolveCachedStartupUpdateStatus(import.meta.url);
|
||||||
|
|
||||||
// Declare store and agentStore early so callbacks can safely reference them
|
// Declare store and agentStore early so callbacks can safely reference them
|
||||||
// (they're assigned after initialization, but the variables exist from the start).
|
// (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) {
|
if (isTTY) {
|
||||||
tui = new DashboardTUI();
|
tui = new DashboardTUI();
|
||||||
|
void startupUpdateStatusPromise.then((updateStatus) => {
|
||||||
|
tui?.setUpdateStatus(updateStatus);
|
||||||
|
});
|
||||||
// Set up callbacks for utility actions
|
// Set up callbacks for utility actions
|
||||||
tui.setCallbacks({
|
tui.setCallbacks({
|
||||||
onRefreshStats: async () => {
|
onRefreshStats: async () => {
|
||||||
@@ -1825,29 +1873,7 @@ export async function runDashboard(port: number, opts: { paused?: boolean; dev?:
|
|||||||
? `${baseUrl}/?token=${encodeURIComponent(dashboardAuthToken)}`
|
? `${baseUrl}/?token=${encodeURIComponent(dashboardAuthToken)}`
|
||||||
: baseUrl;
|
: baseUrl;
|
||||||
|
|
||||||
const updateMessage = await (async (): Promise<string | null> => {
|
const updateMessage = formatUpdateMessage(await startupUpdateStatusPromise);
|
||||||
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;
|
|
||||||
}
|
|
||||||
})();
|
|
||||||
|
|
||||||
// ── TTY Mode: Set system info on TUI ───────────────────────────────
|
// ── TTY Mode: Set system info on TUI ───────────────────────────────
|
||||||
//
|
//
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ type UpdateCachePayload = {
|
|||||||
currentVersion?: unknown;
|
currentVersion?: unknown;
|
||||||
};
|
};
|
||||||
|
|
||||||
export function getCachedUpdateStatus(): CachedUpdateStatus | null {
|
export function getCachedUpdateStatus(currentVersion?: string): CachedUpdateStatus | null {
|
||||||
try {
|
try {
|
||||||
const cachePath = join(resolveGlobalDir(), "update-check.json");
|
const cachePath = join(resolveGlobalDir(), "update-check.json");
|
||||||
const raw = readFileSync(cachePath, "utf-8");
|
const raw = readFileSync(cachePath, "utf-8");
|
||||||
@@ -27,6 +27,14 @@ export function getCachedUpdateStatus(): CachedUpdateStatus | null {
|
|||||||
typeof parsed.currentVersion === "string" &&
|
typeof parsed.currentVersion === "string" &&
|
||||||
parsed.currentVersion.length > 0
|
parsed.currentVersion.length > 0
|
||||||
) {
|
) {
|
||||||
|
if (
|
||||||
|
typeof currentVersion === "string" &&
|
||||||
|
currentVersion.length > 0 &&
|
||||||
|
parsed.currentVersion !== currentVersion
|
||||||
|
) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
updateAvailable: true,
|
updateAvailable: true,
|
||||||
latestVersion: parsed.latestVersion,
|
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 { rateLimit, RATE_LIMITS, type RateLimitOptions } from "./rate-limit.js";
|
||||||
export { GitHubPollingService, type GitHubPollingServiceOptions, type TaskWatchInput, type WatchedBadgeType } from "./github-poll.js";
|
export { GitHubPollingService, type GitHubPollingServiceOptions, type TaskWatchInput, type WatchedBadgeType } from "./github-poll.js";
|
||||||
export { GitHubIssueCommentService, DEFAULT_COMMENT_TEMPLATE } from "./github-issue-comment.js";
|
export { GitHubIssueCommentService, DEFAULT_COMMENT_TEMPLATE } from "./github-issue-comment.js";
|
||||||
|
export { getCliPackageVersion, resolveCliPackageVersionInfo, type CliPackageVersionInfo } from "./cli-package-version.js";
|
||||||
export {
|
export {
|
||||||
ApiError,
|
ApiError,
|
||||||
type ApiErrorResponse,
|
type ApiErrorResponse,
|
||||||
|
|||||||
Reference in New Issue
Block a user