diff --git a/src/cli.ts b/src/cli.ts index 11bedd424..b6d610878 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -18,7 +18,7 @@ import { loadStoredEnvKeys } from './wizard.js' import { getPiDefaultModelAndProvider, migratePiCredentials } from './pi-migration.js' import { shouldRunOnboarding, runOnboarding } from './onboarding.js' import chalk from 'chalk' -import { checkForUpdates } from './update-check.js' +import { checkForUpdates, checkAndPromptForUpdates } from './update-check.js' import { printHelp, printSubcommandHelp } from './help-text.js' // --------------------------------------------------------------------------- @@ -211,9 +211,17 @@ if (!isPrintMode && shouldRunOnboarding(authStorage, settingsManager.getDefaultP process.stdin.pause() } -// Non-blocking update check — runs at most once per 24h, fire-and-forget +// Update check — interactive prompt when stdin is a TTY, passive banner otherwise if (!isPrintMode) { - checkForUpdates().catch(() => {}) + if (process.stdin.isTTY) { + const updated = await checkAndPromptForUpdates().catch(() => false) + if (updated) { + // User chose to update — exit so they relaunch with the new version + process.exit(0) + } + } else { + checkForUpdates().catch(() => {}) + } } // Warn if terminal is too narrow for readable output diff --git a/src/update-check.ts b/src/update-check.ts index 18dc66cd1..50213cf3f 100644 --- a/src/update-check.ts +++ b/src/update-check.ts @@ -2,6 +2,7 @@ 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' @@ -108,3 +109,112 @@ export async function checkForUpdates(options: UpdateCheckOptions = {}): Promise 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 { + 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((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 +}