feat: add startup update check with 24h cache
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 <noreply@anthropic.com>
This commit is contained in:
parent
1fc3a4ca77
commit
5a2ed4eb05
4 changed files with 448 additions and 1 deletions
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
327
src/tests/update-check.test.ts
Normal file
327
src/tests/update-check.test.ts
Normal file
|
|
@ -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<void> }> {
|
||||
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<void>((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<void>((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<void>((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 })
|
||||
}
|
||||
})
|
||||
114
src/update-check.ts
Normal file
114
src/update-check.ts
Normal file
|
|
@ -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<void> {
|
||||
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)
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue