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>
114 lines
3.6 KiB
TypeScript
114 lines
3.6 KiB
TypeScript
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)
|
|
}
|
|
}
|