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:
@@ -46,6 +46,7 @@ import {
|
||||
resolveClaudeCliExtensionPaths,
|
||||
setCachedClaudeCliResolution,
|
||||
} from "./claude-cli-extension.js";
|
||||
import { getCachedUpdateStatus, isUpdateCheckEnabled } from "../update-cache.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";
|
||||
|
||||
@@ -1812,6 +1813,30 @@ 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;
|
||||
}
|
||||
})();
|
||||
|
||||
// ── TTY Mode: Set system info on TUI ───────────────────────────────
|
||||
//
|
||||
// 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("File watcher active");
|
||||
if (updateMessage) {
|
||||
tui.log(updateMessage);
|
||||
}
|
||||
} else {
|
||||
// ── 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(` File watcher: ✓ active`);
|
||||
if (updateMessage) {
|
||||
console.log(` ${updateMessage}`);
|
||||
}
|
||||
console.log(` Press Ctrl+C to stop`);
|
||||
console.log();
|
||||
}
|
||||
|
||||
48
packages/cli/src/update-cache.ts
Normal file
48
packages/cli/src/update-cache.ts
Normal 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;
|
||||
}
|
||||
@@ -29,6 +29,7 @@ export const DEFAULT_GLOBAL_SETTINGS = {
|
||||
favoriteProviders: undefined,
|
||||
favoriteModels: undefined,
|
||||
openrouterModelSync: true,
|
||||
updateCheckEnabled: true,
|
||||
modelOnboardingComplete: undefined,
|
||||
useClaudeCli: undefined,
|
||||
// Global baseline lanes for per-role model selection
|
||||
|
||||
@@ -1089,6 +1089,9 @@ export interface GlobalSettings {
|
||||
* the OpenRouter API at startup so the model picker shows all available
|
||||
* OpenRouter models (not just the static built-in list). Default: true. */
|
||||
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
|
||||
* (connected at least one provider and selected a default model). When
|
||||
* false/undefined, the dashboard will auto-open the onboarding modal.
|
||||
|
||||
@@ -13,6 +13,7 @@ import { DashboardLoader, type DashboardLoaderStage } from "./components/Dashboa
|
||||
import { ExecutorStatusBar } from "./components/ExecutorStatusBar";
|
||||
import { SessionNotificationBanner } from "./components/SessionNotificationBanner";
|
||||
import { SetupWarningBanner } from "./components/SetupWarningBanner";
|
||||
import { UpdateAvailableBanner } from "./components/UpdateAvailableBanner";
|
||||
import { OnboardingResumeCard } from "./components/OnboardingResumeCard";
|
||||
import { PostOnboardingRecommendations } from "./components/PostOnboardingRecommendations";
|
||||
import {
|
||||
@@ -37,6 +38,7 @@ import { useDeepLink } from "./hooks/useDeepLink";
|
||||
import { useFavorites } from "./hooks/useFavorites";
|
||||
import { useAuthOnboarding } from "./hooks/useAuthOnboarding";
|
||||
import { useSetupReadiness } from "./hooks/useSetupReadiness";
|
||||
import { useUpdateCheck } from "./hooks/useUpdateCheck";
|
||||
import { useViewState, type TaskView } from "./hooks/useViewState";
|
||||
import { useProjectActions } from "./hooks/useProjectActions";
|
||||
import { useTaskHandlers } from "./hooks/useTaskHandlers";
|
||||
@@ -115,6 +117,13 @@ function AppInner() {
|
||||
loading: setupReadinessLoading,
|
||||
hasWarnings,
|
||||
} = useSetupReadiness(currentProject?.id);
|
||||
const {
|
||||
updateAvailable,
|
||||
latestVersion,
|
||||
currentVersion,
|
||||
dismissed: updateBannerDismissed,
|
||||
dismiss: dismissUpdateBanner,
|
||||
} = useUpdateCheck();
|
||||
|
||||
// Sync node context with useNodes() results:
|
||||
// - Resolve saved node ID to full NodeConfig when nodes list loads
|
||||
@@ -850,6 +859,13 @@ function AppInner() {
|
||||
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 && (
|
||||
<SetupWarningBanner
|
||||
hasAiProvider={hasAiProvider}
|
||||
|
||||
@@ -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 {
|
||||
remoteActiveProvider: "tailscale" | "cloudflare" | null;
|
||||
remoteTailscaleEnabled: boolean;
|
||||
|
||||
73
packages/dashboard/app/components/UpdateAvailableBanner.css
Normal file
73
packages/dashboard/app/components/UpdateAvailableBanner.css
Normal 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;
|
||||
}
|
||||
}
|
||||
35
packages/dashboard/app/components/UpdateAvailableBanner.tsx
Normal file
35
packages/dashboard/app/components/UpdateAvailableBanner.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
63
packages/dashboard/app/hooks/useUpdateCheck.ts
Normal file
63
packages/dashboard/app/hooks/useUpdateCheck.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
@@ -34,17 +34,32 @@ import * as terminalServiceModule from "../terminal-service.js";
|
||||
import { get as performGet, request as performRequest } from "../test-request.js";
|
||||
import { resetRuntimeLogSink, setRuntimeLogSink } from "../runtime-logger.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
|
||||
const mockCentralListProjects = vi.fn().mockResolvedValue([]);
|
||||
const mockCentralInit = vi.fn().mockResolvedValue(undefined);
|
||||
const mockCentralClose = 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 () => {
|
||||
const actual = await vi.importActual<typeof import("@fusion/core")>("@fusion/core");
|
||||
return {
|
||||
...actual,
|
||||
resolveGlobalDir: vi.fn().mockReturnValue("/tmp/fusion-test"),
|
||||
isGhAvailable: vi.fn(),
|
||||
isGhAuthenticated: vi.fn(),
|
||||
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 ---
|
||||
|
||||
function createMockAuthStorage(overrides: Partial<AuthStorageLike> = {}): AuthStorageLike {
|
||||
|
||||
145
packages/dashboard/src/__tests__/update-check.test.ts
Normal file
145
packages/dashboard/src/__tests__/update-check.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
@@ -60,6 +60,7 @@ import { registerProxyRoutes } from "./routes/register-proxy-routes.js";
|
||||
import { registerModelRoutes } from "./routes/register-model-routes.js";
|
||||
import { registerUsageRoutes } from "./routes/register-usage-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 { runGitCommand } from "./routes/resolve-diff-base.js";
|
||||
|
||||
@@ -1518,6 +1519,7 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout
|
||||
});
|
||||
|
||||
registerUsageRoutes(routeContext);
|
||||
registerUpdateCheckRoutes(routeContext);
|
||||
|
||||
// ── Automation / Scheduled Task Routes ────────────────────────────
|
||||
//
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
});
|
||||
};
|
||||
111
packages/dashboard/src/update-check.ts
Normal file
111
packages/dashboard/src/update-check.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user