Repository navigation
feat(cli): Codex-style auto-update prompt and e2b update command
#2007
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,6 @@ | ||
| --- | ||
| '@e2b/cli': minor | ||
| --- | ||
|
|
||
| - Add interactive startup update prompts and an `e2b update` command. | ||
| - Support updating CLI installations from npm, pnpm, bun, and Homebrew. |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,60 @@ | ||
| import * as commander from 'commander' | ||
| import * as fs from 'fs' | ||
| import * as packageJSON from '../../package.json' | ||
| import { | ||
| detectInstallMethod, | ||
| fetchLatestVersion, | ||
| getUpdateCommandDisplay, | ||
| isNewer, | ||
| runUpdateCommand, | ||
| writeUpdateCache, | ||
| } from '../utils/updater' | ||
|
|
||
| export const updateCommand = new commander.Command('update') | ||
| .description('update E2B CLI to the latest version') | ||
| .action(async () => { | ||
| let method: ReturnType<typeof detectInstallMethod> = 'other' | ||
| try { | ||
| method = detectInstallMethod(fs.realpathSync(process.argv[1])) | ||
| } catch { | ||
| // Leave the install method as unknown if the executable path is unavailable. | ||
| } | ||
|
|
||
| let latestVersion: string | ||
| try { | ||
| latestVersion = await fetchLatestVersion(method) | ||
| } catch (error) { | ||
| console.error('Failed to fetch the latest E2B CLI version:', error) | ||
| process.exitCode = 1 | ||
| return | ||
| } | ||
|
|
||
| writeUpdateCache({ | ||
| lastCheckedAt: new Date().toISOString(), | ||
| latestVersion, | ||
| }) | ||
|
|
||
| const command = getUpdateCommandDisplay(method) | ||
| if (!command) { | ||
| console.error( | ||
| "Couldn't detect how E2B CLI was installed. For example, run `npm install -g @e2b/cli@latest`." | ||
| ) | ||
| process.exitCode = 1 | ||
| return | ||
| } | ||
|
|
||
| if (!isNewer(latestVersion, packageJSON.version)) { | ||
| console.log(`E2B CLI is up to date (${packageJSON.version})`) | ||
| return | ||
| } | ||
|
|
||
| const code = await runUpdateCommand(method) | ||
| if (code === 0) { | ||
| console.log( | ||
| `Updated E2B CLI to ${latestVersion}. Run your command again.` | ||
| ) | ||
| } else { | ||
| console.error(`Update failed. Run \`${command}\` to update manually.`) | ||
| } | ||
| process.exitCode = code ?? 1 | ||
| }) | ||
| Original file line number | Diff line number | Diff line change | ||||||||
|---|---|---|---|---|---|---|---|---|---|---|
| @@ -1,25 +1,30 @@ | ||||||||||
| #!/usr/bin/env -S node --enable-source-maps | ||||||||||
|
|
||||||||||
| import simpleUpdateNotifier from 'simple-update-notifier' | ||||||||||
| import { select } from '@inquirer/prompts' | ||||||||||
| import * as commander from 'commander' | ||||||||||
| import * as fs from 'fs' | ||||||||||
| import * as packageJSON from '../package.json' | ||||||||||
| import { program } from './commands' | ||||||||||
| import { commands2md } from './utils/commands2md' | ||||||||||
| import { | ||||||||||
| detectInstallMethod, | ||||||||||
| getUpdateCachePath, | ||||||||||
| getUpdateCommandDisplay, | ||||||||||
| isNewer, | ||||||||||
| isUpdateCacheStale, | ||||||||||
| readUpdateCache, | ||||||||||
| refreshUpdateCache, | ||||||||||
| runUpdateCommand, | ||||||||||
| shouldPrompt, | ||||||||||
| writeUpdateCache, | ||||||||||
| } from './utils/updater' | ||||||||||
|
|
||||||||||
| export const pkg = packageJSON | ||||||||||
|
|
||||||||||
| // NO_UPDATE_NOTIFIER is the opt-out shared with `update-notifier`; distro | ||||||||||
| // packagers set it because "install the new version" doesn't apply there. | ||||||||||
| const updateCheck = process.env.NO_UPDATE_NOTIFIER | ||||||||||
| ? Promise.resolve() | ||||||||||
| : simpleUpdateNotifier({ | ||||||||||
| pkg, | ||||||||||
| updateCheckInterval: 1000 * 60 * 60 * 8, // 8 hours | ||||||||||
| }).catch((e) => { | ||||||||||
| if (process.env.DEBUG) { | ||||||||||
| console.error('Update check failed:', e) | ||||||||||
| } | ||||||||||
| }) | ||||||||||
| const UPDATE_CHANGELOG_URL = | ||||||||||
| 'https://github.com/e2b-dev/E2B/blob/main/packages/cli/CHANGELOG.md' | ||||||||||
|
|
||||||||||
| const prog = program.version( | ||||||||||
| packageJSON.version, | ||||||||||
|
|
@@ -36,9 +41,131 @@ if (process.env.NODE_ENV === 'development') { | |||||||||
| }) | ||||||||||
| } | ||||||||||
|
|
||||||||||
| interface StartupUpdateResult { | ||||||||||
| updateCheck: Promise<unknown> | ||||||||||
| skipCommand: boolean | ||||||||||
| exitCode?: number | ||||||||||
| warning?: string | ||||||||||
| } | ||||||||||
|
|
||||||||||
| function getCurrentInstallMethod(): ReturnType<typeof detectInstallMethod> { | ||||||||||
| try { | ||||||||||
| return detectInstallMethod(fs.realpathSync(process.argv[1])) | ||||||||||
| } catch { | ||||||||||
| return 'other' | ||||||||||
| } | ||||||||||
| } | ||||||||||
|
|
||||||||||
| function isUpdaterDisabled(): boolean { | ||||||||||
| return ( | ||||||||||
| process.env.NO_UPDATE_NOTIFIER !== undefined || | ||||||||||
| process.env.CI !== undefined || | ||||||||||
|
Comment on lines
+61
to
+62
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. T-50 — an env var set to the empty string means unset.
Suggested change
|
||||||||||
| process.env.NODE_ENV === 'development' | ||||||||||
| ) | ||||||||||
| } | ||||||||||
|
|
||||||||||
| async function startStartupUpdate(): Promise<StartupUpdateResult> { | ||||||||||
| if (isUpdaterDisabled()) { | ||||||||||
| return { updateCheck: Promise.resolve(), skipCommand: false } | ||||||||||
| } | ||||||||||
|
|
||||||||||
| const method = getCurrentInstallMethod() | ||||||||||
| const cachePath = getUpdateCachePath() | ||||||||||
| const cachedUpdate = readUpdateCache(cachePath) | ||||||||||
| const updateCheck = isUpdateCacheStale(cachedUpdate) | ||||||||||
| ? refreshUpdateCache(method, { cachePath }) | ||||||||||
| : Promise.resolve(undefined) | ||||||||||
| const latestVersion = cachedUpdate?.latestVersion | ||||||||||
|
|
||||||||||
| let prompted = false | ||||||||||
| let skipCommand = false | ||||||||||
| let exitCode: number | undefined | ||||||||||
|
|
||||||||||
| if ( | ||||||||||
| latestVersion && | ||||||||||
| shouldPrompt({ | ||||||||||
| currentVersion: packageJSON.version, | ||||||||||
| latestVersion, | ||||||||||
| method, | ||||||||||
| dismissedVersion: cachedUpdate.dismissedVersion, | ||||||||||
| }) | ||||||||||
| ) { | ||||||||||
| prompted = true | ||||||||||
| console.log(UPDATE_CHANGELOG_URL) | ||||||||||
|
|
||||||||||
| let answer: 'update' | 'skip' | 'dismiss' = 'skip' | ||||||||||
| try { | ||||||||||
| answer = await select({ | ||||||||||
| message: `Update available! ${packageJSON.version} -> ${latestVersion}`, | ||||||||||
| choices: [ | ||||||||||
| { | ||||||||||
| name: `Update now (runs \`${getUpdateCommandDisplay(method)}\`)`, | ||||||||||
| value: 'update', | ||||||||||
| }, | ||||||||||
| { name: 'Skip', value: 'skip' }, | ||||||||||
| { name: 'Skip until next version', value: 'dismiss' }, | ||||||||||
| ], | ||||||||||
| }) | ||||||||||
| } catch (error) { | ||||||||||
| if (!(error instanceof Error && error.name === 'ExitPromptError')) { | ||||||||||
| if (process.env.DEBUG) { | ||||||||||
| console.error('Update prompt failed:', error) | ||||||||||
| } | ||||||||||
| } | ||||||||||
| } | ||||||||||
|
|
||||||||||
| if (answer === 'update') { | ||||||||||
| skipCommand = true | ||||||||||
| const code = await runUpdateCommand(method) | ||||||||||
| if (code === 0) { | ||||||||||
| console.log( | ||||||||||
| `Updated E2B CLI to ${latestVersion}. Run your command again.` | ||||||||||
| ) | ||||||||||
| } else { | ||||||||||
| console.error( | ||||||||||
| `Update failed. Run \`${getUpdateCommandDisplay(method)}\` to update manually.` | ||||||||||
| ) | ||||||||||
| } | ||||||||||
| exitCode = code ?? 1 | ||||||||||
| } else if (answer === 'dismiss') { | ||||||||||
| writeUpdateCache( | ||||||||||
| { ...cachedUpdate, dismissedVersion: latestVersion }, | ||||||||||
| cachePath | ||||||||||
| ) | ||||||||||
| } | ||||||||||
| } | ||||||||||
|
|
||||||||||
| let warning: string | undefined | ||||||||||
| if ( | ||||||||||
| !prompted && | ||||||||||
| latestVersion && | ||||||||||
| isNewer(latestVersion, packageJSON.version) && | ||||||||||
| latestVersion !== cachedUpdate?.dismissedVersion && | ||||||||||
| process.stderr.isTTY | ||||||||||
| ) { | ||||||||||
| const command = getUpdateCommandDisplay(method) | ||||||||||
| warning = command | ||||||||||
| ? `Update available: ${packageJSON.version} -> ${latestVersion}. Run \`${command}\` to update.` | ||||||||||
| : `Update available: ${packageJSON.version} -> ${latestVersion}. Update @e2b/cli with your package manager.` | ||||||||||
| } | ||||||||||
|
|
||||||||||
| return { updateCheck, skipCommand, exitCode, warning } | ||||||||||
| } | ||||||||||
|
|
||||||||||
| async function main() { | ||||||||||
| await prog.parseAsync() | ||||||||||
| await updateCheck | ||||||||||
| const startupUpdate = await startStartupUpdate() | ||||||||||
| if (startupUpdate.warning) { | ||||||||||
| process.once('exit', () => { | ||||||||||
| process.stderr.write(`${startupUpdate.warning}\n`) | ||||||||||
| }) | ||||||||||
| } | ||||||||||
| if (!startupUpdate.skipCommand) { | ||||||||||
| await prog.parseAsync() | ||||||||||
| } | ||||||||||
| await startupUpdate.updateCheck | ||||||||||
| if (startupUpdate.exitCode !== undefined) { | ||||||||||
| process.exitCode = startupUpdate.exitCode | ||||||||||
| } | ||||||||||
| } | ||||||||||
|
|
||||||||||
| main() | ||||||||||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
T-62 — error messages should be actionable and say what to do, not just what failed. This prints the raw error (including its stack) and leaves the user stuck. The other failure paths in this command already name the manual fallback; do the same here: