feat(FN-2663): add cached update-check setting, APIs, and banner

- Add global updateCheckEnabled setting to core schema/types and wire dashboard command to cache update checks in the CLI
- Implement dashboard server update-check cache module plus REST routes for status and refresh behavior
- Add dashboard client hook, legacy API helpers, and UpdateAvailableBanner UI to show cached CLI update notices
- Cover update-check server routes, hook behavior, banner rendering, and route registration with focused tests
- Document update-check configuration and API behavior in architecture and settings reference docs
This commit is contained in:
Fusion
2026-04-27 01:40:07 -07:00
committed by gsxdsm
parent 90a28dfe23
commit e80850e7c9
18 changed files with 827 additions and 0 deletions

View File

@@ -499,6 +499,21 @@ A `prefetchLazyViews()` function runs once on mount via `requestIdleCallback` to
- Response: `{ status: "ok", version: string, uptime: number }` - Response: `{ status: "ok", version: string, uptime: number }`
- No authentication required - No authentication required
### Node settings sync and update-check endpoints
| Method | Path | Description |
|---|---|---|
| GET | `/api/nodes/:id/settings` | Fetch settings from a remote node. |
| POST | `/api/nodes/:id/settings/push` | Push local settings to a remote node. |
| POST | `/api/nodes/:id/settings/pull` | Pull settings from a remote node. |
| GET | `/api/nodes/:id/settings/sync-status` | Get sync status and diff summary. |
| POST | `/api/nodes/:id/auth/sync` | Sync model auth credentials. |
| POST | `/api/settings/sync-receive` | Receive pushed settings (inbound). |
| POST | `/api/settings/auth-receive` | Receive auth credentials (inbound). |
| GET | `/api/settings/auth-export` | Export local auth credentials. |
| GET | `/api/update-check` | Read cached/TTL-guarded npm update status for `@runfusion/fusion` (respects `updateCheckEnabled`). |
| POST | `/api/update-check/refresh` | Clear cached update data and force a fresh npm update check. |
### Run Audit API ### Run Audit API
The run-audit system records every mutation performed by the engine across three domains: The run-audit system records every mutation performed by the engine across three domains:
- **Database** — task:create, task:update, task:move, etc. - **Database** — task:create, task:update, task:move, etc.

View File

@@ -48,6 +48,7 @@ Defaults from `DEFAULT_GLOBAL_SETTINGS`; key scope from `GLOBAL_SETTINGS_KEYS`.
| `favoriteProviders` | `string[]` | `undefined` | Pinned providers shown first in model selectors. | | `favoriteProviders` | `string[]` | `undefined` | Pinned providers shown first in model selectors. |
| `favoriteModels` | `string[]` | `undefined` | Pinned models in `{provider}/{modelId}` format. | | `favoriteModels` | `string[]` | `undefined` | Pinned models in `{provider}/{modelId}` format. |
| `openrouterModelSync` | `boolean` | `true` | Sync OpenRouter model catalog into model pickers at startup. | | `openrouterModelSync` | `boolean` | `true` | Sync OpenRouter model catalog into model pickers at startup. |
| `updateCheckEnabled` | `boolean` | `true` | When enabled, Fusion performs a daily npm registry check for new `@runfusion/fusion` versions and shows update notices in CLI/dashboard. |
| `modelOnboardingComplete` | `boolean` | `undefined` | Whether AI onboarding has been completed or dismissed. | | `modelOnboardingComplete` | `boolean` | `undefined` | Whether AI onboarding has been completed or dismissed. |
| `executionGlobalProvider` | `string` | `undefined` | Global baseline provider for task execution. Project `executionProvider` overrides this. | | `executionGlobalProvider` | `string` | `undefined` | Global baseline provider for task execution. Project `executionProvider` overrides this. |
| `executionGlobalModelId` | `string` | `undefined` | Global baseline model ID for task execution. | | `executionGlobalModelId` | `string` | `undefined` | Global baseline model ID for task execution. |
@@ -67,6 +68,12 @@ Defaults from `DEFAULT_GLOBAL_SETTINGS`; key scope from `GLOBAL_SETTINGS_KEYS`.
| `dashboardCurrentNodeId` | `string` | `undefined` | Currently selected dashboard node ID. Restores the last-viewed node on fresh browser/PWA sessions. `undefined` means viewing the local node. | | `dashboardCurrentNodeId` | `string` | `undefined` | Currently selected dashboard node ID. Restores the last-viewed node on fresh browser/PWA sessions. `undefined` means viewing the local node. |
| `dashboardCurrentProjectIdByNode` | `Record<string, string>` | `undefined` | Map of node ID to last-selected project ID. Use key `"local"` for the local node. Persists project context across browser restarts and PWA sessions. | | `dashboardCurrentProjectIdByNode` | `Record<string, string>` | `undefined` | Map of node ID to last-selected project ID. Use key `"local"` for the local node. Persists project context across browser restarts and PWA sessions. |
Disable daily update checks globally:
```bash
fn settings set updateCheckEnabled false
```
--- ---
## Project Settings ## Project Settings

