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.
This commit is contained in:
Jeremy McSpadden 2026-03-16 22:09:33 -05:00 committed by GitHub
parent ed341a95b1
commit d64ed32850
2 changed files with 121 additions and 3 deletions

View file

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

View file

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