* 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:
parent
ed341a95b1
commit
d64ed32850
2 changed files with 121 additions and 3 deletions
14
src/cli.ts
14
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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue