feat(FN-2664): add dashboard updates check endpoint and settings flow
- Add GET /api/updates/check endpoint in dashboard server with coverage for success, failure, and disabled update scenarios - Extend legacy dashboard API client with check-for-updates request support - Add Settings modal update-check UI and styling for trigger, loading, and result states - Expand Settings modal tests and document the updates check endpoint in architecture docs
This commit is contained in:
@@ -192,6 +192,10 @@ export function fetchDashboardHealth(): Promise<DashboardHealthResponse> {
|
||||
return api<DashboardHealthResponse>("/health");
|
||||
}
|
||||
|
||||
export function checkForUpdates(): Promise<UpdateCheckResponse> {
|
||||
return api<UpdateCheckResponse>("/updates/check");
|
||||
}
|
||||
|
||||
export function fetchTasks(
|
||||
limit?: number,
|
||||
offset?: number,
|
||||
@@ -411,8 +415,8 @@ export function updateSettings(settings: Partial<Settings>, projectId?: string):
|
||||
}
|
||||
|
||||
export interface UpdateCheckResponse {
|
||||
currentVersion?: string;
|
||||
latestVersion?: string | null;
|
||||
currentVersion: string;
|
||||
latestVersion: string | null;
|
||||
updateAvailable: boolean;
|
||||
lastChecked?: number;
|
||||
disabled?: boolean;
|
||||
|
||||
@@ -20,6 +20,43 @@
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.settings-update-check {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-sm);
|
||||
}
|
||||
|
||||
.settings-update-btn {
|
||||
border-radius: var(--radius-sm);
|
||||
}
|
||||
|
||||
.settings-update-btn svg.spinning {
|
||||
animation: settings-update-spin 1s linear infinite;
|
||||
}
|
||||
|
||||
.settings-update-result {
|
||||
font-size: 0.85rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.settings-update-result--up-to-date {
|
||||
color: var(--color-success);
|
||||
}
|
||||
|
||||
.settings-update-result--available {
|
||||
color: var(--color-info);
|
||||
}
|
||||
|
||||
.settings-update-result--error {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
@keyframes settings-update-spin {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
.settings-layout {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
@@ -844,6 +881,11 @@
|
||||
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.settings-update-check {
|
||||
flex-wrap: wrap;
|
||||
row-gap: var(--space-xs);
|
||||
}
|
||||
|
||||
/* Settings modal: use the section picker as the only mobile navigation */
|
||||
.settings-layout {
|
||||
flex-direction: column;
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { useState, useEffect, useCallback, useRef, lazy, Suspense, type MouseEvent } from "react";
|
||||
import { Globe, Folder } from "lucide-react";
|
||||
import { Globe, Folder, RefreshCw } from "lucide-react";
|
||||
import { THINKING_LEVELS, isGlobalSettingsKey, isProjectSettingsKey, getErrorMessage } from "@fusion/core";
|
||||
import type { Settings, GlobalSettings, ThemeMode, ColorTheme, ModelPreset, NtfyNotificationEvent, AgentPromptsConfig, ThinkingLevel } from "@fusion/core";
|
||||
import { fetchSettings, fetchSettingsByScope, updateSettings, updateGlobalSettings, fetchAuthStatus, loginProvider, logoutProvider, saveApiKey, clearApiKey, fetchModels, testNtfyNotification, fetchBackups, createBackup, exportSettings, importSettings, fetchMemoryFile, fetchMemoryFiles, saveMemoryFile, compactMemory, fetchGlobalConcurrency, updateGlobalConcurrency, installQmd, testMemoryRetrieval, fetchGitRemotesDetailed, fetchDashboardHealth, fetchRemoteSettings, updateRemoteSettings, fetchRemoteStatus, activateRemoteProvider, startRemoteTunnel, stopRemoteTunnel, regenerateRemotePersistentToken, generateShortLivedRemoteToken, fetchRemoteQr, fetchRemoteUrl } from "../api";
|
||||
import type { AuthProvider, ModelInfo, BackupListResponse, SettingsExportData, MemoryFileInfo, MemoryRetrievalTestResult, GitRemoteDetailed, RemoteSettings, RemoteStatus } from "../api";
|
||||
import { fetchSettings, fetchSettingsByScope, updateSettings, updateGlobalSettings, fetchAuthStatus, loginProvider, logoutProvider, saveApiKey, clearApiKey, fetchModels, testNtfyNotification, fetchBackups, createBackup, exportSettings, importSettings, fetchMemoryFile, fetchMemoryFiles, saveMemoryFile, compactMemory, fetchGlobalConcurrency, updateGlobalConcurrency, installQmd, testMemoryRetrieval, fetchGitRemotesDetailed, fetchDashboardHealth, checkForUpdates, fetchRemoteSettings, updateRemoteSettings, fetchRemoteStatus, activateRemoteProvider, startRemoteTunnel, stopRemoteTunnel, regenerateRemotePersistentToken, generateShortLivedRemoteToken, fetchRemoteQr, fetchRemoteUrl } from "../api";
|
||||
import type { AuthProvider, ModelInfo, BackupListResponse, SettingsExportData, MemoryFileInfo, MemoryRetrievalTestResult, GitRemoteDetailed, RemoteSettings, RemoteStatus, UpdateCheckResponse } from "../api";
|
||||
import { useMemoryBackendStatus } from "../hooks/useMemoryBackendStatus";
|
||||
import type { ToastType } from "../hooks/useToast";
|
||||
import { ThemeSelector } from "./ThemeSelector";
|
||||
@@ -226,6 +226,8 @@ export function SettingsModal({
|
||||
: false,
|
||||
);
|
||||
const [appVersion, setAppVersion] = useState<string | null>(null);
|
||||
const [updateCheckLoading, setUpdateCheckLoading] = useState(false);
|
||||
const [updateCheckResult, setUpdateCheckResult] = useState<UpdateCheckResponse | null>(null);
|
||||
const [prefixError, setPrefixError] = useState<string | null>(null);
|
||||
const [overlapPathPickerIndex, setOverlapPathPickerIndex] = useState<number | null>(null);
|
||||
|
||||
@@ -413,6 +415,30 @@ export function SettingsModal({
|
||||
};
|
||||
}, []);
|
||||
|
||||
const handleCheckForUpdates = useCallback(async () => {
|
||||
setUpdateCheckLoading(true);
|
||||
|
||||
try {
|
||||
const result = await checkForUpdates();
|
||||
setUpdateCheckResult(result);
|
||||
|
||||
if (result.error) {
|
||||
addToast(result.error, "error");
|
||||
}
|
||||
} catch (error) {
|
||||
const message = getErrorMessage(error) || "Failed to check for updates";
|
||||
setUpdateCheckResult({
|
||||
currentVersion: appVersion ?? "unknown",
|
||||
latestVersion: null,
|
||||
updateAvailable: false,
|
||||
error: message,
|
||||
});
|
||||
addToast(message, "error");
|
||||
} finally {
|
||||
setUpdateCheckLoading(false);
|
||||
}
|
||||
}, [addToast, appVersion]);
|
||||
|
||||
// Load auth status when the authentication section is active
|
||||
const loadAuthStatus = useCallback(async () => {
|
||||
try {
|
||||
@@ -4040,7 +4066,39 @@ export function SettingsModal({
|
||||
<div className="modal-header">
|
||||
<div className="settings-modal-heading">
|
||||
<h3>Settings</h3>
|
||||
{appVersion && <p className="settings-modal-version">Version {appVersion}</p>}
|
||||
<div className="settings-update-check">
|
||||
{appVersion && <p className="settings-modal-version">Version {appVersion}</p>}
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-icon btn-sm settings-update-btn"
|
||||
onClick={() => {
|
||||
void handleCheckForUpdates();
|
||||
}}
|
||||
disabled={updateCheckLoading}
|
||||
aria-label="Check for updates"
|
||||
title="Check for updates"
|
||||
>
|
||||
<RefreshCw className={updateCheckLoading ? "spinning" : undefined} />
|
||||
</button>
|
||||
{updateCheckResult && (
|
||||
<span
|
||||
aria-live="polite"
|
||||
className={`settings-update-result ${
|
||||
updateCheckResult.error
|
||||
? "settings-update-result--error"
|
||||
: updateCheckResult.updateAvailable
|
||||
? "settings-update-result--available"
|
||||
: "settings-update-result--up-to-date"
|
||||
}`}
|
||||
>
|
||||
{updateCheckResult.error
|
||||
? updateCheckResult.error
|
||||
: updateCheckResult.updateAvailable && updateCheckResult.latestVersion
|
||||
? `v${updateCheckResult.latestVersion} available`
|
||||
: "You're up to date ✓"}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<button className="modal-close" onClick={onClose} aria-label="Close">
|
||||
×
|
||||
|
||||
@@ -2,7 +2,7 @@ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { render, screen, fireEvent, waitFor, within } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { SettingsModal } from "../SettingsModal";
|
||||
import type { SettingsExportData } from "../../api";
|
||||
import type { SettingsExportData, UpdateCheckResponse } from "../../api";
|
||||
|
||||
// --- API mocks ---
|
||||
const mockFetchSettings = vi.fn();
|
||||
@@ -29,6 +29,7 @@ const mockTestMemoryRetrieval = vi.fn();
|
||||
const mockInstallQmd = vi.fn();
|
||||
const mockFetchGitRemotesDetailed = vi.fn();
|
||||
const mockFetchDashboardHealth = vi.fn();
|
||||
const mockCheckForUpdates = vi.fn();
|
||||
const mockFetchRemoteSettings = vi.fn();
|
||||
const mockUpdateRemoteSettings = vi.fn();
|
||||
const mockFetchRemoteStatus = vi.fn();
|
||||
@@ -66,6 +67,7 @@ vi.mock("../../api", () => ({
|
||||
installQmd: (...args: unknown[]) => mockInstallQmd(...args),
|
||||
fetchGitRemotesDetailed: (...args: unknown[]) => mockFetchGitRemotesDetailed(...args),
|
||||
fetchDashboardHealth: (...args: unknown[]) => mockFetchDashboardHealth(...args),
|
||||
checkForUpdates: (...args: unknown[]) => mockCheckForUpdates(...args),
|
||||
fetchRemoteSettings: (...args: unknown[]) => mockFetchRemoteSettings(...args),
|
||||
updateRemoteSettings: (...args: unknown[]) => mockUpdateRemoteSettings(...args),
|
||||
fetchRemoteStatus: (...args: unknown[]) => mockFetchRemoteStatus(...args),
|
||||
@@ -84,6 +86,16 @@ vi.mock("../../hooks/useMemoryBackendStatus", () => ({
|
||||
useMemoryBackendStatus: (...args: unknown[]) => mockUseMemoryBackendStatus(...args),
|
||||
}));
|
||||
|
||||
vi.mock("lucide-react", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("lucide-react")>();
|
||||
return {
|
||||
...actual,
|
||||
Globe: () => <span data-testid="icon-globe" />,
|
||||
Folder: () => <span data-testid="icon-folder" />,
|
||||
RefreshCw: ({ className }: { className?: string }) => <span data-testid="icon-refresh" className={className} />,
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("../PluginManager", () => ({
|
||||
PluginManager: () => <div data-testid="plugin-manager">Plugin manager content</div>,
|
||||
}));
|
||||
@@ -198,6 +210,7 @@ describe("SettingsModal", () => {
|
||||
});
|
||||
mockFetchGitRemotesDetailed.mockResolvedValue([]);
|
||||
mockFetchDashboardHealth.mockResolvedValue({ status: "ok", version: "1.2.3", uptime: 123 });
|
||||
mockCheckForUpdates.mockResolvedValue(undefined);
|
||||
mockFetchRemoteSettings.mockResolvedValue({
|
||||
settings: {
|
||||
remoteActiveProvider: null,
|
||||
@@ -369,6 +382,72 @@ describe("SettingsModal", () => {
|
||||
expect(await screen.findByLabelText("Max Concurrent Tasks")).toBeInTheDocument();
|
||||
expect(addToast).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("renders check for updates button in header", async () => {
|
||||
renderModal();
|
||||
await waitForSettingsModalReady();
|
||||
|
||||
expect(screen.getByRole("button", { name: "Check for updates" })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("clicking check for updates shows up-to-date message", async () => {
|
||||
mockCheckForUpdates.mockResolvedValueOnce({
|
||||
currentVersion: "1.2.3",
|
||||
latestVersion: "1.2.3",
|
||||
updateAvailable: false,
|
||||
});
|
||||
|
||||
renderModal();
|
||||
await waitForSettingsModalReady();
|
||||
|
||||
await userEvent.click(screen.getByRole("button", { name: "Check for updates" }));
|
||||
|
||||
expect(await screen.findByText("You're up to date ✓")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("clicking check for updates shows update available message", async () => {
|
||||
mockCheckForUpdates.mockResolvedValueOnce({
|
||||
currentVersion: "1.0.0",
|
||||
latestVersion: "2.0.0",
|
||||
updateAvailable: true,
|
||||
});
|
||||
|
||||
renderModal();
|
||||
await waitForSettingsModalReady();
|
||||
|
||||
await userEvent.click(screen.getByRole("button", { name: "Check for updates" }));
|
||||
|
||||
expect(await screen.findByText("v2.0.0 available")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("disables button while checking", async () => {
|
||||
let resolveCheck: ((result: UpdateCheckResponse) => void) | undefined;
|
||||
const pendingCheck = new Promise<UpdateCheckResponse>((resolve) => {
|
||||
resolveCheck = resolve;
|
||||
});
|
||||
mockCheckForUpdates.mockReturnValueOnce(pendingCheck);
|
||||
|
||||
renderModal();
|
||||
await waitForSettingsModalReady();
|
||||
|
||||
const button = screen.getByRole("button", { name: "Check for updates" });
|
||||
expect(button).not.toBeDisabled();
|
||||
|
||||
fireEvent.click(button);
|
||||
await waitFor(() => {
|
||||
expect(button).toBeDisabled();
|
||||
});
|
||||
|
||||
resolveCheck?.({
|
||||
currentVersion: "1.2.3",
|
||||
latestVersion: "1.2.3",
|
||||
updateAvailable: false,
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(button).not.toBeDisabled();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("settings export filename", () => {
|
||||
|
||||
132
packages/dashboard/src/__tests__/update-check-route.test.ts
Normal file
132
packages/dashboard/src/__tests__/update-check-route.test.ts
Normal file
@@ -0,0 +1,132 @@
|
||||
// @vitest-environment node
|
||||
|
||||
import { readFileSync } from "node:fs";
|
||||
import { dirname, join } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { describe, it, expect, vi, afterEach } from "vitest";
|
||||
import type { TaskStore } from "@fusion/core";
|
||||
import { createServer } from "../server.js";
|
||||
import { get as performGet } from "../test-request.js";
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
const CLI_PACKAGE_VERSION = (() => {
|
||||
const packageJsonPath = join(__dirname, "..", "..", "..", "cli", "package.json");
|
||||
const packageJson = JSON.parse(readFileSync(packageJsonPath, "utf-8")) as {
|
||||
version?: unknown;
|
||||
};
|
||||
|
||||
return typeof packageJson.version === "string" ? packageJson.version : "0.0.0";
|
||||
})();
|
||||
|
||||
function createMockStore(overrides: Partial<TaskStore> = {}): TaskStore {
|
||||
return {
|
||||
getTask: vi.fn(),
|
||||
listTasks: vi.fn().mockResolvedValue([]),
|
||||
createTask: vi.fn(),
|
||||
moveTask: vi.fn(),
|
||||
updateTask: vi.fn(),
|
||||
deleteTask: vi.fn(),
|
||||
mergeTask: vi.fn(),
|
||||
archiveTask: vi.fn(),
|
||||
unarchiveTask: vi.fn(),
|
||||
getSettings: vi.fn().mockResolvedValue({}),
|
||||
updateSettings: vi.fn(),
|
||||
logEntry: vi.fn().mockResolvedValue(undefined),
|
||||
getAgentLogs: vi.fn().mockResolvedValue([]),
|
||||
addSteeringComment: vi.fn(),
|
||||
updatePrInfo: vi.fn().mockResolvedValue(undefined),
|
||||
updateIssueInfo: vi.fn().mockResolvedValue(undefined),
|
||||
getRootDir: vi.fn().mockReturnValue("/fake/root"),
|
||||
getFusionDir: vi.fn().mockReturnValue("/fake/root/.fusion"),
|
||||
getDatabase: vi.fn().mockReturnValue({
|
||||
exec: vi.fn(),
|
||||
prepare: vi.fn().mockReturnValue({ run: vi.fn().mockReturnValue({ changes: 0 }), get: vi.fn(), all: vi.fn().mockReturnValue([]) }),
|
||||
}),
|
||||
getMissionStore: vi.fn().mockReturnValue({
|
||||
listMissions: vi.fn().mockReturnValue([]),
|
||||
createMission: vi.fn(),
|
||||
getMissionWithHierarchy: vi.fn(),
|
||||
updateMission: vi.fn(),
|
||||
getMission: vi.fn(),
|
||||
deleteMission: vi.fn(),
|
||||
listMilestonesByMission: vi.fn().mockReturnValue([]),
|
||||
createMilestone: vi.fn(),
|
||||
updateMilestone: vi.fn(),
|
||||
getMilestone: vi.fn(),
|
||||
deleteMilestone: vi.fn(),
|
||||
listTasksByMilestone: vi.fn().mockReturnValue([]),
|
||||
createMissionTask: vi.fn(),
|
||||
updateMissionTask: vi.fn(),
|
||||
getMissionTask: vi.fn(),
|
||||
deleteMissionTask: vi.fn(),
|
||||
}),
|
||||
on: vi.fn(),
|
||||
off: vi.fn(),
|
||||
...overrides,
|
||||
} as unknown as TaskStore;
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
describe("GET /api/updates/check", () => {
|
||||
it("returns updateAvailable=true when npm has a newer version", async () => {
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({ version: "99.0.0" }),
|
||||
}),
|
||||
);
|
||||
|
||||
const app = createServer(createMockStore());
|
||||
const response = await performGet(app, "/api/updates/check");
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.headers["cache-control"]).toBe("no-store");
|
||||
expect(response.body).toEqual({
|
||||
currentVersion: CLI_PACKAGE_VERSION,
|
||||
latestVersion: "99.0.0",
|
||||
updateAvailable: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("returns updateAvailable=false when already up to date", async () => {
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({ version: CLI_PACKAGE_VERSION }),
|
||||
}),
|
||||
);
|
||||
|
||||
const app = createServer(createMockStore());
|
||||
const response = await performGet(app, "/api/updates/check");
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.headers["cache-control"]).toBe("no-store");
|
||||
expect(response.body).toEqual({
|
||||
currentVersion: CLI_PACKAGE_VERSION,
|
||||
latestVersion: CLI_PACKAGE_VERSION,
|
||||
updateAvailable: false,
|
||||
});
|
||||
});
|
||||
|
||||
it("gracefully returns an error payload when npm registry is unreachable", async () => {
|
||||
vi.stubGlobal("fetch", vi.fn().mockRejectedValue(new Error("network down")));
|
||||
|
||||
const app = createServer(createMockStore());
|
||||
const response = await performGet(app, "/api/updates/check");
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.headers["cache-control"]).toBe("no-store");
|
||||
expect(response.body).toEqual({
|
||||
currentVersion: CLI_PACKAGE_VERSION,
|
||||
latestVersion: null,
|
||||
updateAvailable: false,
|
||||
error: "Failed to check for updates",
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -68,6 +68,52 @@ const PACKAGE_VERSION = (() => {
|
||||
return process.env.npm_package_version ?? "0.0.0";
|
||||
})();
|
||||
|
||||
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 environment fallback.
|
||||
}
|
||||
|
||||
return process.env.npm_package_version ?? "0.0.0";
|
||||
})();
|
||||
|
||||
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 isRemoteVersionNewer(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;
|
||||
}
|
||||
|
||||
const DEFAULT_AI_SESSION_TTL_MS = SESSION_CLEANUP_DEFAULT_MAX_AGE_MS;
|
||||
const MIN_AI_SESSION_TTL_MS = 10 * 60 * 1000;
|
||||
const MAX_AI_SESSION_TTL_MS = 30 * 24 * 60 * 60 * 1000;
|
||||
@@ -921,6 +967,45 @@ export function createServer(store: TaskStore, options?: ServerOptions): ReturnT
|
||||
});
|
||||
});
|
||||
|
||||
app.get("/api/updates/check", async (_req, res) => {
|
||||
const currentVersion = CLI_PACKAGE_VERSION;
|
||||
res.set("Cache-Control", "no-store");
|
||||
|
||||
const controller = new AbortController();
|
||||
const timeout = setTimeout(() => controller.abort(), 10_000);
|
||||
|
||||
try {
|
||||
const response = await fetch("https://registry.npmjs.org/@runfusion/fusion/latest", {
|
||||
signal: controller.signal,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`registry request failed: ${response.status}`);
|
||||
}
|
||||
|
||||
const payload = (await response.json()) as { version?: unknown };
|
||||
if (typeof payload.version !== "string" || payload.version.trim().length === 0) {
|
||||
throw new Error("registry response missing version");
|
||||
}
|
||||
|
||||
const latestVersion = payload.version;
|
||||
res.json({
|
||||
currentVersion,
|
||||
latestVersion,
|
||||
updateAvailable: isRemoteVersionNewer(latestVersion, currentVersion),
|
||||
});
|
||||
} catch {
|
||||
res.status(200).json({
|
||||
currentVersion,
|
||||
latestVersion: null,
|
||||
updateAvailable: false,
|
||||
error: "Failed to check for updates",
|
||||
});
|
||||
} finally {
|
||||
clearTimeout(timeout);
|
||||
}
|
||||
});
|
||||
|
||||
app.get("/remote-login", async (req, res) => {
|
||||
const remoteToken = typeof req.query.rt === "string" ? req.query.rt : undefined;
|
||||
|
||||
|
||||
Reference in New Issue
Block a user