View File

@@ -46,6 +46,7 @@ import {
resolveClaudeCliExtensionPaths, resolveClaudeCliExtensionPaths,
setCachedClaudeCliResolution, setCachedClaudeCliResolution,
} from "./claude-cli-extension.js"; } from "./claude-cli-extension.js";
import { getCachedUpdateStatus, isUpdateCheckEnabled } from "../update-cache.js";
import { resolveSelfExtension } from "./self-extension.js"; import { resolveSelfExtension } from "./self-extension.js";
import { DashboardTUI, DashboardLogSink, isTTYAvailable, type SystemInfo, type GitStatus, type GitCommit, type GitCommitDetail, type GitBranch, type GitWorktree, type FileEntry, type FileReadResult, type TaskStep as TUITaskStep, type TaskLogEntry as TUITaskLogEntry, type TaskDetailData, type TaskEvent } from "./dashboard-tui/index.js"; import { DashboardTUI, DashboardLogSink, isTTYAvailable, type SystemInfo, type GitStatus, type GitCommit, type GitCommitDetail, type GitBranch, type GitWorktree, type FileEntry, type FileReadResult, type TaskStep as TUITaskStep, type TaskLogEntry as TUITaskLogEntry, type TaskDetailData, type TaskEvent } from "./dashboard-tui/index.js";
@@ -1812,6 +1813,30 @@ 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> => {
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 ───────────────────────────────
// //
// In TTY mode, we populate the TUI System panel instead of printing // In TTY mode, we populate the TUI System panel instead of printing
@@ -2290,6 +2315,9 @@ export async function runDashboard(port: number, opts: { paused?: boolean; dev?:
tui.log("AI engine paused"); tui.log("AI engine paused");
} }
tui.log("File watcher active"); tui.log("File watcher active");
if (updateMessage) {
tui.log(updateMessage);
}
} else { } else {
// ── Non-TTY Mode: Print plain-text banner ─────────────────────────── // ── Non-TTY Mode: Print plain-text banner ───────────────────────────
// //
@@ -2320,6 +2348,9 @@ export async function runDashboard(port: number, opts: { paused?: boolean; dev?:
console.log(` • cron: scheduled task execution`); console.log(` • cron: scheduled task execution`);
} }
console.log(` File watcher: ✓ active`); console.log(` File watcher: ✓ active`);
if (updateMessage) {
console.log(` ${updateMessage}`);
}
console.log(` Press Ctrl+C to stop`); console.log(` Press Ctrl+C to stop`);
console.log(); console.log();
} }

View File

@@ -0,0 +1,48 @@
import { readFileSync } from "node:fs";
import { join } from "node:path";
import { GlobalSettingsStore, resolveGlobalDir } from "@fusion/core";
type CachedUpdateStatus = {
updateAvailable: boolean;
latestVersion: string;
currentVersion: string;
};
type UpdateCachePayload = {
updateAvailable?: unknown;
latestVersion?: unknown;
currentVersion?: unknown;
};
export function getCachedUpdateStatus(): CachedUpdateStatus | null {
try {
const cachePath = join(resolveGlobalDir(), "update-check.json");
const raw = readFileSync(cachePath, "utf-8");
const parsed = JSON.parse(raw) as UpdateCachePayload;
if (
parsed.updateAvailable === true &&
typeof parsed.latestVersion === "string" &&
parsed.latestVersion.length > 0 &&
typeof parsed.currentVersion === "string" &&
parsed.currentVersion.length > 0
) {
return {
updateAvailable: true,
latestVersion: parsed.latestVersion,
currentVersion: parsed.currentVersion,
};
}
return null;
} catch {
return null;
}
}
export async function isUpdateCheckEnabled(): Promise<boolean> {
const store = new GlobalSettingsStore();
await store.init();
const settings = await store.getSettings();
return settings.updateCheckEnabled !== false;
}

View File

