Merge pull request #209 from FacuVCanale/feat/auto-update-check

feat: add startup update check with 24h cache
This commit is contained in:
TÂCHES 2026-03-13 11:32:42 -06:00 committed by GitHub
commit 8ecd8f6360
4 changed files with 448 additions and 1 deletions

View file

@ -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)

View file

@ -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)

View 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
View 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)
}
}