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:
@@ -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.
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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();
|
||||||
}
|
}
|
||||||
|
|||||||
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,
|
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
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
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 { 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 {
|
||||||
|
|||||||
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 { 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 ────────────────────────────
|
||||||
//
|
//
|
||||||
|
|||||||
@@ -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