From 5a2ed4eb05c70b2aaeff4ee4d407cf42ea1f7fce Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Facu=5FVi=C3=B1as?= Date: Fri, 13 Mar 2026 14:28:43 -0300 Subject: [PATCH] feat: add startup update check with 24h cache MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Queries npm registry at most once per 24h to check if a newer version of gsd-pi is available. Displays a non-blocking banner in interactive mode when an update exists. The check is fire-and-forget — network errors or timeouts never block startup. Co-Authored-By: Claude Opus 4.6 --- src/cli.ts | 6 + src/loader.ts | 2 +- src/tests/update-check.test.ts | 327 +++++++++++++++++++++++++++++++++ src/update-check.ts | 114 ++++++++++++ 4 files changed, 448 insertions(+), 1 deletion(-) create mode 100644 src/tests/update-check.test.ts create mode 100644 src/update-check.ts diff --git a/src/cli.ts b/src/cli.ts index 1b52d40ef..92bc95044 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -17,6 +17,7 @@ import { ensureManagedTools } from './tool-bootstrap.js' import { loadStoredEnvKeys } from './wizard.js' import { getPiDefaultModelAndProvider, migratePiCredentials } from './pi-migration.js' import { shouldRunOnboarding, runOnboarding } from './onboarding.js' +import { checkForUpdates } from './update-check.js' // --------------------------------------------------------------------------- // Minimal CLI arg parser — detects print/subagent mode flags @@ -105,6 +106,11 @@ if (!isPrintMode && shouldRunOnboarding(authStorage)) { await runOnboarding(authStorage) } +// Non-blocking update check — runs at most once per 24h, fire-and-forget +if (!isPrintMode) { + checkForUpdates().catch(() => {}) +} + const modelRegistry = new ModelRegistry(authStorage) const settingsManager = SettingsManager.create(agentDir) diff --git a/src/loader.ts b/src/loader.ts index 09459bdba..b212c7731 100644 --- a/src/loader.ts +++ b/src/loader.ts @@ -15,7 +15,7 @@ const pkgDir = resolve(dirname(fileURLToPath(import.meta.url)), '..', 'pkg') // MUST be set before any dynamic import of pi SDK fires — this is what config.js // reads to determine APP_NAME and CONFIG_DIR_NAME process.env.PI_PACKAGE_DIR = pkgDir -process.env.PI_SKIP_VERSION_CHECK = '1' // GSD ships its own update check — suppress pi's +process.env.PI_SKIP_VERSION_CHECK = '1' // GSD runs its own update check in cli.ts — suppress pi's process.title = 'gsd' // Print branded banner on first launch (before ~/.gsd/ exists) diff --git a/src/tests/update-check.test.ts b/src/tests/update-check.test.ts new file mode 100644 index 000000000..1275b1356 --- /dev/null +++ b/src/tests/update-check.test.ts @@ -0,0 +1,327 @@ +import test from 'node:test' +import assert from 'node:assert/strict' +import { mkdtempSync, rmSync, readFileSync, writeFileSync } from 'node:fs' +import { join } from 'node:path' +import { tmpdir } from 'node:os' +import { createServer } from 'node:http' + +import { compareSemver, readUpdateCache, writeUpdateCache, checkForUpdates } from '../update-check.js' + +// --------------------------------------------------------------------------- +// compareSemver +// --------------------------------------------------------------------------- + +test('compareSemver returns 0 for equal versions', () => { + assert.equal(compareSemver('1.0.0', '1.0.0'), 0) + assert.equal(compareSemver('2.8.3', '2.8.3'), 0) +}) + +test('compareSemver returns 1 when first is greater', () => { + assert.equal(compareSemver('2.0.0', '1.0.0'), 1) + assert.equal(compareSemver('1.1.0', '1.0.0'), 1) + assert.equal(compareSemver('1.0.1', '1.0.0'), 1) + assert.equal(compareSemver('2.8.3', '2.7.1'), 1) +}) + +test('compareSemver returns -1 when first is smaller', () => { + assert.equal(compareSemver('1.0.0', '2.0.0'), -1) + assert.equal(compareSemver('1.0.0', '1.1.0'), -1) + assert.equal(compareSemver('1.0.0', '1.0.1'), -1) + assert.equal(compareSemver('2.3.11', '2.8.3'), -1) +}) + +test('compareSemver handles versions with different segment counts', () => { + assert.equal(compareSemver('1.0', '1.0.0'), 0) + assert.equal(compareSemver('1.0.0', '1.0'), 0) + assert.equal(compareSemver('1.0', '1.0.1'), -1) + assert.equal(compareSemver('1.0.1', '1.0'), 1) +}) + +// --------------------------------------------------------------------------- +// readUpdateCache / writeUpdateCache +// --------------------------------------------------------------------------- + +test('readUpdateCache returns null for nonexistent file', () => { + const tmp = mkdtempSync(join(tmpdir(), 'gsd-update-cache-')) + try { + const result = readUpdateCache(join(tmp, 'nonexistent')) + assert.equal(result, null) + } finally { + rmSync(tmp, { recursive: true, force: true }) + } +}) + +test('readUpdateCache returns null for malformed JSON', () => { + const tmp = mkdtempSync(join(tmpdir(), 'gsd-update-cache-')) + try { + const cachePath = join(tmp, '.update-check') + writeFileSync(cachePath, 'not json') + const result = readUpdateCache(cachePath) + assert.equal(result, null) + } finally { + rmSync(tmp, { recursive: true, force: true }) + } +}) + +test('writeUpdateCache + readUpdateCache round-trips correctly', () => { + const tmp = mkdtempSync(join(tmpdir(), 'gsd-update-cache-')) + try { + const cachePath = join(tmp, '.update-check') + const cache = { lastCheck: Date.now(), latestVersion: '3.0.0' } + writeUpdateCache(cache, cachePath) + const result = readUpdateCache(cachePath) + assert.deepEqual(result, cache) + } finally { + rmSync(tmp, { recursive: true, force: true }) + } +}) + +test('writeUpdateCache creates parent directories', () => { + const tmp = mkdtempSync(join(tmpdir(), 'gsd-update-cache-')) + try { + const cachePath = join(tmp, 'nested', 'dir', '.update-check') + writeUpdateCache({ lastCheck: Date.now(), latestVersion: '1.0.0' }, cachePath) + const raw = readFileSync(cachePath, 'utf-8') + assert.ok(raw.includes('1.0.0')) + } finally { + rmSync(tmp, { recursive: true, force: true }) + } +}) + +// --------------------------------------------------------------------------- +// checkForUpdates — integration tests with a local HTTP server +// --------------------------------------------------------------------------- + +function startMockRegistry(responseBody: object, statusCode = 200): Promise<{ url: string; close: () => Promise }> { + return new Promise((resolve) => { + const server = createServer((_req, res) => { + res.writeHead(statusCode, { 'Content-Type': 'application/json' }) + res.end(JSON.stringify(responseBody)) + }) + server.listen(0, '127.0.0.1', () => { + const addr = server.address() as { port: number } + resolve({ + url: `http://127.0.0.1:${addr.port}`, + close: () => new Promise((r) => server.close(() => r())), + }) + }) + }) +} + +test('checkForUpdates calls onUpdate when newer version is available', async () => { + const tmp = mkdtempSync(join(tmpdir(), 'gsd-update-')) + const registry = await startMockRegistry({ version: '99.0.0' }) + try { + let called = false + let reportedCurrent = '' + let reportedLatest = '' + + await checkForUpdates({ + currentVersion: '1.0.0', + cachePath: join(tmp, '.update-check'), + registryUrl: registry.url, + checkIntervalMs: 0, + fetchTimeoutMs: 5000, + onUpdate: (current, latest) => { + called = true + reportedCurrent = current + reportedLatest = latest + }, + }) + + assert.ok(called, 'onUpdate should have been called') + assert.equal(reportedCurrent, '1.0.0') + assert.equal(reportedLatest, '99.0.0') + } finally { + await registry.close() + rmSync(tmp, { recursive: true, force: true }) + } +}) + +test('checkForUpdates does not call onUpdate when already on latest', async () => { + const tmp = mkdtempSync(join(tmpdir(), 'gsd-update-')) + const registry = await startMockRegistry({ version: '1.0.0' }) + try { + let called = false + + await checkForUpdates({ + currentVersion: '1.0.0', + cachePath: join(tmp, '.update-check'), + registryUrl: registry.url, + checkIntervalMs: 0, + fetchTimeoutMs: 5000, + onUpdate: () => { called = true }, + }) + + assert.ok(!called, 'onUpdate should not be called when versions match') + } finally { + await registry.close() + rmSync(tmp, { recursive: true, force: true }) + } +}) + +test('checkForUpdates does not call onUpdate when current is ahead', async () => { + const tmp = mkdtempSync(join(tmpdir(), 'gsd-update-')) + const registry = await startMockRegistry({ version: '1.0.0' }) + try { + let called = false + + await checkForUpdates({ + currentVersion: '2.0.0', + cachePath: join(tmp, '.update-check'), + registryUrl: registry.url, + checkIntervalMs: 0, + fetchTimeoutMs: 5000, + onUpdate: () => { called = true }, + }) + + assert.ok(!called, 'onUpdate should not be called when current is ahead') + } finally { + await registry.close() + rmSync(tmp, { recursive: true, force: true }) + } +}) + +test('checkForUpdates writes cache after successful fetch', async () => { + const tmp = mkdtempSync(join(tmpdir(), 'gsd-update-')) + const cachePath = join(tmp, '.update-check') + const registry = await startMockRegistry({ version: '5.0.0' }) + try { + await checkForUpdates({ + currentVersion: '1.0.0', + cachePath, + registryUrl: registry.url, + checkIntervalMs: 0, + fetchTimeoutMs: 5000, + onUpdate: () => {}, + }) + + const cache = readUpdateCache(cachePath) + assert.ok(cache, 'cache should exist after fetch') + assert.equal(cache!.latestVersion, '5.0.0') + assert.ok(cache!.lastCheck > 0) + } finally { + await registry.close() + rmSync(tmp, { recursive: true, force: true }) + } +}) + +test('checkForUpdates uses cache and skips fetch when checked recently', async () => { + const tmp = mkdtempSync(join(tmpdir(), 'gsd-update-')) + const cachePath = join(tmp, '.update-check') + // Write a fresh cache entry + writeUpdateCache({ lastCheck: Date.now(), latestVersion: '10.0.0' }, cachePath) + + // Start server that would return a different version — should NOT be reached + const registry = await startMockRegistry({ version: '20.0.0' }) + try { + let reportedLatest = '' + + await checkForUpdates({ + currentVersion: '1.0.0', + cachePath, + registryUrl: registry.url, + checkIntervalMs: 60 * 60 * 1000, // 1 hour + fetchTimeoutMs: 5000, + onUpdate: (_current, latest) => { reportedLatest = latest }, + }) + + // Should use cached version (10.0.0), not the server's (20.0.0) + assert.equal(reportedLatest, '10.0.0') + } finally { + await registry.close() + rmSync(tmp, { recursive: true, force: true }) + } +}) + +test('checkForUpdates skips notification when cache is fresh and versions match', async () => { + const tmp = mkdtempSync(join(tmpdir(), 'gsd-update-')) + const cachePath = join(tmp, '.update-check') + writeUpdateCache({ lastCheck: Date.now(), latestVersion: '1.0.0' }, cachePath) + + try { + let called = false + + await checkForUpdates({ + currentVersion: '1.0.0', + cachePath, + checkIntervalMs: 60 * 60 * 1000, + fetchTimeoutMs: 5000, + onUpdate: () => { called = true }, + }) + + assert.ok(!called, 'onUpdate should not be called when cached version matches current') + } finally { + rmSync(tmp, { recursive: true, force: true }) + } +}) + +test('checkForUpdates handles server error gracefully', async () => { + const tmp = mkdtempSync(join(tmpdir(), 'gsd-update-')) + const registry = await startMockRegistry({}, 500) + try { + let called = false + + await checkForUpdates({ + currentVersion: '1.0.0', + cachePath: join(tmp, '.update-check'), + registryUrl: registry.url, + checkIntervalMs: 0, + fetchTimeoutMs: 5000, + onUpdate: () => { called = true }, + }) + + assert.ok(!called, 'onUpdate should not be called on server error') + } finally { + await registry.close() + rmSync(tmp, { recursive: true, force: true }) + } +}) + +test('checkForUpdates handles network timeout gracefully', async () => { + // Start a server that never responds + const server = createServer(() => { /* intentionally never respond */ }) + await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)) + const addr = server.address() as { port: number } + const tmp = mkdtempSync(join(tmpdir(), 'gsd-update-')) + + try { + let called = false + + await checkForUpdates({ + currentVersion: '1.0.0', + cachePath: join(tmp, '.update-check'), + registryUrl: `http://127.0.0.1:${addr.port}`, + checkIntervalMs: 0, + fetchTimeoutMs: 500, // Very short timeout + onUpdate: () => { called = true }, + }) + + assert.ok(!called, 'onUpdate should not be called on timeout') + } finally { + await new Promise((r) => server.close(() => r())) + rmSync(tmp, { recursive: true, force: true }) + } +}) + +test('checkForUpdates handles missing version field in response', async () => { + const tmp = mkdtempSync(join(tmpdir(), 'gsd-update-')) + const registry = await startMockRegistry({ name: 'gsd-pi' }) // no version field + try { + let called = false + + await checkForUpdates({ + currentVersion: '1.0.0', + cachePath: join(tmp, '.update-check'), + registryUrl: registry.url, + checkIntervalMs: 0, + fetchTimeoutMs: 5000, + onUpdate: () => { called = true }, + }) + + assert.ok(!called, 'onUpdate should not be called when response has no version') + } finally { + await registry.close() + rmSync(tmp, { recursive: true, force: true }) + } +}) diff --git a/src/update-check.ts b/src/update-check.ts new file mode 100644 index 000000000..623a36b5a --- /dev/null +++ b/src/update-check.ts @@ -0,0 +1,114 @@ +import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'node:fs' +import { dirname, join } from 'node:path' +import { appRoot } from './app-paths.js' + +const CACHE_FILE = join(appRoot, '.update-check') +const NPM_PACKAGE_NAME = 'gsd-pi' +const CHECK_INTERVAL_MS = 24 * 60 * 60 * 1000 // 24 hours +const FETCH_TIMEOUT_MS = 5000 + +interface UpdateCheckCache { + lastCheck: number + latestVersion: string +} + +/** + * Compares two semver strings. Returns 1 if a > b, -1 if a < b, 0 if equal. + */ +export function compareSemver(a: string, b: string): number { + const pa = a.split('.').map(Number) + const pb = b.split('.').map(Number) + for (let i = 0; i < Math.max(pa.length, pb.length); i++) { + const va = pa[i] || 0 + const vb = pb[i] || 0 + if (va > vb) return 1 + if (va < vb) return -1 + } + return 0 +} + +export function readUpdateCache(cachePath: string = CACHE_FILE): UpdateCheckCache | null { + try { + if (!existsSync(cachePath)) return null + return JSON.parse(readFileSync(cachePath, 'utf-8')) + } catch { + return null + } +} + +export function writeUpdateCache(cache: UpdateCheckCache, cachePath: string = CACHE_FILE): void { + try { + mkdirSync(dirname(cachePath), { recursive: true }) + writeFileSync(cachePath, JSON.stringify(cache)) + } catch { + // Non-fatal — don't block startup if cache write fails + } +} + +function printUpdateBanner(current: string, latest: string): void { + const yellow = '\x1b[33m' + const dim = '\x1b[2m' + const reset = '\x1b[0m' + const bold = '\x1b[1m' + + process.stderr.write( + ` ${yellow}Update available:${reset} ${dim}v${current}${reset} → ${bold}v${latest}${reset}\n` + + ` ${dim}Run${reset} npm update -g gsd-pi ${dim}or${reset} /gsd:update ${dim}to upgrade${reset}\n\n`, + ) +} + +export interface UpdateCheckOptions { + currentVersion?: string + cachePath?: string + registryUrl?: string + checkIntervalMs?: number + fetchTimeoutMs?: number + onUpdate?: (current: string, latest: string) => void +} + +/** + * Non-blocking update check. Queries npm registry at most once per 24h, + * caches the result, and prints a banner if a newer version is available. + */ +export async function checkForUpdates(options: UpdateCheckOptions = {}): Promise { + const currentVersion = options.currentVersion || process.env.GSD_VERSION || '0.0.0' + const cachePath = options.cachePath || CACHE_FILE + const registryUrl = options.registryUrl || `https://registry.npmjs.org/${NPM_PACKAGE_NAME}/latest` + const checkIntervalMs = options.checkIntervalMs ?? CHECK_INTERVAL_MS + const fetchTimeoutMs = options.fetchTimeoutMs ?? FETCH_TIMEOUT_MS + const onUpdate = options.onUpdate || printUpdateBanner + + // Check cache — skip network if checked recently + const cache = readUpdateCache(cachePath) + if (cache && Date.now() - cache.lastCheck < checkIntervalMs) { + if (compareSemver(cache.latestVersion, currentVersion) > 0) { + onUpdate(currentVersion, cache.latestVersion) + } + return + } + + // Fetch latest version from npm registry + const controller = new AbortController() + const timeout = setTimeout(() => controller.abort(), fetchTimeoutMs) + + try { + const res = await fetch(registryUrl, { signal: controller.signal }) + clearTimeout(timeout) + + if (!res.ok) return + + const data = (await res.json()) as { version?: string } + const latestVersion = data.version + if (!latestVersion) return + + writeUpdateCache({ lastCheck: Date.now(), latestVersion }, cachePath) + + if (compareSemver(latestVersion, currentVersion) > 0) { + onUpdate(currentVersion, latestVersion) + } + } catch { + // Network error or timeout — silently ignore, don't block startup + } finally { + clearTimeout(timeout) + } +}