From 25d8043b9de6f779b320169e1c8ea607ec6f28a3 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sat, 17 Jan 2026 12:07:14 +0000 Subject: [PATCH] feat: add gateway update check on start --- docs/install/updating.md | 2 + src/config/schema.ts | 2 + src/config/types.clawdbot.ts | 2 + src/config/zod-schema.ts | 1 + src/gateway/server.impl.ts | 2 + src/infra/update-startup.test.ts | 92 +++++++++++++++++++++++ src/infra/update-startup.ts | 121 +++++++++++++++++++++++++++++++ 7 files changed, 222 insertions(+) create mode 100644 src/infra/update-startup.test.ts create mode 100644 src/infra/update-startup.ts diff --git a/docs/install/updating.md b/docs/install/updating.md index 476fea5b2..66671be17 100644 --- a/docs/install/updating.md +++ b/docs/install/updating.md @@ -64,6 +64,8 @@ clawdbot update --channel stable Use `--tag ` for a one-off install tag/version. +Note: on npm installs, the gateway logs an update hint on startup (checks the current channel tag). Disable via `update.checkOnStart: false`. + Then: ```bash diff --git a/src/config/schema.ts b/src/config/schema.ts index 7055fc996..6e625a15c 100644 --- a/src/config/schema.ts +++ b/src/config/schema.ts @@ -98,6 +98,7 @@ const GROUP_ORDER: Record = { const FIELD_LABELS: Record = { "update.channel": "Update Channel", + "update.checkOnStart": "Update Check on Start", "gateway.remote.url": "Remote Gateway URL", "gateway.remote.sshTarget": "Remote Gateway SSH Target", "gateway.remote.sshIdentity": "Remote Gateway SSH Identity", @@ -277,6 +278,7 @@ const FIELD_LABELS: Record = { const FIELD_HELP: Record = { "update.channel": 'Update channel for npm installs ("stable" or "beta").', + "update.checkOnStart": "Check for npm updates when the gateway starts (default: true).", "gateway.remote.url": "Remote Gateway WebSocket URL (ws:// or wss://).", "gateway.remote.sshTarget": "Remote gateway over SSH (tunnels the gateway port to localhost). Format: user@host or user@host:port.", diff --git a/src/config/types.clawdbot.ts b/src/config/types.clawdbot.ts index 84555504f..8bd959e6d 100644 --- a/src/config/types.clawdbot.ts +++ b/src/config/types.clawdbot.ts @@ -52,6 +52,8 @@ export type ClawdbotConfig = { update?: { /** Update channel for npm installs ("stable" or "beta"). */ channel?: "stable" | "beta"; + /** Check for updates on gateway start (npm installs only). */ + checkOnStart?: boolean; }; browser?: BrowserConfig; ui?: { diff --git a/src/config/zod-schema.ts b/src/config/zod-schema.ts index 081ba32ea..7532f5936 100644 --- a/src/config/zod-schema.ts +++ b/src/config/zod-schema.ts @@ -64,6 +64,7 @@ export const ClawdbotSchema = z update: z .object({ channel: z.union([z.literal("stable"), z.literal("beta")]).optional(), + checkOnStart: z.boolean().optional(), }) .optional(), browser: z diff --git a/src/gateway/server.impl.ts b/src/gateway/server.impl.ts index 89dad77b3..325e01435 100644 --- a/src/gateway/server.impl.ts +++ b/src/gateway/server.impl.ts @@ -22,6 +22,7 @@ import { refreshRemoteBinsForConnectedNodes, setSkillsRemoteBridge, } from "../infra/skills-remote.js"; +import { scheduleGatewayUpdateCheck } from "../infra/update-startup.js"; import { autoMigrateLegacyState } from "../infra/state-migrations.js"; import { createSubsystemLogger, runtimeForLogger } from "../logging.js"; import type { PluginServicesHandle } from "../plugins/services.js"; @@ -406,6 +407,7 @@ export async function startGatewayServer( log, isNixMode, }); + scheduleGatewayUpdateCheck({ cfg: cfgAtStart, log, isNixMode }); const tailscaleCleanup = await startGatewayTailscaleExposure({ tailscaleMode, resetOnExit: tailscaleConfig.resetOnExit, diff --git a/src/infra/update-startup.test.ts b/src/infra/update-startup.test.ts new file mode 100644 index 000000000..f60b1fdec --- /dev/null +++ b/src/infra/update-startup.test.ts @@ -0,0 +1,92 @@ +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import type { UpdateCheckResult } from "./update-check.js"; + +vi.mock("./clawdbot-root.js", () => ({ + resolveClawdbotPackageRoot: vi.fn(), +})); + +vi.mock("./update-check.js", async () => { + const actual = await vi.importActual("./update-check.js"); + return { + ...actual, + checkUpdateStatus: vi.fn(), + fetchNpmTagVersion: vi.fn(), + }; +}); + +vi.mock("../version.js", () => ({ + VERSION: "1.0.0", +})); + +describe("update-startup", () => { + const originalEnv = { ...process.env }; + let tempDir: string; + + beforeEach(async () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date("2026-01-17T10:00:00Z")); + tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-update-check-")); + process.env.CLAWDBOT_STATE_DIR = tempDir; + delete process.env.VITEST; + process.env.NODE_ENV = "test"; + }); + + afterEach(async () => { + vi.useRealTimers(); + process.env = { ...originalEnv }; + await fs.rm(tempDir, { recursive: true, force: true }); + }); + + it("logs update hint for npm installs when newer tag exists", async () => { + const { resolveClawdbotPackageRoot } = await import("./clawdbot-root.js"); + const { checkUpdateStatus, fetchNpmTagVersion } = await import("./update-check.js"); + const { runGatewayUpdateCheck } = await import("./update-startup.js"); + + vi.mocked(resolveClawdbotPackageRoot).mockResolvedValue("/opt/clawdbot"); + vi.mocked(checkUpdateStatus).mockResolvedValue({ + root: "/opt/clawdbot", + installKind: "package", + packageManager: "npm", + } satisfies UpdateCheckResult); + vi.mocked(fetchNpmTagVersion).mockResolvedValue({ + tag: "latest", + version: "2.0.0", + }); + + const log = { info: vi.fn() }; + await runGatewayUpdateCheck({ + cfg: { update: { channel: "stable" } }, + log, + isNixMode: false, + allowInTests: true, + }); + + expect(log.info).toHaveBeenCalledWith( + expect.stringContaining("update available (latest): v2.0.0"), + ); + + const statePath = path.join(tempDir, "update-check.json"); + const raw = await fs.readFile(statePath, "utf-8"); + const parsed = JSON.parse(raw) as { lastNotifiedVersion?: string }; + expect(parsed.lastNotifiedVersion).toBe("2.0.0"); + }); + + it("skips update check when disabled in config", async () => { + const { runGatewayUpdateCheck } = await import("./update-startup.js"); + const log = { info: vi.fn() }; + + await runGatewayUpdateCheck({ + cfg: { update: { checkOnStart: false } }, + log, + isNixMode: false, + allowInTests: true, + }); + + expect(log.info).not.toHaveBeenCalled(); + await expect(fs.stat(path.join(tempDir, "update-check.json"))).rejects.toThrow(); + }); +}); diff --git a/src/infra/update-startup.ts b/src/infra/update-startup.ts new file mode 100644 index 000000000..ffdce44e0 --- /dev/null +++ b/src/infra/update-startup.ts @@ -0,0 +1,121 @@ +import fs from "node:fs/promises"; +import path from "node:path"; + +import type { loadConfig } from "../config/config.js"; +import { resolveStateDir } from "../config/paths.js"; +import { resolveClawdbotPackageRoot } from "./clawdbot-root.js"; +import { compareSemverStrings, fetchNpmTagVersion, checkUpdateStatus } from "./update-check.js"; +import { VERSION } from "../version.js"; + +type UpdateCheckState = { + lastCheckedAt?: string; + lastNotifiedVersion?: string; + lastNotifiedTag?: string; +}; + +const UPDATE_CHECK_FILENAME = "update-check.json"; +const UPDATE_CHECK_INTERVAL_MS = 24 * 60 * 60 * 1000; + +function normalizeChannel(value?: string | null): "stable" | "beta" | null { + if (!value) return null; + const normalized = value.trim().toLowerCase(); + if (normalized === "stable" || normalized === "beta") return normalized; + return null; +} + +function channelToTag(channel: "stable" | "beta"): string { + return channel === "beta" ? "beta" : "latest"; +} + +function shouldSkipCheck(allowInTests: boolean): boolean { + if (allowInTests) return false; + if (process.env.VITEST || process.env.NODE_ENV === "test") return true; + return false; +} + +async function readState(statePath: string): Promise { + try { + const raw = await fs.readFile(statePath, "utf-8"); + const parsed = JSON.parse(raw) as UpdateCheckState; + return parsed && typeof parsed === "object" ? parsed : {}; + } catch { + return {}; + } +} + +async function writeState(statePath: string, state: UpdateCheckState): Promise { + await fs.mkdir(path.dirname(statePath), { recursive: true }); + await fs.writeFile(statePath, JSON.stringify(state, null, 2), "utf-8"); +} + +export async function runGatewayUpdateCheck(params: { + cfg: ReturnType; + log: { info: (msg: string, meta?: Record) => void }; + isNixMode: boolean; + allowInTests?: boolean; +}): Promise { + if (shouldSkipCheck(Boolean(params.allowInTests))) return; + if (params.isNixMode) return; + if (params.cfg.update?.checkOnStart === false) return; + + const statePath = path.join(resolveStateDir(), UPDATE_CHECK_FILENAME); + const state = await readState(statePath); + const now = Date.now(); + const lastCheckedAt = state.lastCheckedAt ? Date.parse(state.lastCheckedAt) : null; + if (lastCheckedAt && Number.isFinite(lastCheckedAt)) { + if (now - lastCheckedAt < UPDATE_CHECK_INTERVAL_MS) return; + } + + const root = await resolveClawdbotPackageRoot({ + moduleUrl: import.meta.url, + argv1: process.argv[1], + cwd: process.cwd(), + }); + const status = await checkUpdateStatus({ + root, + timeoutMs: 2500, + fetchGit: false, + includeRegistry: false, + }); + + const nextState: UpdateCheckState = { + ...state, + lastCheckedAt: new Date(now).toISOString(), + }; + + if (status.installKind !== "package") { + await writeState(statePath, nextState); + return; + } + + const channel = normalizeChannel(params.cfg.update?.channel) ?? "stable"; + const tag = channelToTag(channel); + const tagStatus = await fetchNpmTagVersion({ tag, timeoutMs: 2500 }); + if (!tagStatus.version) { + await writeState(statePath, nextState); + return; + } + + const cmp = compareSemverStrings(VERSION, tagStatus.version); + if (cmp != null && cmp < 0) { + const shouldNotify = + state.lastNotifiedVersion !== tagStatus.version || state.lastNotifiedTag !== tag; + if (shouldNotify) { + params.log.info( + `update available (${tag}): v${tagStatus.version} (current v${VERSION}). Run: clawdbot update`, + ); + nextState.lastNotifiedVersion = tagStatus.version; + nextState.lastNotifiedTag = tag; + } + } + + await writeState(statePath, nextState); +} + +export function scheduleGatewayUpdateCheck(params: { + cfg: ReturnType; + log: { info: (msg: string, meta?: Record) => void }; + isNixMode: boolean; +}): void { + void runGatewayUpdateCheck(params).catch(() => {}); +}