@@ -29,6 +29,7 @@ export const DEFAULT_GLOBAL_SETTINGS = {
favoriteProviders: undefined, favoriteProviders: undefined,
favoriteModels: undefined, favoriteModels: undefined,
openrouterModelSync: true, openrouterModelSync: true,
updateCheckEnabled: true,
modelOnboardingComplete: undefined, modelOnboardingComplete: undefined,
useClaudeCli: undefined, useClaudeCli: undefined,
// Global baseline lanes for per-role model selection // Global baseline lanes for per-role model selection

View File

@@ -1089,6 +1089,9 @@ export interface GlobalSettings {
* the OpenRouter API at startup so the model picker shows all available * the OpenRouter API at startup so the model picker shows all available
* OpenRouter models (not just the static built-in list). Default: true. */ * OpenRouter models (not just the static built-in list). Default: true. */
openrouterModelSync?: boolean; openrouterModelSync?: boolean;
/** When true (default), checks npm daily for new versions of @runfusion/fusion
* and shows update notices in the CLI and dashboard. */
updateCheckEnabled?: boolean;
/** When true, indicates the user has completed the AI model onboarding flow /** When true, indicates the user has completed the AI model onboarding flow
* (connected at least one provider and selected a default model). When * (connected at least one provider and selected a default model). When
* false/undefined, the dashboard will auto-open the onboarding modal. * false/undefined, the dashboard will auto-open the onboarding modal.

View File

@@ -13,6 +13,7 @@ import { DashboardLoader, type DashboardLoaderStage } from "./components/Dashboa
import { ExecutorStatusBar } from "./components/ExecutorStatusBar"; import { ExecutorStatusBar } from "./components/ExecutorStatusBar";
import { SessionNotificationBanner } from "./components/SessionNotificationBanner"; import { SessionNotificationBanner } from "./components/SessionNotificationBanner";
import { SetupWarningBanner } from "./components/SetupWarningBanner"; import { SetupWarningBanner } from "./components/SetupWarningBanner";
import { UpdateAvailableBanner } from "./components/UpdateAvailableBanner";
import { OnboardingResumeCard } from "./components/OnboardingResumeCard"; import { OnboardingResumeCard } from "./components/OnboardingResumeCard";
import { PostOnboardingRecommendations } from "./components/PostOnboardingRecommendations"; import { PostOnboardingRecommendations } from "./components/PostOnboardingRecommendations";
import { import {
@@ -37,6 +38,7 @@ import { useDeepLink } from "./hooks/useDeepLink";
import { useFavorites } from "./hooks/useFavorites"; import { useFavorites } from "./hooks/useFavorites";
import { useAuthOnboarding } from "./hooks/useAuthOnboarding"; import { useAuthOnboarding } from "./hooks/useAuthOnboarding";
import { useSetupReadiness } from "./hooks/useSetupReadiness"; import { useSetupReadiness } from "./hooks/useSetupReadiness";
import { useUpdateCheck } from "./hooks/useUpdateCheck";
import { useViewState, type TaskView } from "./hooks/useViewState"; import { useViewState, type TaskView } from "./hooks/useViewState";
import { useProjectActions } from "./hooks/useProjectActions"; import { useProjectActions } from "./hooks/useProjectActions";
import { useTaskHandlers } from "./hooks/useTaskHandlers"; import { useTaskHandlers } from "./hooks/useTaskHandlers";
@@ -115,6 +117,13 @@ function AppInner() {
loading: setupReadinessLoading, loading: setupReadinessLoading,
hasWarnings, hasWarnings,
} = useSetupReadiness(currentProject?.id); } = useSetupReadiness(currentProject?.id);
const {
updateAvailable,
latestVersion,
currentVersion,
dismissed: updateBannerDismissed,
dismiss: dismissUpdateBanner,
} = useUpdateCheck();
// Sync node context with useNodes() results: // Sync node context with useNodes() results:
// - Resolve saved node ID to full NodeConfig when nodes list loads // - Resolve saved node ID to full NodeConfig when nodes list loads
@@ -850,6 +859,13 @@ function AppInner() {
onOpenSettings={(section) => modalManager.openSettings(section as SectionId)} onOpenSettings={(section) => modalManager.openSettings(section as SectionId)}
/> />
)} )}
{viewMode === "project" && currentProject && updateAvailable && latestVersion && currentVersion && !updateBannerDismissed && (
<UpdateAvailableBanner
latestVersion={latestVersion}
currentVersion={currentVersion}
onDismiss={dismissUpdateBanner}
/>
)}
{viewMode === "project" && currentProject && !setupReadinessLoading && hasWarnings && !setupWarningDismissed && ( {viewMode === "project" && currentProject && !setupReadinessLoading && hasWarnings && !setupWarningDismissed && (
<SetupWarningBanner <SetupWarningBanner
hasAiProvider={hasAiProvider} hasAiProvider={hasAiProvider}

View File

@@ -410,6 +410,25 @@ export function updateSettings(settings: Partial<Settings>, projectId?: string):
}); });
} }
export interface UpdateCheckResponse {
currentVersion?: string;
latestVersion?: string | null;
updateAvailable: boolean;
lastChecked?: number;
disabled?: boolean;
error?: string;
}
export function checkForUpdate(projectId?: string): Promise<UpdateCheckResponse> {
return api<UpdateCheckResponse>(withProjectId("/update-check", projectId));
}
export function refreshUpdateCheck(projectId?: string): Promise<UpdateCheckResponse> {
return api<UpdateCheckResponse>(withProjectId("/update-check/refresh", projectId), {
method: "POST",
});
}
export interface RemoteSettings { export interface RemoteSettings {
remoteActiveProvider: "tailscale" | "cloudflare" | null; remoteActiveProvider: "tailscale" | "cloudflare" | null;
remoteTailscaleEnabled: boolean; remoteTailscaleEnabled: boolean;

View File

@@ -0,0 +1,73 @@
/* === UpdateAvailableBanner === */
.update-available-banner {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: var(--space-sm);
padding: var(--space-sm) var(--space-md);
margin-bottom: var(--space-md);
border-radius: var(--radius-md);
border: 1px solid color-mix(in srgb, var(--color-info) 35%, var(--border));
border-inline-start: var(--space-xs) solid var(--color-info);
background: color-mix(in srgb, var(--color-info) 10%, var(--surface));
color: var(--text);
}
.update-available-banner__text {
margin: 0;
color: var(--text);
}
.update-available-banner__text code {
font-family: var(--font-mono);
}
.update-available-banner__link {
color: var(--color-info);
text-decoration: underline;
text-underline-offset: var(--space-xs);
transition: color var(--transition-fast);
}
.update-available-banner__link:hover {
color: var(--text);
}
.update-available-banner__link:focus-visible {
outline: none;
box-shadow: var(--focus-ring-strong);
border-radius: var(--radius-sm);
}
.update-available-banner__dismiss {
background: none;
border: none;
color: var(--text-muted);
cursor: pointer;
padding: var(--space-xs);
border-radius: var(--radius-sm);
transition: color var(--transition-fast), background var(--transition-fast);
line-height: 0;
flex-shrink: 0;
}
.update-available-banner__dismiss:hover {
color: var(--text);
background: color-mix(in srgb, var(--color-info) 14%, transparent);
}
.update-available-banner__dismiss:focus-visible {
outline: none;
box-shadow: var(--focus-ring-strong);
}
@media (max-width: 768px) {
.update-available-banner {
flex-direction: column;
align-items: stretch;
}
.update-available-banner__dismiss {
align-self: flex-end;
}
}

View File

@@ -0,0 +1,35 @@
import "./UpdateAvailableBanner.css";
import { X } from "lucide-react";
interface UpdateAvailableBannerProps {
latestVersion: string;
currentVersion: string;
onDismiss: () => void;
}
export function UpdateAvailableBanner({ latestVersion, currentVersion, onDismiss }: UpdateAvailableBannerProps) {
return (
<div className="update-available-banner" role="status" aria-live="polite">
<p className="update-available-banner__text">
Update available: v{latestVersion} (current: v{currentVersion}). Run <code>npm i -g @runfusion/fusion</code> to
update. {" "}
<a
className="update-available-banner__link"
href="https://github.com/Runfusion/Fusion/releases"
target="_blank"
rel="noreferrer"
>
Release notes
</a>
</p>
<button
type="button"
className="update-available-banner__dismiss touch-target"
aria-label="Dismiss update notice"
onClick={onDismiss}
>
<X size={16} aria-hidden="true" />
</button>
</div>
);
}

View File

@@ -0,0 +1,50 @@
import { describe, it, expect, vi } from "vitest";
import { fireEvent, render, screen } from "@testing-library/react";
import { useState } from "react";
import { UpdateAvailableBanner } from "../UpdateAvailableBanner";
describe("UpdateAvailableBanner", () => {
it("renders version information and release notes link", () => {
render(
<UpdateAvailableBanner latestVersion="0.7.0" currentVersion="0.6.0" onDismiss={vi.fn()} />,
);
expect(screen.getByText(/Update available: v0.7.0 \(current: v0.6.0\)/)).toBeInTheDocument();
expect(screen.getByText("npm i -g @runfusion/fusion")).toBeInTheDocument();
expect(screen.getByRole("link", { name: "Release notes" })).toHaveAttribute(
"href",
"https://github.com/Runfusion/Fusion/releases",
);
});
it("dismiss button calls onDismiss", () => {
const onDismiss = vi.fn();
render(
<UpdateAvailableBanner latestVersion="0.7.0" currentVersion="0.6.0" onDismiss={onDismiss} />,
);
fireEvent.click(screen.getByRole("button", { name: "Dismiss update notice" }));
expect(onDismiss).toHaveBeenCalledTimes(1);
});
it("can be hidden by parent on dismiss", () => {
function Harness() {
const [visible, setVisible] = useState(true);
if (!visible) return null;
return (
<UpdateAvailableBanner
latestVersion="0.7.0"
currentVersion="0.6.0"
onDismiss={() => setVisible(false)}
/>
);
}
render(<Harness />);
expect(screen.getByRole("status")).toBeInTheDocument();
fireEvent.click(screen.getByRole("button", { name: "Dismiss update notice" }));
expect(screen.queryByRole("status")).toBeNull();
});
});

View File

@@ -0,0 +1,67 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { renderHook, waitFor, act } from "@testing-library/react";
import { useUpdateCheck } from "../useUpdateCheck";
import * as api from "../../api";
vi.mock("../../api", () => ({
checkForUpdate: vi.fn(),
}));
const mockCheckForUpdate = vi.mocked(api.checkForUpdate);
describe("useUpdateCheck", () => {
beforeEach(() => {
vi.clearAllMocks();
sessionStorage.clear();
});
it("fetches update status on mount", async () => {
mockCheckForUpdate.mockResolvedValueOnce({
currentVersion: "0.6.0",
latestVersion: "0.7.0",
updateAvailable: true,
lastChecked: Date.now(),
});
const { result } = renderHook(() => useUpdateCheck());
await waitFor(() => expect(result.current.loading).toBe(false));
expect(mockCheckForUpdate).toHaveBeenCalledOnce();
expect(result.current.updateAvailable).toBe(true);
expect(result.current.latestVersion).toBe("0.7.0");
expect(result.current.currentVersion).toBe("0.6.0");
});
it("dismiss stores session flag", async () => {
mockCheckForUpdate.mockResolvedValueOnce({
currentVersion: "0.6.0",
latestVersion: "0.7.0",
updateAvailable: true,
});
const { result } = renderHook(() => useUpdateCheck());
await waitFor(() => expect(result.current.loading).toBe(false));
act(() => {
result.current.dismiss();
});
expect(result.current.dismissed).toBe(true);
expect(sessionStorage.getItem("kb-update-banner-dismissed")).toBe("true");
});
it("starts dismissed when sessionStorage already has dismissal key", async () => {
sessionStorage.setItem("kb-update-banner-dismissed", "true");
mockCheckForUpdate.mockResolvedValueOnce({
currentVersion: "0.6.0",
latestVersion: "0.7.0",
updateAvailable: true,
});
const { result } = renderHook(() => useUpdateCheck());
await waitFor(() => expect(result.current.loading).toBe(false));
expect(result.current.dismissed).toBe(true);
});
});

View File

@@ -0,0 +1,63 @@
import { useCallback, useEffect, useState } from "react";
import { checkForUpdate } from "../api";
const UPDATE_BANNER_DISMISSED_KEY = "kb-update-banner-dismissed";
export interface UseUpdateCheckResult {
updateAvailable: boolean;
latestVersion: string | null;
currentVersion: string | null;
loading: boolean;
dismissed: boolean;
dismiss: () => void;
}
export function useUpdateCheck(): UseUpdateCheckResult {
const [loading, setLoading] = useState(true);
const [updateAvailable, setUpdateAvailable] = useState(false);
const [latestVersion, setLatestVersion] = useState<string | null>(null);
const [currentVersion, setCurrentVersion] = useState<string | null>(null);
const [dismissed, setDismissed] = useState(false);
useEffect(() => {
const isDismissed = sessionStorage.getItem(UPDATE_BANNER_DISMISSED_KEY) === "true";
setDismissed(isDismissed);
let cancelled = false;
void checkForUpdate()
.then((result) => {
if (cancelled || result.disabled) return;
setUpdateAvailable(result.updateAvailable === true);
setLatestVersion(typeof result.latestVersion === "string" ? result.latestVersion : null);
setCurrentVersion(typeof result.currentVersion === "string" ? result.currentVersion : null);
})
.catch(() => {
// Fail silently. Update checks are best-effort.
})
.finally(() => {
if (!cancelled) {
setLoading(false);
}
});
return () => {
cancelled = true;
};
}, []);
const dismiss = useCallback(() => {
setDismissed(true);
sessionStorage.setItem(UPDATE_BANNER_DISMISSED_KEY, "true");
}, []);
return {
updateAvailable,
latestVersion,
currentVersion,
loading,
dismissed,
dismiss,
};
}

View File

@@ -34,17 +34,32 @@ import * as terminalServiceModule from "../terminal-service.js";
import { get as performGet, request as performRequest } from "../test-request.js"; import { get as performGet, request as performRequest } from "../test-request.js";
import { resetRuntimeLogSink, setRuntimeLogSink } from "../runtime-logger.js"; import { resetRuntimeLogSink, setRuntimeLogSink } from "../runtime-logger.js";
import { resetDiagnosticsSink, setDiagnosticsSink, type LogEntry } from "../ai-session-diagnostics.js"; import { resetDiagnosticsSink, setDiagnosticsSink, type LogEntry } from "../ai-session-diagnostics.js";
import * as updateCheckModule from "../update-check.js";
// Mock @fusion/core for gh CLI auth checks // Mock @fusion/core for gh CLI auth checks
const mockCentralListProjects = vi.fn().mockResolvedValue([]); const mockCentralListProjects = vi.fn().mockResolvedValue([]);
const mockCentralInit = vi.fn().mockResolvedValue(undefined); const mockCentralInit = vi.fn().mockResolvedValue(undefined);
const mockCentralClose = vi.fn().mockResolvedValue(undefined); const mockCentralClose = vi.fn().mockResolvedValue(undefined);
const mockCentralReconcileProjectStatuses = vi.fn().mockResolvedValue(undefined); const mockCentralReconcileProjectStatuses = vi.fn().mockResolvedValue(undefined);
const { mockPerformUpdateCheck, mockClearUpdateCheckCache } = vi.hoisted(() => ({
mockPerformUpdateCheck: vi.fn(),
mockClearUpdateCheckCache: vi.fn(),
}));
vi.mock("../update-check.js", async () => {
const actual = await vi.importActual<typeof import("../update-check.js")>("../update-check.js");
return {
...actual,
performUpdateCheck: mockPerformUpdateCheck,
clearUpdateCheckCache: mockClearUpdateCheckCache,
};
});
vi.mock("@fusion/core", async () => { vi.mock("@fusion/core", async () => {
const actual = await vi.importActual<typeof import("@fusion/core")>("@fusion/core"); const actual = await vi.importActual<typeof import("@fusion/core")>("@fusion/core");
return { return {
...actual, ...actual,
resolveGlobalDir: vi.fn().mockReturnValue("/tmp/fusion-test"),
isGhAvailable: vi.fn(), isGhAvailable: vi.fn(),
isGhAuthenticated: vi.fn(), isGhAuthenticated: vi.fn(),
isQmdAvailable: vi.fn().mockResolvedValue(false), isQmdAvailable: vi.fn().mockResolvedValue(false),
@@ -4133,6 +4148,71 @@ describe("GET /usage", () => {
}); });
}); });
describe("/update-check routes", () => {
let store: TaskStore;
beforeEach(() => {
store = createMockStore();
mockPerformUpdateCheck.mockReset();
mockClearUpdateCheckCache.mockReset();
});
function buildApp() {
const app = express();
app.use(express.json());
app.use("/api", createApiRoutes(store));
return app;
}
it("GET /update-check returns disabled payload when update checks are disabled", async () => {
const mockGlobalStore = createMockGlobalSettingsStore();
mockGlobalStore.getSettings.mockResolvedValue({ updateCheckEnabled: false });
(store.getGlobalSettingsStore as ReturnType<typeof vi.fn>).mockReturnValue(mockGlobalStore);
const res = await GET(buildApp(), "/api/update-check");
expect(res.status).toBe(200);
expect(res.body.updateAvailable).toBe(false);
expect(res.body.disabled).toBe(true);
expect(mockPerformUpdateCheck).not.toHaveBeenCalled();
});
it("GET /update-check performs update check when enabled", async () => {
const mockGlobalStore = createMockGlobalSettingsStore();
mockGlobalStore.getSettings.mockResolvedValue({ updateCheckEnabled: true });
(store.getGlobalSettingsStore as ReturnType<typeof vi.fn>).mockReturnValue(mockGlobalStore);
mockPerformUpdateCheck.mockResolvedValue({
currentVersion: "0.1.0",
latestVersion: "0.2.0",
updateAvailable: true,
lastChecked: 123,
});
const res = await GET(buildApp(), "/api/update-check");
expect(res.status).toBe(200);
expect(res.body.updateAvailable).toBe(true);
expect(res.body.latestVersion).toBe("0.2.0");
expect(updateCheckModule.performUpdateCheck).toHaveBeenCalledOnce();
});
it("POST /update-check/refresh clears cache then rechecks", async () => {
mockPerformUpdateCheck.mockResolvedValue({
currentVersion: "0.1.0",
latestVersion: "0.1.0",
updateAvailable: false,
lastChecked: 123,
});
const res = await REQUEST(buildApp(), "POST", "/api/update-check/refresh");
expect(res.status).toBe(200);
expect(updateCheckModule.clearUpdateCheckCache).toHaveBeenCalledOnce();
expect(updateCheckModule.performUpdateCheck).toHaveBeenCalledOnce();
});
});
// --- Auth route tests --- // --- Auth route tests ---
function createMockAuthStorage(overrides: Partial<AuthStorageLike> = {}): AuthStorageLike { function createMockAuthStorage(overrides: Partial<AuthStorageLike> = {}): AuthStorageLike {

View File

@@ -0,0 +1,145 @@
import { mkdtemp, readFile, writeFile } from "node:fs/promises";
import { existsSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import {
clearUpdateCheckCache,
performUpdateCheck,
readCachedUpdateCheck,
type UpdateCheckResult,
} from "../update-check.js";
describe("update-check", () => {
let fusionDir: string;
beforeEach(async () => {
fusionDir = await mkdtemp(join(tmpdir(), "fn-update-check-"));
vi.restoreAllMocks();
});
afterEach(async () => {
await clearUpdateCheckCache(fusionDir);
vi.restoreAllMocks();
});
it("returns cached result when cache is still fresh", async () => {
const cached: UpdateCheckResult = {
currentVersion: "0.6.0",
latestVersion: "0.7.0",
updateAvailable: true,
lastChecked: Date.now(),
};
await writeFile(join(fusionDir, "update-check.json"), JSON.stringify(cached), "utf-8");
const fetchSpy = vi.fn();
vi.stubGlobal("fetch", fetchSpy);
const result = await performUpdateCheck(fusionDir, "0.6.0");
expect(result).toEqual(cached);
expect(fetchSpy).not.toHaveBeenCalled();
});
it("fetches latest version when cache is expired", async () => {
const stale: UpdateCheckResult = {
currentVersion: "0.6.0",
latestVersion: "0.6.0",
updateAvailable: false,
lastChecked: Date.now() - 25 * 60 * 60 * 1000,
};
await writeFile(join(fusionDir, "update-check.json"), JSON.stringify(stale), "utf-8");
const fetchSpy = vi.fn().mockResolvedValue({
json: async () => ({
"dist-tags": {
latest: "0.8.0",
},
}),
});
vi.stubGlobal("fetch", fetchSpy);
const result = await performUpdateCheck(fusionDir, "0.6.0");
expect(fetchSpy).toHaveBeenCalledOnce();
expect(result.latestVersion).toBe("0.8.0");
expect(result.updateAvailable).toBe(true);
});
it("handles semver comparisons for equal, newer, and older registry versions", async () => {
const fetchSpy = vi.fn();
vi.stubGlobal("fetch", fetchSpy);
fetchSpy.mockResolvedValueOnce({
json: async () => ({ "dist-tags": { latest: "1.2.3" } }),
});
const equalResult = await performUpdateCheck(fusionDir, "1.2.3");
expect(equalResult.updateAvailable).toBe(false);
await clearUpdateCheckCache(fusionDir);
fetchSpy.mockResolvedValueOnce({
json: async () => ({ "dist-tags": { latest: "1.2.4" } }),
});
const newerResult = await performUpdateCheck(fusionDir, "1.2.3");
expect(newerResult.updateAvailable).toBe(true);
await clearUpdateCheckCache(fusionDir);
fetchSpy.mockResolvedValueOnce({
json: async () => ({ "dist-tags": { latest: "1.2.2" } }),
});
const olderResult = await performUpdateCheck(fusionDir, "1.2.3");
expect(olderResult.updateAvailable).toBe(false);
});
it("returns a non-throwing error result when network fetch fails", async () => {
vi.stubGlobal("fetch", vi.fn().mockRejectedValue(new Error("network down")));
await expect(performUpdateCheck(fusionDir, "0.6.0")).resolves.toEqual(
expect.objectContaining({
currentVersion: "0.6.0",
latestVersion: null,
updateAvailable: false,
error: "network down",
}),
);
});
it("clearUpdateCheckCache removes the cache file", async () => {
const cachePath = join(fusionDir, "update-check.json");
await writeFile(cachePath, JSON.stringify({ ok: true }), "utf-8");
await clearUpdateCheckCache(fusionDir);
expect(existsSync(cachePath)).toBe(false);
});
it("readCachedUpdateCheck returns null for missing file and parsed result when present", async () => {
expect(readCachedUpdateCheck(fusionDir)).toBeNull();
const value: UpdateCheckResult = {
currentVersion: "0.6.0",
latestVersion: "0.7.0",
updateAvailable: true,
lastChecked: 123,
};
await writeFile(join(fusionDir, "update-check.json"), JSON.stringify(value), "utf-8");
expect(readCachedUpdateCheck(fusionDir)).toEqual(value);
});
it("persists fetched results to the cache file", async () => {
vi.stubGlobal(
"fetch",
vi.fn().mockResolvedValue({
json: async () => ({ "dist-tags": { latest: "0.7.0" } }),
}),
);
const result = await performUpdateCheck(fusionDir, "0.6.0");
const cachedRaw = await readFile(join(fusionDir, "update-check.json"), "utf-8");
expect(JSON.parse(cachedRaw)).toEqual(result);
});
});

View File

@@ -60,6 +60,7 @@ import { registerProxyRoutes } from "./routes/register-proxy-routes.js";
import { registerModelRoutes } from "./routes/register-model-routes.js"; import { registerModelRoutes } from "./routes/register-model-routes.js";
import { registerUsageRoutes } from "./routes/register-usage-routes.js"; import { registerUsageRoutes } from "./routes/register-usage-routes.js";
import { registerAuthRoutes } from "./routes/register-auth-routes.js"; import { registerAuthRoutes } from "./routes/register-auth-routes.js";
import { registerUpdateCheckRoutes } from "./routes/register-update-check-routes.js";
import { registerIntegratedRouters, registerIntegratedDevServerRouter } from "./routes/register-integrated-routers.js"; import { registerIntegratedRouters, registerIntegratedDevServerRouter } from "./routes/register-integrated-routers.js";
import { runGitCommand } from "./routes/resolve-diff-base.js"; import { runGitCommand } from "./routes/resolve-diff-base.js";
@@ -1518,6 +1519,7 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout
}); });
registerUsageRoutes(routeContext); registerUsageRoutes(routeContext);
registerUpdateCheckRoutes(routeContext);
// ── Automation / Scheduled Task Routes ──────────────────────────── // ── Automation / Scheduled Task Routes ────────────────────────────
// //

View File

@@ -0,0 +1,61 @@
import { readFileSync } from "node:fs";
import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
import { resolveGlobalDir } from "@fusion/core";
import { clearUpdateCheckCache, performUpdateCheck } from "../update-check.js";
import type { ApiRouteRegistrar } from "./types.js";
const __dirname = dirname(fileURLToPath(import.meta.url));
const CLI_PACKAGE_VERSION = (() => {
try {
const packageJsonPath = join(__dirname, "..", "..", "..", "cli", "package.json");
const packageJson = JSON.parse(readFileSync(packageJsonPath, "utf-8")) as {
version?: unknown;
};
if (typeof packageJson.version === "string" && packageJson.version.length > 0) {
return packageJson.version;
}
} catch {
// Fall through to env/default fallback.
}
return process.env.npm_package_version ?? "0.0.0";
})();
export const registerUpdateCheckRoutes: ApiRouteRegistrar = (ctx) => {
const { router, store, rethrowAsApiError } = ctx;
router.get("/update-check", async (_req, res) => {
try {
const globalSettings = await store.getGlobalSettingsStore().getSettings();
if (globalSettings.updateCheckEnabled === false) {
res.json({
updateAvailable: false,
disabled: true,
currentVersion: CLI_PACKAGE_VERSION,
latestVersion: null,
lastChecked: Date.now(),
});
return;
}
const result = await performUpdateCheck(resolveGlobalDir(), CLI_PACKAGE_VERSION);
res.json(result);
} catch (error) {
rethrowAsApiError(error, "Failed to perform update check");
}
});
router.post("/update-check/refresh", async (_req, res) => {
try {
const fusionDir = resolveGlobalDir();
await clearUpdateCheckCache(fusionDir);
const result = await performUpdateCheck(fusionDir, CLI_PACKAGE_VERSION);
res.json(result);
} catch (error) {
rethrowAsApiError(error, "Failed to refresh update check");
}
});
};

View File

@@ -0,0 +1,111 @@
import { readFileSync } from "node:fs";
import { mkdir, rm, writeFile } from "node:fs/promises";
import { join } from "node:path";
const CACHE_FILENAME = "update-check.json";
const CHECK_TTL_MS = 24 * 60 * 60 * 1000;
const REGISTRY_URL = "https://registry.npmjs.org/@runfusion%2Ffusion";
export type UpdateCheckResult = {
currentVersion: string;
latestVersion: string | null;
updateAvailable: boolean;
lastChecked: number;
error?: string;
};
function getCachePath(fusionDir: string): string {
return join(fusionDir, CACHE_FILENAME);
}
function parseVersion(version: string): number[] {
return version
.split(".")
.slice(0, 3)
.map((part) => Number.parseInt(part, 10))
.map((value) => (Number.isFinite(value) ? value : 0));
}
function isRemoteNewer(remoteVersion: string, currentVersion: string): boolean {
const remote = parseVersion(remoteVersion);
const current = parseVersion(currentVersion);
const maxLength = Math.max(remote.length, current.length, 3);
for (let i = 0; i < maxLength; i += 1) {
const remotePart = remote[i] ?? 0;
const currentPart = current[i] ?? 0;
if (remotePart > currentPart) return true;
if (remotePart < currentPart) return false;
}
return false;
}
function isValidResult(value: unknown): value is UpdateCheckResult {
if (!value || typeof value !== "object") return false;
const candidate = value as Record<string, unknown>;
return (
typeof candidate.currentVersion === "string" &&
(typeof candidate.latestVersion === "string" || candidate.latestVersion === null) &&
typeof candidate.updateAvailable === "boolean" &&
typeof candidate.lastChecked === "number" &&
(candidate.error === undefined || typeof candidate.error === "string")
);
}
export function readCachedUpdateCheck(fusionDir: string): UpdateCheckResult | null {
try {
const raw = readFileSync(getCachePath(fusionDir), "utf-8");
const parsed = JSON.parse(raw) as unknown;
return isValidResult(parsed) ? parsed : null;
} catch {
return null;
}
}
export async function clearUpdateCheckCache(fusionDir: string): Promise<void> {
await rm(getCachePath(fusionDir), { force: true });
}
export async function performUpdateCheck(fusionDir: string, currentVersion: string): Promise<UpdateCheckResult> {
const now = Date.now();
const cached = readCachedUpdateCheck(fusionDir);
if (cached && now - cached.lastChecked < CHECK_TTL_MS) {
return cached;
}
try {
const response = await fetch(REGISTRY_URL);
const payload = (await response.json()) as {
"dist-tags"?: {
latest?: string;
};
};
const latestVersion = typeof payload?.["dist-tags"]?.latest === "string" ? payload["dist-tags"].latest : null;
const updateAvailable = latestVersion ? isRemoteNewer(latestVersion, currentVersion) : false;
const result: UpdateCheckResult = {
currentVersion,
latestVersion,
updateAvailable,
lastChecked: now,
};
await mkdir(fusionDir, { recursive: true });
await writeFile(getCachePath(fusionDir), JSON.stringify(result, null, 2), "utf-8");
return result;
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
return {
currentVersion,
latestVersion: null,
updateAvailable: false,
lastChecked: now,
error: message,
};
}
}