From f28b38213be3d079054634dea4709cc7e938ee67 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Tue, 20 Oct 2026 17:56:59 +0000 Subject: [PATCH] feat(cli): auto-update prompt and e2b update command Co-Authored-By: mish@e2b.dev --- .changeset/cli-auto-updater.md | 6 + packages/cli/README.md | 6 + packages/cli/package.json | 1 - packages/cli/src/commands/index.ts | 2 + packages/cli/src/commands/update.ts | 60 +++++ packages/cli/src/index.ts | 153 +++++++++++-- packages/cli/src/utils/updater.ts | 265 +++++++++++++++++++++++ packages/cli/tests/utils/updater.test.ts | 240 ++++++++++++++++++++ pnpm-lock.yaml | 11 - 9 files changed, 719 insertions(+), 25 deletions(-) create mode 100644 .changeset/cli-auto-updater.md create mode 100644 packages/cli/src/commands/update.ts create mode 100644 packages/cli/src/utils/updater.ts create mode 100644 packages/cli/tests/utils/updater.test.ts diff --git a/.changeset/cli-auto-updater.md b/.changeset/cli-auto-updater.md new file mode 100644 index 0000000000..55faaf9446 --- /dev/null +++ b/.changeset/cli-auto-updater.md @@ -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. diff --git a/packages/cli/README.md b/packages/cli/README.md index 74c654e2a6..bcdc4bfcd6 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -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 diff --git a/packages/cli/package.json b/packages/cli/package.json index ee8c645a7c..0f9383e849 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -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" diff --git a/packages/cli/src/commands/index.ts b/packages/cli/src/commands/index.ts index 0dcba975af..1791908df6 100644 --- a/packages/cli/src/commands/index.ts +++ b/packages/cli/src/commands/index.ts @@ -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 @@ -23,3 +24,4 @@ Visit ${asPrimary( .addCommand(authCommand) .addCommand(templateCommand) .addCommand(sandboxCommand) + .addCommand(updateCommand) diff --git a/packages/cli/src/commands/update.ts b/packages/cli/src/commands/update.ts new file mode 100644 index 0000000000..bd58c72436 --- /dev/null +++ b/packages/cli/src/commands/update.ts @@ -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 = '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 + }) diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts index da0b5975b2..dca2a1c4e0 100644 --- a/packages/cli/src/index.ts +++ b/packages/cli/src/index.ts @@ -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 + skipCommand: boolean + exitCode?: number + warning?: string +} + +function getCurrentInstallMethod(): ReturnType { + 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 || + process.env.NODE_ENV === 'development' + ) +} + +async function startStartupUpdate(): Promise { + 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() diff --git a/packages/cli/src/utils/updater.ts b/packages/cli/src/utils/updater.ts new file mode 100644 index 0000000000..c0df110ea0 --- /dev/null +++ b/packages/cli/src/utils/updater.ts @@ -0,0 +1,265 @@ +import { spawn } from 'child_process' +import * as fs from 'fs' +import * as path from 'path' +import { USER_CONFIG_PATH } from '../user' + +type InstallMethod = 'npm' | 'pnpm' | 'bun' | 'brew' | 'other' + +interface UpdateCommand { + command: string + args: string[] +} + +interface UpdateCache { + lastCheckedAt: string + latestVersion: string + dismissedVersion?: string +} + +export const UPDATE_CHECK_INTERVAL_MS = 20 * 60 * 60 * 1000 + +const BREW_FORMULA_URL = 'https://formulae.brew.sh/api/formula/e2b.json' +const NPM_REGISTRY_URL = 'https://registry.npmjs.org/@e2b/cli/latest' + +export function detectInstallMethod(binPath: string): InstallMethod { + const normalized = binPath.replace(/\\/g, '/') + const lowerPath = normalized.toLowerCase() + + if (normalized.includes('/Cellar/e2b/')) return 'brew' + if (lowerPath.includes('/_npx/')) return 'other' + if (lowerPath.includes('/.bun/install/global/')) return 'bun' + if (lowerPath.includes('/pnpm/global/')) return 'pnpm' + if ( + lowerPath.includes('/lib/node_modules/@e2b/cli/') || + /\/npm\/node_modules\/@e2b\/cli\//i.test(normalized) + ) { + return 'npm' + } + + return 'other' +} + +export function getUpdateCommand( + method: InstallMethod +): UpdateCommand | undefined { + switch (method) { + case 'npm': + return { command: 'npm', args: ['install', '-g', '@e2b/cli@latest'] } + case 'pnpm': + return { command: 'pnpm', args: ['add', '-g', '@e2b/cli@latest'] } + case 'bun': + return { command: 'bun', args: ['add', '-g', '@e2b/cli@latest'] } + case 'brew': + return { command: 'brew', args: ['upgrade', 'e2b'] } + default: + return undefined + } +} + +export function getUpdateCommandDisplay( + method: InstallMethod +): string | undefined { + const updateCommand = getUpdateCommand(method) + if (!updateCommand) return undefined + return [updateCommand.command, ...updateCommand.args].join(' ') +} + +export async function fetchLatestVersion( + method: InstallMethod, + fetchFn: typeof fetch = fetch +): Promise { + const url = method === 'brew' ? BREW_FORMULA_URL : NPM_REGISTRY_URL + const response = await fetchFn(url, { signal: AbortSignal.timeout(5000) }) + if (!response.ok) { + throw new Error(`Update check failed with status ${response.status}`) + } + + const data = (await response.json()) as { + version?: unknown + versions?: { stable?: unknown } + } + const version = method === 'brew' ? data.versions?.stable : data.version + if (typeof version !== 'string') { + throw new Error('Update check returned an invalid version') + } + + return version +} + +function parseVersion(version: string): [bigint, bigint, bigint] | undefined { + const match = + /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$/.exec( + version + ) + if (!match) return undefined + return [BigInt(match[1]), BigInt(match[2]), BigInt(match[3])] +} + +export function isNewer(latest: string, current: string): boolean { + const latestVersion = parseVersion(latest) + const currentVersion = parseVersion(current) + if (!latestVersion || !currentVersion) return false + + for (let index = 0; index < latestVersion.length; index += 1) { + if (latestVersion[index] > currentVersion[index]) return true + if (latestVersion[index] < currentVersion[index]) return false + } + + return false +} + +export function getUpdateCachePath(userConfigPath = USER_CONFIG_PATH): string { + return path.join(path.dirname(userConfigPath), 'update-check.json') +} + +function isUpdateCache(value: unknown): value is UpdateCache { + if (!value || typeof value !== 'object' || Array.isArray(value)) return false + const cache = value as Record + return ( + typeof cache.lastCheckedAt === 'string' && + Number.isFinite(Date.parse(cache.lastCheckedAt)) && + typeof cache.latestVersion === 'string' && + (cache.dismissedVersion === undefined || + typeof cache.dismissedVersion === 'string') + ) +} + +function debugCacheError(error: unknown): void { + if (process.env.DEBUG) { + console.error('Update cache error:', error) + } +} + +export function readUpdateCache( + cachePath = getUpdateCachePath() +): UpdateCache | undefined { + try { + const cache = JSON.parse(fs.readFileSync(cachePath, 'utf8')) as unknown + return isUpdateCache(cache) ? cache : undefined + } catch (error) { + debugCacheError(error) + return undefined + } +} + +export function isUpdateCacheStale( + cache: UpdateCache | undefined, + now = Date.now() +): boolean { + if (!cache) return true + return now - Date.parse(cache.lastCheckedAt) >= UPDATE_CHECK_INTERVAL_MS +} + +export function writeUpdateCache( + cache: UpdateCache, + cachePath = getUpdateCachePath() +): void { + try { + const existing = readUpdateCache(cachePath) + const preservedCache = { + ...cache, + ...(cache.dismissedVersion === undefined && + existing?.dismissedVersion !== undefined + ? { dismissedVersion: existing.dismissedVersion } + : {}), + } + const dir = path.dirname(cachePath) + fs.mkdirSync(dir, { recursive: true, mode: 0o700 }) + fs.chmodSync(dir, 0o700) + fs.writeFileSync(cachePath, JSON.stringify(preservedCache, null, 2), { + mode: 0o600, + }) + fs.chmodSync(cachePath, 0o600) + } catch (error) { + debugCacheError(error) + } +} + +export async function refreshUpdateCache( + method: InstallMethod, + options: { + cachePath?: string + fetchFn?: typeof fetch + now?: () => number + } = {} +): Promise { + const cachePath = options.cachePath ?? getUpdateCachePath() + const now = options.now ?? Date.now + + try { + const latestVersion = await fetchLatestVersion(method, options.fetchFn) + const cache: UpdateCache = { + lastCheckedAt: new Date(now()).toISOString(), + latestVersion, + } + writeUpdateCache(cache, cachePath) + return readUpdateCache(cachePath) ?? cache + } catch (error) { + debugCacheError(error) + return undefined + } +} + +interface ShouldPromptOptions { + currentVersion: string + latestVersion: string + method: InstallMethod + dismissedVersion?: string + stdinIsTTY?: boolean + stdoutIsTTY?: boolean + env?: NodeJS.ProcessEnv + argv?: string[] +} + +export function shouldPrompt({ + currentVersion, + latestVersion, + method, + dismissedVersion, + stdinIsTTY = process.stdin.isTTY, + stdoutIsTTY = process.stdout.isTTY, + env = process.env, + argv = process.argv.slice(2), +}: ShouldPromptOptions): boolean { + if ( + !isNewer(latestVersion, currentVersion) || + latestVersion === dismissedVersion || + method === 'other' || + !stdinIsTTY || + !stdoutIsTTY || + env.CI !== undefined || + env.NO_UPDATE_NOTIFIER !== undefined || + env.NODE_ENV === 'development' + ) { + return false + } + + if ( + argv.some((argument) => + ['-V', '--version', '-h', '--help'].includes(argument) + ) || + ['help', 'update'].includes(argv[0]) + ) { + return false + } + + return true +} + +export function runUpdateCommand(method: InstallMethod): Promise { + const updateCommand = getUpdateCommand(method) + if (!updateCommand) return Promise.resolve(1) + + return new Promise((resolve) => { + try { + const child = spawn(updateCommand.command, updateCommand.args, { + stdio: 'inherit', + shell: process.platform === 'win32', + }) + child.once('error', () => resolve(1)) + child.once('close', (code) => resolve(code ?? 1)) + } catch { + resolve(1) + } + }) +} diff --git a/packages/cli/tests/utils/updater.test.ts b/packages/cli/tests/utils/updater.test.ts new file mode 100644 index 0000000000..dfef90b60c --- /dev/null +++ b/packages/cli/tests/utils/updater.test.ts @@ -0,0 +1,240 @@ +import * as fs from 'fs' +import * as os from 'os' +import * as path from 'path' +import { afterEach, describe, expect, test, vi } from 'vitest' +import { + detectInstallMethod, + fetchLatestVersion, + getUpdateCommand, + getUpdateCommandDisplay, + isNewer, + isUpdateCacheStale, + readUpdateCache, + refreshUpdateCache, + shouldPrompt, + UPDATE_CHECK_INTERVAL_MS, + writeUpdateCache, +} from '../../src/utils/updater' + +const installMethodCases: Array<[string, string]> = [ + [ + '/opt/homebrew/Cellar/e2b/2.22.0/libexec/lib/node_modules/@e2b/cli/dist/index.js', + 'brew', + ], + [ + '/home/linuxbrew/.linuxbrew/Cellar/e2b/2.22.0/libexec/lib/node_modules/@e2b/cli/dist/index.js', + 'brew', + ], + ['/usr/local/lib/node_modules/@e2b/cli/dist/index.js', 'npm'], + [ + '/home/me/.nvm/versions/node/v24.1.0/lib/node_modules/@e2b/cli/dist/index.js', + 'npm', + ], + [ + 'C:\\Users\\me\\AppData\\Roaming\\npm\\node_modules\\@e2b\\cli\\dist\\index.js', + 'npm', + ], + [ + '/home/me/.local/share/pnpm/global/5/.pnpm/@e2b+cli@2.22.2/node_modules/@e2b/cli/dist/index.js', + 'pnpm', + ], + ['/home/me/.bun/install/global/node_modules/@e2b/cli/dist/index.js', 'bun'], + ['/home/me/.npm/_npx/abc123/node_modules/@e2b/cli/dist/index.js', 'other'], + ['/home/me/project/node_modules/@e2b/cli/dist/index.js', 'other'], +] + +const versionCases: Array<[string, string, boolean]> = [ + ['2.22.2', '2.22.0', true], + ['2.10.0', '2.9.9', true], + ['2.22.0', '2.22.0', false], + ['2.21.9', '2.22.0', false], + ['2.23.0-beta.1', '2.22.0', true], + ['garbage', '2.22.0', false], +] + +describe('detectInstallMethod', () => { + test.each(installMethodCases)( + 'detects %s as %s', + (binPath: string, expected: string) => { + expect(detectInstallMethod(binPath)).toBe(expected) + } + ) +}) + +describe('getUpdateCommand', () => { + test('returns updater commands and display strings', () => { + expect(getUpdateCommand('npm')).toEqual({ + command: 'npm', + args: ['install', '-g', '@e2b/cli@latest'], + }) + expect(getUpdateCommandDisplay('npm')).toBe( + 'npm install -g @e2b/cli@latest' + ) + expect(getUpdateCommand('pnpm')).toEqual({ + command: 'pnpm', + args: ['add', '-g', '@e2b/cli@latest'], + }) + expect(getUpdateCommandDisplay('pnpm')).toBe('pnpm add -g @e2b/cli@latest') + expect(getUpdateCommand('bun')).toEqual({ + command: 'bun', + args: ['add', '-g', '@e2b/cli@latest'], + }) + expect(getUpdateCommandDisplay('bun')).toBe('bun add -g @e2b/cli@latest') + expect(getUpdateCommand('brew')).toEqual({ + command: 'brew', + args: ['upgrade', 'e2b'], + }) + expect(getUpdateCommandDisplay('brew')).toBe('brew upgrade e2b') + expect(getUpdateCommand('other')).toBeUndefined() + expect(getUpdateCommandDisplay('other')).toBeUndefined() + }) +}) + +describe('isNewer', () => { + test.each(versionCases)( + '%s compared to %s is newer: %s', + (latest: string, current: string, expected: boolean) => { + expect(isNewer(latest, current)).toBe(expected) + } + ) +}) + +describe('fetchLatestVersion', () => { + test('fetches the stable Homebrew formula version', async () => { + const fetchFn = vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ versions: { stable: '2.22.0' } }), + } as Response) + + await expect(fetchLatestVersion('brew', fetchFn)).resolves.toBe('2.22.0') + expect(fetchFn).toHaveBeenCalledWith( + 'https://formulae.brew.sh/api/formula/e2b.json', + expect.objectContaining({ signal: expect.any(AbortSignal) }) + ) + }) + + test('fetches the npm registry version', async () => { + const fetchFn = vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ version: '2.22.2' }), + } as Response) + + await expect(fetchLatestVersion('npm', fetchFn)).resolves.toBe('2.22.2') + expect(fetchFn).toHaveBeenCalledWith( + 'https://registry.npmjs.org/@e2b/cli/latest', + expect.objectContaining({ signal: expect.any(AbortSignal) }) + ) + }) + + test('rejects non-2xx responses', async () => { + const fetchFn = vi.fn().mockResolvedValue({ + ok: false, + status: 503, + } as Response) + + await expect(fetchLatestVersion('npm', fetchFn)).rejects.toThrow( + 'Update check failed with status 503' + ) + }) +}) + +describe('update cache', () => { + let tempDir: string | undefined + + afterEach(() => { + if (tempDir) fs.rmSync(tempDir, { recursive: true, force: true }) + tempDir = undefined + }) + + test('determines freshness using the configured interval', () => { + const now = Date.parse('2026-01-01T20:00:00.000Z') + const fresh = { + lastCheckedAt: new Date(now - UPDATE_CHECK_INTERVAL_MS + 1).toISOString(), + latestVersion: '2.22.2', + } + const stale = { + lastCheckedAt: new Date(now - UPDATE_CHECK_INTERVAL_MS).toISOString(), + latestVersion: '2.22.2', + } + + expect(isUpdateCacheStale(fresh, now)).toBe(false) + expect(isUpdateCacheStale(stale, now)).toBe(true) + expect(isUpdateCacheStale(undefined, now)).toBe(true) + }) + + test('treats a corrupt cache file as stale', () => { + tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'e2b-updater-test-')) + const cachePath = path.join(tempDir, 'update-check.json') + fs.writeFileSync(cachePath, '{') + + expect(readUpdateCache(cachePath)).toBeUndefined() + expect(isUpdateCacheStale(readUpdateCache(cachePath))).toBe(true) + }) + + test('refreshes the latest version without losing a dismissed version', async () => { + tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'e2b-updater-test-')) + const cachePath = path.join(tempDir, 'update-check.json') + writeUpdateCache( + { + lastCheckedAt: '2025-01-01T00:00:00.000Z', + latestVersion: '2.22.0', + dismissedVersion: '2.22.0', + }, + cachePath + ) + const fetchFn = vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ version: '2.22.2' }), + } as Response) + const now = Date.parse('2026-01-01T20:00:00.000Z') + + await expect( + refreshUpdateCache('npm', { cachePath, fetchFn, now: () => now }) + ).resolves.toEqual({ + lastCheckedAt: new Date(now).toISOString(), + latestVersion: '2.22.2', + dismissedVersion: '2.22.0', + }) + }) +}) + +describe('shouldPrompt', () => { + type PromptOverrides = Partial[0]> + + const defaults = { + currentVersion: '2.22.0', + latestVersion: '2.22.2', + method: 'npm' as const, + stdinIsTTY: true, + stdoutIsTTY: true, + env: {}, + argv: ['auth', 'info'], + } + + const promptCases: Array<[string, PromptOverrides]> = [ + ['dismissed version', { dismissedVersion: '2.22.2' }], + ['unknown install method', { method: 'other' }], + ['non-interactive stdin', { stdinIsTTY: false }], + ['non-interactive stdout', { stdoutIsTTY: false }], + ['CI', { env: { CI: '1' } }], + ['NO_UPDATE_NOTIFIER', { env: { NO_UPDATE_NOTIFIER: '1' } }], + ['development environment', { env: { NODE_ENV: 'development' } }], + ['short version option', { argv: ['-V'] }], + ['version option', { argv: ['--version'] }], + ['help option', { argv: ['-h'] }], + ['long help option', { argv: ['--help'] }], + ['help command', { argv: ['help'] }], + ['update command', { argv: ['update'] }], + ] + + test('allows the interactive update prompt', () => { + expect(shouldPrompt(defaults)).toBe(true) + }) + + test.each(promptCases)( + 'does not prompt for %s', + (_condition: string, options: PromptOverrides) => { + expect(shouldPrompt({ ...defaults, ...options })).toBe(false) + } + ) +}) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 13882d1b67..96bd6b3c3d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -130,9 +130,6 @@ importers: inquirer: specifier: ^12.11.1 version: 12.11.1(@types/node@22.20.5) - simple-update-notifier: - specifier: ^2.0.0 - version: 2.0.0 simple-wcswidth: specifier: ^1.1.2 version: 1.1.2 @@ -3743,10 +3740,6 @@ packages: resolution: {integrity: sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==} engines: {node: '>=14'} - simple-update-notifier@2.0.0: - resolution: {integrity: sha512-a2B9Y0KlNXl9u/vsW6sTIu9vGEpfKu2wRV6l1H3XEas/0gUIzGzBoP/IouTcUQbm9JWZLH3COxyn03TYlFax6w==} - engines: {node: '>=10'} - simple-wcswidth@1.1.2: resolution: {integrity: sha512-j7piyCjAeTDSjzTSQ7DokZtMNwNlEAyxqSZeCS+CXH7fJ4jx3FuJ/mTW3mE+6JLs4VJBbcll0Kjn+KXI5t21Iw==} @@ -7412,10 +7405,6 @@ snapshots: signal-exit@4.1.0: {} - simple-update-notifier@2.0.0: - dependencies: - semver: 7.8.5 - simple-wcswidth@1.1.2: {} sirv@3.0.2: