Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .changeset/cli-auto-updater.md
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.
6 changes: 6 additions & 0 deletions packages/cli/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,12 @@ brew install e2b
npm install -g @e2b/cli
```

### Updating the CLI

Run `e2b update` to update the CLI. When an update is available, the CLI also
offers an interactive prompt before running your command. Set
`NO_UPDATE_NOTIFIER=1` to opt out of update checks.

### 2. Authenticate

```bash
Expand Down
1 change: 0 additions & 1 deletion packages/cli/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -83,7 +83,6 @@
"e2b": "workspace:^",
"handlebars": "^4.7.10",
"inquirer": "^12.11.1",
"simple-update-notifier": "^2.0.0",
"simple-wcswidth": "^1.1.2",
"statuses": "^2.0.2",
"yup": "^1.7.1"
Expand Down
2 changes: 2 additions & 0 deletions packages/cli/src/commands/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { asPrimary } from 'src/utils/format'
import { templateCommand } from './template'
import { sandboxCommand } from './sandbox'
import { authCommand } from './auth'
import { updateCommand } from './update'

export const program = new commander.Command()
// Fixed so help reads `e2b` however the entrypoint is invoked (npm bin
Expand All @@ -23,3 +24,4 @@ Visit ${asPrimary(
.addCommand(authCommand)
.addCommand(templateCommand)
.addCommand(sandboxCommand)
.addCommand(updateCommand)
60 changes: 60 additions & 0 deletions packages/cli/src/commands/update.ts
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)

Copy link
Copy Markdown
Contributor Author

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:

Suggested change
console.error('Failed to fetch the latest E2B CLI version:', error)
console.error(
`Failed to fetch the latest E2B CLI version: ${error instanceof Error ? error.message : String(error)}. Check your network connection, or update manually with \`${getUpdateCommandDisplay(method) ?? 'npm install -g @e2b/cli@latest'}\`.`
)

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
})
153 changes: 140 additions & 13 deletions packages/cli/src/index.ts
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,
Expand All @@ -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

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

T-50 — an env var set to the empty string means unset. !== undefined treats an exported-but-empty NO_UPDATE_NOTIFIER= (or CI=) as set, so a blank value silently disables the updater. The code this replaces used truthiness (process.env.NO_UPDATE_NOTIFIER ? …), so this is also a behavior change for anyone with an empty var in their shell profile.

Suggested change
process.env.NO_UPDATE_NOTIFIER !== undefined ||
process.env.CI !== undefined ||
!!process.env.NO_UPDATE_NOTIFIER ||
!!process.env.CI ||

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()
Loading
Loading