singularity-forge/src/update-check.ts
Jeremy McSpadden d64ed32850 feat: interactive update prompt on startup (#770) (#775)
* feat: interactive update prompt on startup (#770)

When a newer version of gsd-pi is available, show an interactive
prompt at startup with two options:
  [1] Update now  (runs npm install -g gsd-pi@latest)
  [2] Skip

- Adds checkAndPromptForUpdates() to update-check.ts
  - Reuses existing 24h cache so the registry is hit at most once/day
  - Shows a boxed banner with current → latest versions
  - Runs npm install -g gsd-pi@latest if the user picks [1]
  - Exits after a successful update so the user relaunches with the new build
  - Cleans up stdin state (listeners + raw mode) so the TUI starts cleanly
- Updates cli.ts to call checkAndPromptForUpdates() instead of the
  fire-and-forget checkForUpdates() in interactive mode
- Skipped in print/RPC/MCP/headless modes (isPrintMode guard)

* fix: update-check prompt cleanup and robustness (#770)

- Remove duplicate NPM_PACKAGE constant (was shadowing NPM_PACKAGE_NAME)
- Fix hardcoded box width: measure visible text width dynamically so the
  border aligns correctly for any version string length
- Add 30s timeout to rl.question so the prompt auto-skips in non-TTY
  or piped-stdin edge cases that slip past the isPrintMode guard

* fix: address review feedback on update prompt (#770)

Three issues from @glittercowboy's review:

1. Box rendering bug: mid line was built as '║' + content + '║' then
   sliced with .slice(1,-1) which cuts into ANSI escape sequences.
   Fix: build midContent without delimiters and wrap with chalk.yellow('║')
   directly, keeping a separate plain-text midVisible for width measurement.

2. Missing TTY guard: !isPrintMode alone isn't sufficient — a piped
   stdin without --print would sit waiting 30s silently.
   Fix: gate checkAndPromptForUpdates() on process.stdin.isTTY; fall back
   to the passive checkForUpdates() banner for non-TTY interactive mode.

3. Dead import: checkForUpdates was imported but unused after the
   previous refactor. Now used again as the non-TTY fallback — no
   dead code.
2026-03-16 21:09:33 -06:00

220 lines
7.7 KiB
TypeScript

import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'node:fs'
import { dirname, join } from 'node:path'
import chalk from 'chalk'
import { appRoot } from './app-paths.js'
import { execSync } from 'node:child_process'
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 {
process.stderr.write(
` ${chalk.yellow('Update available:')} ${chalk.dim(`v${current}`)}${chalk.bold(`v${latest}`)}\n` +
` ${chalk.dim('Run')} npm update -g gsd-pi ${chalk.dim('or')} /gsd:update ${chalk.dim('to upgrade')}\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)
}
}
const PROMPT_TIMEOUT_MS = 30_000
/**
* Interactive update prompt shown at startup when a newer version is available.
* Fetches the latest version (with cache), then asks the user whether to
* update now or skip. Runs at most once per 24 hours (same cache as checkForUpdates).
* Defaults to skip after 30 seconds of inactivity.
*
* Returns true if an update was performed, false otherwise.
*/
export async function checkAndPromptForUpdates(options: UpdateCheckOptions = {}): Promise<boolean> {
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
// Determine latest version (from cache or network)
let latestVersion: string | null = null
const cache = readUpdateCache(cachePath)
if (cache && Date.now() - cache.lastCheck < checkIntervalMs) {
latestVersion = cache.latestVersion
} else {
const controller = new AbortController()
const timeout = setTimeout(() => controller.abort(), fetchTimeoutMs)
try {
const res = await fetch(registryUrl, { signal: controller.signal })
clearTimeout(timeout)
if (res.ok) {
const data = (await res.json()) as { version?: string }
if (data.version) {
latestVersion = data.version
writeUpdateCache({ lastCheck: Date.now(), latestVersion }, cachePath)
}
}
} catch {
// Network unavailable — silently skip
} finally {
clearTimeout(timeout)
}
}
if (!latestVersion || compareSemver(latestVersion, currentVersion) <= 0) {
return false
}
// Update available — show interactive prompt
// Measure visible (ANSI-free) width to size the box, then render with chalk.
const midContent = ` ${chalk.bold('Update available!')} ${chalk.dim(`v${currentVersion}`)}${chalk.bold.green(`v${latestVersion}`)} `
const midVisible = ` Update available! v${currentVersion} → v${latestVersion} `
const innerWidth = midVisible.length
const top = '╔' + '═'.repeat(innerWidth) + '╗'
const bot = '╚' + '═'.repeat(innerWidth) + '╝'
process.stderr.write('\n')
process.stderr.write(
` ${chalk.yellow(top)}\n` +
` ${chalk.yellow('║')}${midContent}${chalk.yellow('║')}\n` +
` ${chalk.yellow(bot)}\n\n`,
)
// Use readline for a simple two-option prompt that works without @clack/prompts
const readline = await import('node:readline')
const rl = readline.createInterface({ input: process.stdin, output: process.stderr })
const choice = await new Promise<string>((resolve) => {
process.stderr.write(
` ${chalk.bold('[1]')} Update now ${chalk.dim(`npm install -g ${NPM_PACKAGE_NAME}@latest`)}\n` +
` ${chalk.bold('[2]')} Skip\n\n`,
)
// Default to skip if the user doesn't respond within PROMPT_TIMEOUT_MS
const timer = setTimeout(() => {
process.stderr.write('\n')
rl.close()
resolve('2')
}, PROMPT_TIMEOUT_MS)
rl.question(` ${chalk.bold('Choose [1/2]:')} `, (answer) => {
clearTimeout(timer)
resolve(answer.trim())
})
})
rl.close()
// Clean up stdin state so the TUI can start with a clean slate
process.stdin.removeAllListeners('data')
process.stdin.removeAllListeners('keypress')
if (process.stdin.setRawMode) process.stdin.setRawMode(false)
process.stdin.pause()
if (choice === '1') {
process.stderr.write(`\n ${chalk.dim('Running:')} npm install -g ${NPM_PACKAGE_NAME}@latest\n\n`)
try {
execSync(`npm install -g ${NPM_PACKAGE_NAME}@latest`, { stdio: 'inherit' })
process.stderr.write(`\n ${chalk.green.bold(`✓ Updated to v${latestVersion}`)}\n\n`)
return true
} catch {
process.stderr.write(`\n ${chalk.yellow(`Update failed. You can run: npm install -g ${NPM_PACKAGE_NAME}@latest`)}\n\n`)
}
} else {
process.stderr.write(` ${chalk.dim('Skipped. Run')} gsd update ${chalk.dim('anytime to upgrade.')}\n\n`)
}
return false
}