From 1f278bde288a7a44c68301665697923a1a8c4be2 Mon Sep 17 00:00:00 2001 From: Mish Ushakov <10400064+mishushakov@users.noreply.github.com> Date: Fri, 6 Nov 2026 18:51:17 +0100 Subject: [PATCH 1/6] feat(cli): add workspace and project commands, --project everywhere - `e2b workspace list` lists the workspaces from workspace-api /v1/me. - `e2b project list [--workspace]` lists projects (current one marked with *, blocked and provisioning ones flagged), `project use ` sets the default project (ID, prj_ ID, slug or name; inactive projects are refused, blocked ones warned about), `project info [--project]` shows the current project and what selected it. - Every sandbox and template command that calls the API takes `--project`. A root preAction hook hands it to ensureAuth(), so helpers pick it up without threading it through. `template list` keeps `-t`; `--team` still works there with its deprecation warning. - With E2B_API_KEY set, `--project` is ignored with a warning: the key determines the project. - workspace/project commands need a login even with E2B_API_KEY, since workspace-api only accepts the access token. Co-Authored-By: Claude Opus 5.5 (1M context) --- .changeset/cli-project-commands.md | 15 ++ packages/cli/src/api.ts | 85 +++++-- packages/cli/src/commands/auth/configure.ts | 2 +- packages/cli/src/commands/auth/login.ts | 2 +- packages/cli/src/commands/index.ts | 39 ++++ packages/cli/src/commands/project/index.ts | 210 ++++++++++++++++++ packages/cli/src/commands/template/list.ts | 5 +- packages/cli/src/commands/workspace/index.ts | 66 ++++++ packages/cli/src/options.ts | 15 +- packages/cli/src/workspaces.ts | 2 +- packages/cli/tests/auth.test.ts | 23 +- packages/cli/tests/commands/project.test.ts | 219 +++++++++++++++++++ 12 files changed, 650 insertions(+), 33 deletions(-) create mode 100644 .changeset/cli-project-commands.md create mode 100644 packages/cli/src/commands/project/index.ts create mode 100644 packages/cli/src/commands/workspace/index.ts create mode 100644 packages/cli/tests/commands/project.test.ts diff --git a/.changeset/cli-project-commands.md b/.changeset/cli-project-commands.md new file mode 100644 index 0000000000..25952ba397 --- /dev/null +++ b/.changeset/cli-project-commands.md @@ -0,0 +1,15 @@ +--- +'@e2b/cli': minor +--- + +Add commands for workspaces and projects, and a `--project` option on every command that calls the E2B API: + +```sh +e2b workspace list # workspaces you can access +e2b project list [--workspace ] # projects, the current one marked with * +e2b project use # set the default project +e2b project info [--project ] # the current project, or another one +e2b sandbox list --project # run any command in another project +``` + +A project can be given by its ID, `prj_` ID, slug or name. The project a command runs in is `--project`, then `E2B_PROJECT_ID`, then the one chosen with `e2b project use` (or `e2b auth configure`, which picks it interactively). With `E2B_API_KEY` set, the API key determines the project and `--project` is ignored with a warning. `template list` keeps its `-t` short flag. diff --git a/packages/cli/src/api.ts b/packages/cli/src/api.ts index 4c0eb04c34..deabd0ebc3 100644 --- a/packages/cli/src/api.ts +++ b/packages/cli/src/api.ts @@ -42,6 +42,34 @@ export type Auth = | Required> let me: Promise | undefined +let projectOverride: string | undefined +let apiKeyProjectWarned = false + +/** + * The `--project` value of the running command. Set once, before the action + * runs, by the preAction hook in src/commands/index.ts. + */ +export function setProjectOverride(project: string | undefined) { + projectOverride = project +} + +/** The project reference to use instead of the selected one, if any. */ +function requestedProject(): string | undefined { + return projectOverride || projectId +} + +/** + * The project commands run in and where that choice comes from, or + * `undefined` when signed out without `--project` or `E2B_PROJECT_ID`. + */ +export function currentProject(): + | { ref: string; source: '--project' | 'E2B_PROJECT_ID' | 'selected' } + | undefined { + if (projectOverride) return { ref: projectOverride, source: '--project' } + if (projectId) return { ref: projectId, source: 'E2B_PROJECT_ID' } + const config = getUserConfig() + return config ? { ref: config.projectId, source: 'selected' } : undefined +} /** The logged-in user's workspaces and projects, fetched once per process. */ export function getMe(): Promise { @@ -59,14 +87,30 @@ export function getMe(): Promise { } /** - * The project requests go to, and its control plane: `cliProjectId`, then - * `E2B_PROJECT_ID`, then the project selected with `e2b auth configure`. + * {@link getMe} for commands about workspaces and projects. workspace-api + * only accepts the login's access token, so these need a login even when + * `E2B_API_KEY` is set. + */ +export async function requireMe(): Promise { + if (!getUserConfig()) { + console.error( + `You must be logged in to see your workspaces and projects. Run ${asBold( + 'e2b auth login' + )}.` + ) + process.exit(1) + } + return getMe() +} + +/** + * The project requests go to, and its control plane: `--project`, then + * `E2B_PROJECT_ID`, then the project selected with `e2b project use`. */ async function resolveProject( - config: UserConfig, - cliProjectId?: string + config: UserConfig ): Promise<{ id: string; domain: string }> { - const ref = cliProjectId || projectId + const ref = requestedProject() if (ref && ref !== config.projectId) { const { project } = findProject(await getMe(), ref) return { id: project.id, domain: project.control_plane_domain } @@ -97,9 +141,19 @@ async function resolveProject( * request (not once per command) so long-running commands keep working after * the hour-long access token expires. */ -export async function ensureAuth(cliProjectId?: string): Promise { +export async function ensureAuth(): Promise { const apiKey = process.env.E2B_API_KEY - if (apiKey) return { apiKey } + if (apiKey) { + if (projectOverride && !apiKeyProjectWarned) { + apiKeyProjectWarned = true + console.warn( + `${asBold('--project')} is ignored when ${asBold( + 'E2B_API_KEY' + )} is set: the API key determines the project.` + ) + } + return { apiKey } + } if (!getUserConfig()) { console.error(authErrorBox()) @@ -109,7 +163,7 @@ export async function ensureAuth(cliProjectId?: string): Promise { const { accessToken, config } = await ensureValidAccessToken() let project: { id: string; domain: string } try { - project = await resolveProject(config, cliProjectId) + project = await resolveProject(config) } catch (err) { console.error(err instanceof Error ? err.message : err) process.exit(1) @@ -125,19 +179,8 @@ export async function ensureAuth(cliProjectId?: string): Promise { } /** An E2B API client authenticated with {@link ensureAuth}. */ -export async function apiClient(cliProjectId?: string) { - return new e2b.ApiClient( - new e2b.ConnectionConfig(await ensureAuth(cliProjectId)) - ) -} - -export function ensureUserConfig(): UserConfig { - const userConfig = getUserConfig() - if (!userConfig) { - console.error('No user config found, run `e2b auth login` to log in first.') - process.exit(1) - } - return userConfig +export async function apiClient() { + return new e2b.ApiClient(new e2b.ConnectionConfig(await ensureAuth())) } // Carries no credentials: used for the API domain and request signals only. diff --git a/packages/cli/src/commands/auth/configure.ts b/packages/cli/src/commands/auth/configure.ts index 2075c9b91a..261d5bbd2b 100644 --- a/packages/cli/src/commands/auth/configure.ts +++ b/packages/cli/src/commands/auth/configure.ts @@ -7,7 +7,7 @@ import { blockedWarning, pickProject } from 'src/workspaces' import { asBold, asFormattedError } from '../../utils/format' export const configureCommand = new commander.Command('configure') - .description('configure user') + .description('choose the project commands run in, interactively') .action(async () => { console.log('Configuring user...\n') diff --git a/packages/cli/src/commands/auth/login.ts b/packages/cli/src/commands/auth/login.ts index 00135d37da..66dfe2e4ef 100644 --- a/packages/cli/src/commands/auth/login.ts +++ b/packages/cli/src/commands/auth/login.ts @@ -34,7 +34,7 @@ export const loginCommand = new commander.Command('login') console.log( `\nAlready logged in. ${asFormattedConfig( userConfig - )}.\n\nIf you want to log in as a different user, log out first by running 'e2b auth logout'.\nTo change the project, run 'e2b auth configure'.\n` + )}.\n\nIf you want to log in as a different user, log out first by running 'e2b auth logout'.\nTo change the project, run 'e2b project use ' (see 'e2b project list').\n` ) return } else if (!userConfig) { diff --git a/packages/cli/src/commands/index.ts b/packages/cli/src/commands/index.ts index edd71e0f24..80b692a91c 100644 --- a/packages/cli/src/commands/index.ts +++ b/packages/cli/src/commands/index.ts @@ -1,9 +1,37 @@ import * as commander from '@commander-js/extra-typings' +import { setProjectOverride } from 'src/api' +import { projectIdFromOptions, projectOption } from 'src/options' import { asPrimary } from 'src/utils/format' import { templateCommand } from './template' +import { initCommand } from './template/init' +import { migrateCommand } from './template/migrate' import { sandboxCommand } from './sandbox' import { authCommand } from './auth' +import { workspaceCommand } from './workspace' +import { projectCommand } from './project' + +// Commands that only touch local files, so a project doesn't apply. +const localCommands: commander.CommandUnknownOpts[] = [ + initCommand, + migrateCommand, +] + +/** Give every API command under `group` a `--project` option. */ +function addProjectOption(group: commander.CommandUnknownOpts) { + for (const command of group.commands) { + if (command.commands.length) { + addProjectOption(command) + } else if ( + !localCommands.includes(command) && + !command.options.some((option) => option.long === '--project') + ) { + command.addOption(projectOption) + } + } +} +addProjectOption(sandboxCommand) +addProjectOption(templateCommand) export const program = new commander.Command() // Fixed so help reads `e2b` however the entrypoint is invoked (npm bin @@ -19,6 +47,17 @@ Visit ${asPrimary( 'E2B docs (https://e2b.dev/docs)' )} to learn how to create sandbox templates and start sandboxes.` ) + // `--project` is read here rather than in each action, so every helper that + // calls ensureAuth() picks it up without threading it through. + .hook('preAction', (_program, actionCommand) => { + setProjectOverride( + projectIdFromOptions( + actionCommand.opts() as { project?: string; team?: string } + ) + ) + }) .addCommand(authCommand) + .addCommand(workspaceCommand) + .addCommand(projectCommand) .addCommand(templateCommand) .addCommand(sandboxCommand) diff --git a/packages/cli/src/commands/project/index.ts b/packages/cli/src/commands/project/index.ts new file mode 100644 index 0000000000..50dc5bb4bb --- /dev/null +++ b/packages/cli/src/commands/project/index.ts @@ -0,0 +1,210 @@ +import * as commander from '@commander-js/extra-typings' + +import { currentProject, requireMe } from 'src/api' +import { Format, parseFormat, projectOption } from 'src/options' +import { asBold, asFormattedError } from 'src/utils/format' +import { renderTable } from 'src/utils/table' +import { updateUserConfig } from 'src/utils/token-refresh' +import { + allProjects, + blockedWarning, + findProject, + Me, + Project, + ProjectEntry, +} from 'src/workspaces' + +const formatOption = new commander.Option( + '-f, --format ', + 'output format, eg. json, pretty' +) + .argParser(parseFormat) + .default(Format.PRETTY) + +function fail(err: unknown): never { + console.error( + asFormattedError(err instanceof Error ? err.message : String(err)) + ) + process.exit(1) +} + +/** `active`, `provisioning`, `deleting`, or `blocked` with its reasons. */ +function projectStatus({ status, blocked, block_reasons }: Project) { + if (!blocked) return status + return block_reasons.length + ? `blocked (${block_reasons.join(', ')})` + : 'blocked' +} + +/** The current project, if it is one `me` lists. */ +function findCurrent(me: Me): ProjectEntry | undefined { + const current = currentProject() + if (!current) return undefined + try { + return findProject(me, current.ref) + } catch { + return undefined + } +} + +const listCommand = new commander.Command('list') + .description('list the projects you can access') + .alias('ls') + .option( + '-w, --workspace ', + 'only list projects in this workspace: its ID, wrk_ ID or name' + ) + .addOption(formatOption) + .action(async (opts) => { + try { + const me = await requireMe() + + let entries = allProjects(me) + if (opts.workspace) { + const ref = opts.workspace + const workspace = me.workspaces.find( + (w) => + w.id === ref || + w.public_id === ref || + w.name.toLowerCase() === ref.toLowerCase() + ) + if (!workspace) { + fail( + `Workspace "${ref}" not found. Run ${asBold('e2b workspace list')} to see your workspaces.` + ) + } + entries = entries.filter((e) => e.workspace.id === workspace.id) + } + const currentId = findCurrent(me)?.project.id + + if (opts.format === Format.JSON) { + console.log( + JSON.stringify( + entries.map(({ project, workspace }) => ({ + ...project, + workspace_name: workspace.name, + current: project.id === currentId, + })), + null, + 2 + ) + ) + return + } + + if (!entries.length) { + console.log('No projects found') + return + } + renderTable(entries, [ + { + header: 'Current', + value: ({ project }) => (project.id === currentId ? '*' : ''), + }, + { header: 'Project ID', value: ({ project }) => project.public_id }, + { header: 'Name', value: ({ project }) => project.name }, + { header: 'Workspace', value: ({ workspace }) => workspace.name }, + { header: 'Region', value: ({ project }) => project.region }, + { header: 'Status', value: ({ project }) => projectStatus(project) }, + ]) + } catch (err) { + fail(err) + } + }) + +const useCommand = new commander.Command('use') + .description('set the project commands run in by default') + .argument('', 'the project: its ID, prj_ ID, slug or name') + .action(async (ref) => { + try { + const entry = findProject(await requireMe(), ref) + const { project, workspace } = entry + if (project.status !== 'active') { + fail(`Project ${project.name} is ${project.status} and can't be used.`) + } + const warning = blockedWarning(entry) + if (warning) console.warn(warning) + + await updateUserConfig({ + projectId: project.id, + projectName: project.name, + projectDomain: project.control_plane_domain, + }) + console.log( + `Now using project ${asBold(project.name)} (${project.public_id}) in workspace ${asBold(workspace.name)}.` + ) + + const override = currentProject() + if (override?.source === 'E2B_PROJECT_ID') { + console.warn( + `${asBold('E2B_PROJECT_ID')} is set and takes precedence over the selected project.` + ) + } + if (process.env.E2B_API_KEY) { + console.warn( + `${asBold('E2B_API_KEY')} is set: commands use the API key's project instead.` + ) + } + } catch (err) { + fail(err) + } + }) + +const infoCommand = new commander.Command('info') + .description('show the current project, or the one passed with --project') + .addOption(projectOption) + .addOption(formatOption) + .action(async (opts) => { + try { + const current = currentProject() + if (!current) { + fail(`No project selected. Run ${asBold('e2b project use ')}.`) + } + const { project, workspace } = findProject(await requireMe(), current.ref) + + if (opts.format === Format.JSON) { + console.log( + JSON.stringify( + { + ...project, + workspace_name: workspace.name, + selected_by: current.source, + }, + null, + 2 + ) + ) + return + } + + const rows: [string, string][] = [ + ['Name', project.name], + ['Project ID', project.public_id], + ['UUID', project.id], + ['Slug', project.slug], + ['Workspace', `${workspace.name} (${workspace.public_id})`], + ['Region', project.region], + ['Control plane', project.control_plane_domain], + ['Status', projectStatus(project)], + ['Your access', project.capabilities.join(', ') || 'none'], + [ + 'Selected by', + current.source === 'selected' ? 'e2b project use' : current.source, + ], + ] + const width = Math.max(...rows.map(([label]) => label.length)) + 2 + console.log(`\nProject info for ${asBold(project.name)}:`) + for (const [label, value] of rows) { + console.log(` ${`${label}:`.padEnd(width)}${value}`) + } + } catch (err) { + fail(err) + } + }) + +export const projectCommand = new commander.Command('project') + .description('work with projects') + .alias('prj') + .addCommand(listCommand) + .addCommand(useCommand) + .addCommand(infoCommand) diff --git a/packages/cli/src/commands/template/list.ts b/packages/cli/src/commands/template/list.ts index a79bf8fb67..527fd5ddd2 100644 --- a/packages/cli/src/commands/template/list.ts +++ b/packages/cli/src/commands/template/list.ts @@ -9,8 +9,7 @@ import { deprecatedTeamOption, Format, parseFormat, - projectIdFromOptions, - projectOption, + projectOptionWithShortFlag, } from '../../options' import { handleE2BRequestError } from '../../utils/errors' import { integer, zodParser } from '../../validation' @@ -21,7 +20,7 @@ const PAGE_LIMIT = 100 export const listCommand = new commander.Command('list') .description('list sandbox templates') .alias('ls') - .addOption(projectOption) + .addOption(projectOptionWithShortFlag) .addOption(deprecatedTeamOption) .option( '-l, --limit ', diff --git a/packages/cli/src/commands/workspace/index.ts b/packages/cli/src/commands/workspace/index.ts new file mode 100644 index 0000000000..d089f7828a --- /dev/null +++ b/packages/cli/src/commands/workspace/index.ts @@ -0,0 +1,66 @@ +import * as commander from '@commander-js/extra-typings' + +import { requireMe } from 'src/api' +import { Format, parseFormat } from 'src/options' +import { asFormattedError } from 'src/utils/format' +import { renderTable } from 'src/utils/table' +import { Workspace } from 'src/workspaces' + +/** `active`, or `blocked` with its reasons. */ +function workspaceStatus({ blocked, block_reasons }: Workspace) { + if (!blocked) return 'active' + return block_reasons.length + ? `blocked (${block_reasons.join(', ')})` + : 'blocked' +} + +const listCommand = new commander.Command('list') + .description('list the workspaces you can access') + .alias('ls') + .option( + '-f, --format ', + 'output format, eg. json, pretty', + parseFormat, + Format.PRETTY + ) + .action(async (opts) => { + try { + const { workspaces } = await requireMe() + + if (opts.format === Format.JSON) { + console.log( + JSON.stringify( + workspaces.map(({ projects, ...workspace }) => ({ + ...workspace, + project_count: projects.length, + })), + null, + 2 + ) + ) + return + } + + if (!workspaces.length) { + console.log('No workspaces found') + return + } + renderTable(workspaces, [ + { header: 'Workspace ID', value: (w) => w.public_id }, + { header: 'Name', value: (w) => w.name }, + { header: 'Kind', value: (w) => w.kind }, + { header: 'Projects', value: (w) => String(w.projects.length) }, + { header: 'Status', value: workspaceStatus }, + ]) + } catch (err) { + console.error( + asFormattedError(err instanceof Error ? err.message : String(err)) + ) + process.exit(1) + } + }) + +export const workspaceCommand = new commander.Command('workspace') + .description('work with workspaces') + .alias('ws') + .addCommand(listCommand) diff --git a/packages/cli/src/options.ts b/packages/cli/src/options.ts index b5ac1fa281..08b5e62142 100644 --- a/packages/cli/src/options.ts +++ b/packages/cli/src/options.ts @@ -69,9 +69,20 @@ export function warnIgnoredConfigOption(opts: { config?: string }) { ) } +const projectDescription = `project to run the command in instead of the selected one: its ID, prj_ ID, slug or name. Defaults to E2B_PROJECT_ID, then the project chosen with ${asBold( + 'e2b project use' +)}.` + +/** Added to every command that calls the E2B API (see src/commands/index.ts). */ export const projectOption = new commander.Option( - '-t, --project ', - 'specify the project ID that the operation will be associated with. You can find project ID in the project settings in the E2B dashboard (https://e2b.dev/dashboard?tab=general).' + '--project ', + projectDescription +) + +// `template list` accepted `-t` before every command took --project. +export const projectOptionWithShortFlag = new commander.Option( + '-t, --project ', + projectDescription ) export const deprecatedTeamOption = new commander.Option( diff --git a/packages/cli/src/workspaces.ts b/packages/cli/src/workspaces.ts index 1054067767..2e5f765cca 100644 --- a/packages/cli/src/workspaces.ts +++ b/packages/cli/src/workspaces.ts @@ -180,7 +180,7 @@ export function findProject( throw new Error( `Project "${ref}" not found among the projects you can access${ workspace ? ` in workspace ${workspace.name}` : '' - }. Run ${asBold('e2b auth configure')} to choose one.` + }. Run ${asBold('e2b project list')} to see them.` ) } diff --git a/packages/cli/tests/auth.test.ts b/packages/cli/tests/auth.test.ts index 610bbaa040..7773ce2c4a 100644 --- a/packages/cli/tests/auth.test.ts +++ b/packages/cli/tests/auth.test.ts @@ -200,10 +200,12 @@ describe('ensureAuth', () => { project('other'), project('flag'), ]) - const { ensureAuth } = await import('../src/api') + const { ensureAuth, setProjectOverride } = await import('../src/api') + // `--project` reaches ensureAuth() through setProjectOverride(). const target = async (ref?: string) => { - const auth = (await ensureAuth(ref)) as { + setProjectOverride(ref) + const auth = (await ensureAuth()) as { apiHeaders: Record domain: string } @@ -223,14 +225,27 @@ describe('ensureAuth', () => { throw new Error(`exit ${code}`) }) as never) const error = vi.spyOn(console, 'error').mockImplementation(() => {}) - const { ensureAuth } = await import('../src/api') + const { ensureAuth, setProjectOverride } = await import('../src/api') - await expect(ensureAuth('prj_missing')).rejects.toThrow('exit 1') + setProjectOverride('prj_missing') + await expect(ensureAuth()).rejects.toThrow('exit 1') expect(String(error.mock.calls[0][0])).toContain( 'Project "prj_missing" not found' ) }) + test('warns that --project is ignored with E2B_API_KEY', async () => { + vi.stubEnv('E2B_API_KEY', 'e2b_env_key') + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) + const { ensureAuth, setProjectOverride } = await import('../src/api') + + setProjectOverride('prj_other') + expect(await ensureAuth()).toEqual({ apiKey: 'e2b_env_key' }) + await ensureAuth() + expect(warn).toHaveBeenCalledTimes(1) + expect(String(warn.mock.calls[0][0])).toContain('--project') + }) + test('exits with the login hint when signed out', async () => { const exit = vi.spyOn(process, 'exit').mockImplementation((( code?: number diff --git a/packages/cli/tests/commands/project.test.ts b/packages/cli/tests/commands/project.test.ts new file mode 100644 index 0000000000..5e06929aec --- /dev/null +++ b/packages/cli/tests/commands/project.test.ts @@ -0,0 +1,219 @@ +import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest' + +import type { Me, Project } from '../../src/workspaces' + +function project(id: string, overrides: Partial = {}): Project { + return { + id, + public_id: `prj_${id}`, + workspace_id: 'ws', + name: `Project ${id}`, + slug: `slug-${id}`, + region: 'us-west-1', + control_plane_domain: `${id}.e2b.dev`, + status: 'active', + blocked: false, + block_reasons: [], + capabilities: ['project:read'], + ...overrides, + } +} + +const me: Me = { + workspaces: [ + { + id: 'personal', + public_id: 'wrk_personal', + name: 'Personal', + kind: 'personal', + blocked: false, + block_reasons: [], + capabilities: [], + projects: [project('mine')], + }, + { + id: 'team', + public_id: 'wrk_team', + name: 'Team', + kind: 'standard', + blocked: false, + block_reasons: [], + capabilities: [], + projects: [ + project('shared', { + blocked: true, + block_reasons: ['credit_exhausted'], + }), + project('new', { status: 'provisioning' }), + ], + }, + ], +} + +const mocks = vi.hoisted(() => ({ + setProjectOverride: vi.fn(), + updateUserConfig: vi.fn(), + current: undefined as { ref: string; source: string } | undefined, +})) + +vi.mock( + '../../src/api', + async (importOriginal: () => Promise) => ({ + ...(await importOriginal()), + requireMe: async () => me, + currentProject: () => mocks.current, + setProjectOverride: mocks.setProjectOverride, + }) +) + +vi.mock( + '../../src/utils/token-refresh', + async ( + importOriginal: () => Promise< + typeof import('../../src/utils/token-refresh') + > + ) => ({ + ...(await importOriginal()), + updateUserConfig: mocks.updateUserConfig, + }) +) + +async function run(args: string[]) { + const out: string[] = [] + const err: string[] = [] + vi.spyOn(console, 'log').mockImplementation((...a: unknown[]) => + out.push(a.join(' ')) + ) + vi.spyOn(console, 'warn').mockImplementation((...a: unknown[]) => + err.push(a.join(' ')) + ) + vi.spyOn(console, 'error').mockImplementation((...a: unknown[]) => + err.push(a.join(' ')) + ) + const exit = vi.spyOn(process, 'exit').mockImplementation((( + code?: number + ) => { + throw new Error(`exit ${code}`) + }) as never) + + const { program } = await import('../../src/commands') + await program + .parseAsync(args, { from: 'user' }) + .catch((e: Error) => err.push(e.message)) + return { out: out.join('\n'), err: err.join('\n'), exit } +} + +beforeEach(() => { + vi.resetModules() + vi.clearAllMocks() + mocks.current = { ref: 'mine', source: 'selected' } +}) + +afterEach(() => { + vi.restoreAllMocks() +}) + +describe('e2b workspace list', () => { + test('lists workspaces with their project count', async () => { + const { out } = await run(['workspace', 'list', '--format', 'json']) + expect(JSON.parse(out)).toEqual([ + expect.objectContaining({ public_id: 'wrk_personal', project_count: 1 }), + expect.objectContaining({ public_id: 'wrk_team', project_count: 2 }), + ]) + }) +}) + +describe('e2b project list', () => { + test('marks the current project and shows blocked ones', async () => { + const { out } = await run(['project', 'list']) + expect(out).toMatch(/\*\s+prj_mine/) + expect(out).toContain('blocked (credit_exhausted)') + expect(out).toContain('provisioning') + }) + + test('filters by --workspace', async () => { + const { out } = await run(['project', 'list', '-w', 'team', '-f', 'json']) + expect(JSON.parse(out).map((p: Project) => p.public_id)).toEqual([ + 'prj_shared', + 'prj_new', + ]) + }) + + test('rejects an unknown workspace', async () => { + const { err } = await run(['project', 'list', '--workspace', 'nope']) + expect(err).toContain('Workspace "nope" not found') + }) +}) + +describe('e2b project use', () => { + test('stores the project, its name and control plane', async () => { + const { out } = await run(['project', 'use', 'slug-shared']) + expect(mocks.updateUserConfig).toHaveBeenCalledWith({ + projectId: 'shared', + projectName: 'Project shared', + projectDomain: 'shared.e2b.dev', + }) + expect(out).toContain('Now using project') + }) + + test('warns about a blocked project', async () => { + const { err } = await run(['project', 'use', 'prj_shared']) + expect(err).toContain('is blocked (credit_exhausted)') + }) + + test('refuses a project that is not active', async () => { + const { err } = await run(['project', 'use', 'prj_new']) + expect(err).toContain("is provisioning and can't be used") + expect(mocks.updateUserConfig).not.toHaveBeenCalled() + }) +}) + +describe('e2b project info', () => { + test('shows the current project and why it is current', async () => { + mocks.current = { ref: 'prj_shared', source: '--project' } + const { out } = await run([ + 'project', + 'info', + '--project', + 'prj_shared', + '-f', + 'json', + ]) + expect(JSON.parse(out)).toMatchObject({ + id: 'shared', + control_plane_domain: 'shared.e2b.dev', + workspace_name: 'Team', + selected_by: '--project', + }) + }) +}) + +describe('--project', () => { + test('is passed to ensureAuth() before the action runs', async () => { + await run(['project', 'info', '--project', 'prj_shared']) + expect(mocks.setProjectOverride).toHaveBeenCalledWith('prj_shared') + }) + + test('is offered by every command that calls the API', async () => { + const { program } = await import('../../src/commands') + const leaves = (cmd: typeof program, path: string[] = []): string[][] => + cmd.commands.flatMap((sub) => + sub.commands.length + ? leaves(sub as typeof program, [...path, sub.name()]) + : [[...path, sub.name()]] + ) + const withoutProject = leaves(program) + .filter(([group]) => group === 'sandbox' || group === 'template') + .filter((path) => { + let cmd = program as { + commands: readonly (typeof program)[] + } & typeof program + for (const name of path) { + cmd = cmd.commands.find((c) => c.name() === name) as typeof cmd + } + return !cmd.options.some((option) => option.long === '--project') + }) + .map((path) => path.join(' ')) + expect(withoutProject).toEqual(['template init', 'template migrate']) + }) +}) From 4655a4d740b3d738100d4e0f806b09b2394319d8 Mon Sep 17 00:00:00 2001 From: Mish Ushakov <10400064+mishushakov@users.noreply.github.com> Date: Tue, 10 Nov 2026 14:52:07 +0100 Subject: [PATCH 2/6] fix(cli): name project sources and statuses, reject ambiguous workspace names - ProjectSource and ProjectStatus members replace the bare literals the project commands dispatched on; the JSON values are unchanged. - project use says what to do with a project that isn't active. - project list --workspace fails on a name shared by several workspaces instead of picking the first, as findProject already does. Co-Authored-By: Claude Fable 5.1 --- packages/cli/src/api.ts | 20 +++++++++--- packages/cli/src/commands/project/index.ts | 35 +++++++++++++++------ packages/cli/tests/commands/project.test.ts | 11 +++++++ 3 files changed, 51 insertions(+), 15 deletions(-) diff --git a/packages/cli/src/api.ts b/packages/cli/src/api.ts index deabd0ebc3..6bc412dfbf 100644 --- a/packages/cli/src/api.ts +++ b/packages/cli/src/api.ts @@ -58,17 +58,27 @@ function requestedProject(): string | undefined { return projectOverride || projectId } +/** Where the current project comes from; shown as `selected_by` in JSON. */ +export enum ProjectSource { + Flag = '--project', + Env = 'E2B_PROJECT_ID', + Selected = 'selected', +} + /** * The project commands run in and where that choice comes from, or * `undefined` when signed out without `--project` or `E2B_PROJECT_ID`. */ export function currentProject(): - | { ref: string; source: '--project' | 'E2B_PROJECT_ID' | 'selected' } - | undefined { - if (projectOverride) return { ref: projectOverride, source: '--project' } - if (projectId) return { ref: projectId, source: 'E2B_PROJECT_ID' } + { ref: string; source: ProjectSource } | undefined { + if (projectOverride) { + return { ref: projectOverride, source: ProjectSource.Flag } + } + if (projectId) return { ref: projectId, source: ProjectSource.Env } const config = getUserConfig() - return config ? { ref: config.projectId, source: 'selected' } : undefined + return config + ? { ref: config.projectId, source: ProjectSource.Selected } + : undefined } /** The logged-in user's workspaces and projects, fetched once per process. */ diff --git a/packages/cli/src/commands/project/index.ts b/packages/cli/src/commands/project/index.ts index 50dc5bb4bb..48c7429988 100644 --- a/packages/cli/src/commands/project/index.ts +++ b/packages/cli/src/commands/project/index.ts @@ -1,6 +1,6 @@ import * as commander from '@commander-js/extra-typings' -import { currentProject, requireMe } from 'src/api' +import { currentProject, ProjectSource, requireMe } from 'src/api' import { Format, parseFormat, projectOption } from 'src/options' import { asBold, asFormattedError } from 'src/utils/format' import { renderTable } from 'src/utils/table' @@ -12,6 +12,7 @@ import { Me, Project, ProjectEntry, + ProjectStatus, } from 'src/workspaces' const formatOption = new commander.Option( @@ -62,12 +63,20 @@ const listCommand = new commander.Command('list') let entries = allProjects(me) if (opts.workspace) { const ref = opts.workspace - const workspace = me.workspaces.find( - (w) => - w.id === ref || - w.public_id === ref || - w.name.toLowerCase() === ref.toLowerCase() + const exact = me.workspaces.find( + (w) => w.id === ref || w.public_id === ref ) + const byName = me.workspaces.filter( + (w) => w.name.toLowerCase() === ref.toLowerCase() + ) + if (!exact && byName.length > 1) { + fail( + `More than one workspace is named "${ref}": ${byName + .map((w) => w.public_id) + .join(', ')}. Pass the workspace ID instead.` + ) + } + const workspace = exact ?? byName[0] if (!workspace) { fail( `Workspace "${ref}" not found. Run ${asBold('e2b workspace list')} to see your workspaces.` @@ -119,8 +128,12 @@ const useCommand = new commander.Command('use') try { const entry = findProject(await requireMe(), ref) const { project, workspace } = entry - if (project.status !== 'active') { - fail(`Project ${project.name} is ${project.status} and can't be used.`) + if (project.status !== ProjectStatus.Active) { + fail( + `Project ${project.name} is ${project.status} and can't be used. Pick an active project from ${asBold( + 'e2b project list' + )}, or try again once it is active.` + ) } const warning = blockedWarning(entry) if (warning) console.warn(warning) @@ -135,7 +148,7 @@ const useCommand = new commander.Command('use') ) const override = currentProject() - if (override?.source === 'E2B_PROJECT_ID') { + if (override?.source === ProjectSource.Env) { console.warn( `${asBold('E2B_PROJECT_ID')} is set and takes precedence over the selected project.` ) @@ -189,7 +202,9 @@ const infoCommand = new commander.Command('info') ['Your access', project.capabilities.join(', ') || 'none'], [ 'Selected by', - current.source === 'selected' ? 'e2b project use' : current.source, + current.source === ProjectSource.Selected + ? 'e2b project use' + : current.source, ], ] const width = Math.max(...rows.map(([label]) => label.length)) + 2 diff --git a/packages/cli/tests/commands/project.test.ts b/packages/cli/tests/commands/project.test.ts index 5e06929aec..cbc627dc72 100644 --- a/packages/cli/tests/commands/project.test.ts +++ b/packages/cli/tests/commands/project.test.ts @@ -143,6 +143,17 @@ describe('e2b project list', () => { const { err } = await run(['project', 'list', '--workspace', 'nope']) expect(err).toContain('Workspace "nope" not found') }) + + test('rejects an ambiguous workspace name', async () => { + me.workspaces.push({ ...me.workspaces[1], id: 'team2', public_id: 'wrk_team2', projects: [] }) + try { + const { err } = await run(['project', 'list', '--workspace', 'TEAM']) + expect(err).toContain('More than one workspace is named "TEAM"') + expect(err).toContain('wrk_team, wrk_team2') + } finally { + me.workspaces.pop() + } + }) }) describe('e2b project use', () => { From 15631cb4531104ed8abab395dc5e91f9f094ea5d Mon Sep 17 00:00:00 2001 From: Mish Ushakov <10400064+mishushakov@users.noreply.github.com> Date: Wed, 11 Nov 2026 18:22:48 +0100 Subject: [PATCH 3/6] refactor(cli): drop --project from the project commands Every sandbox and template command took --project; that is out of scope for this PR. template list keeps its -t/--project and E2B_PROJECT_ID still works. project info shows the current project only. Co-Authored-By: Claude Fable 5.1 --- .changeset/cli-project-commands.md | 13 +++--- packages/cli/src/api.ts | 50 +++++---------------- packages/cli/src/commands/index.ts | 35 --------------- packages/cli/src/commands/project/index.ts | 5 +-- packages/cli/src/commands/template/list.ts | 5 ++- packages/cli/src/options.ts | 15 +------ packages/cli/tests/auth.test.ts | 23 ++-------- packages/cli/tests/commands/project.test.ts | 47 +++---------------- 8 files changed, 36 insertions(+), 157 deletions(-) diff --git a/.changeset/cli-project-commands.md b/.changeset/cli-project-commands.md index 25952ba397..3d8a614e58 100644 --- a/.changeset/cli-project-commands.md +++ b/.changeset/cli-project-commands.md @@ -2,14 +2,13 @@ '@e2b/cli': minor --- -Add commands for workspaces and projects, and a `--project` option on every command that calls the E2B API: +Add commands for workspaces and projects: ```sh -e2b workspace list # workspaces you can access -e2b project list [--workspace ] # projects, the current one marked with * -e2b project use # set the default project -e2b project info [--project ] # the current project, or another one -e2b sandbox list --project # run any command in another project +e2b workspace list # workspaces you can access +e2b project list [--workspace ] # projects, the current one marked with * +e2b project use # set the project commands run in +e2b project info # the current project and why it is current ``` -A project can be given by its ID, `prj_` ID, slug or name. The project a command runs in is `--project`, then `E2B_PROJECT_ID`, then the one chosen with `e2b project use` (or `e2b auth configure`, which picks it interactively). With `E2B_API_KEY` set, the API key determines the project and `--project` is ignored with a warning. `template list` keeps its `-t` short flag. +A project can be given by its ID, `prj_` ID, slug or name. Commands run in the project from `E2B_PROJECT_ID`, then the one chosen with `e2b project use` (or `e2b auth configure`, which picks it interactively). With `E2B_API_KEY` set, the API key determines the project. diff --git a/packages/cli/src/api.ts b/packages/cli/src/api.ts index 6bc412dfbf..8703258a97 100644 --- a/packages/cli/src/api.ts +++ b/packages/cli/src/api.ts @@ -42,38 +42,19 @@ export type Auth = | Required> let me: Promise | undefined -let projectOverride: string | undefined -let apiKeyProjectWarned = false - -/** - * The `--project` value of the running command. Set once, before the action - * runs, by the preAction hook in src/commands/index.ts. - */ -export function setProjectOverride(project: string | undefined) { - projectOverride = project -} - -/** The project reference to use instead of the selected one, if any. */ -function requestedProject(): string | undefined { - return projectOverride || projectId -} /** Where the current project comes from; shown as `selected_by` in JSON. */ export enum ProjectSource { - Flag = '--project', Env = 'E2B_PROJECT_ID', Selected = 'selected', } /** * The project commands run in and where that choice comes from, or - * `undefined` when signed out without `--project` or `E2B_PROJECT_ID`. + * `undefined` when signed out without `E2B_PROJECT_ID`. */ export function currentProject(): { ref: string; source: ProjectSource } | undefined { - if (projectOverride) { - return { ref: projectOverride, source: ProjectSource.Flag } - } if (projectId) return { ref: projectId, source: ProjectSource.Env } const config = getUserConfig() return config @@ -114,13 +95,14 @@ export async function requireMe(): Promise { } /** - * The project requests go to, and its control plane: `--project`, then + * The project requests go to, and its control plane: `cliProjectId`, then * `E2B_PROJECT_ID`, then the project selected with `e2b project use`. */ async function resolveProject( - config: UserConfig + config: UserConfig, + cliProjectId?: string ): Promise<{ id: string; domain: string }> { - const ref = requestedProject() + const ref = cliProjectId || projectId if (ref && ref !== config.projectId) { const { project } = findProject(await getMe(), ref) return { id: project.id, domain: project.control_plane_domain } @@ -151,19 +133,9 @@ async function resolveProject( * request (not once per command) so long-running commands keep working after * the hour-long access token expires. */ -export async function ensureAuth(): Promise { +export async function ensureAuth(cliProjectId?: string): Promise { const apiKey = process.env.E2B_API_KEY - if (apiKey) { - if (projectOverride && !apiKeyProjectWarned) { - apiKeyProjectWarned = true - console.warn( - `${asBold('--project')} is ignored when ${asBold( - 'E2B_API_KEY' - )} is set: the API key determines the project.` - ) - } - return { apiKey } - } + if (apiKey) return { apiKey } if (!getUserConfig()) { console.error(authErrorBox()) @@ -173,7 +145,7 @@ export async function ensureAuth(): Promise { const { accessToken, config } = await ensureValidAccessToken() let project: { id: string; domain: string } try { - project = await resolveProject(config) + project = await resolveProject(config, cliProjectId) } catch (err) { console.error(err instanceof Error ? err.message : err) process.exit(1) @@ -189,8 +161,10 @@ export async function ensureAuth(): Promise { } /** An E2B API client authenticated with {@link ensureAuth}. */ -export async function apiClient() { - return new e2b.ApiClient(new e2b.ConnectionConfig(await ensureAuth())) +export async function apiClient(cliProjectId?: string) { + return new e2b.ApiClient( + new e2b.ConnectionConfig(await ensureAuth(cliProjectId)) + ) } // Carries no credentials: used for the API domain and request signals only. diff --git a/packages/cli/src/commands/index.ts b/packages/cli/src/commands/index.ts index 80b692a91c..fcad1ed401 100644 --- a/packages/cli/src/commands/index.ts +++ b/packages/cli/src/commands/index.ts @@ -1,38 +1,12 @@ import * as commander from '@commander-js/extra-typings' -import { setProjectOverride } from 'src/api' -import { projectIdFromOptions, projectOption } from 'src/options' import { asPrimary } from 'src/utils/format' import { templateCommand } from './template' -import { initCommand } from './template/init' -import { migrateCommand } from './template/migrate' import { sandboxCommand } from './sandbox' import { authCommand } from './auth' import { workspaceCommand } from './workspace' import { projectCommand } from './project' -// Commands that only touch local files, so a project doesn't apply. -const localCommands: commander.CommandUnknownOpts[] = [ - initCommand, - migrateCommand, -] - -/** Give every API command under `group` a `--project` option. */ -function addProjectOption(group: commander.CommandUnknownOpts) { - for (const command of group.commands) { - if (command.commands.length) { - addProjectOption(command) - } else if ( - !localCommands.includes(command) && - !command.options.some((option) => option.long === '--project') - ) { - command.addOption(projectOption) - } - } -} -addProjectOption(sandboxCommand) -addProjectOption(templateCommand) - export const program = new commander.Command() // Fixed so help reads `e2b` however the entrypoint is invoked (npm bin // symlink, `node dist/index.js`, or a distro wrapper script). @@ -47,15 +21,6 @@ Visit ${asPrimary( 'E2B docs (https://e2b.dev/docs)' )} to learn how to create sandbox templates and start sandboxes.` ) - // `--project` is read here rather than in each action, so every helper that - // calls ensureAuth() picks it up without threading it through. - .hook('preAction', (_program, actionCommand) => { - setProjectOverride( - projectIdFromOptions( - actionCommand.opts() as { project?: string; team?: string } - ) - ) - }) .addCommand(authCommand) .addCommand(workspaceCommand) .addCommand(projectCommand) diff --git a/packages/cli/src/commands/project/index.ts b/packages/cli/src/commands/project/index.ts index 48c7429988..f928aaaec6 100644 --- a/packages/cli/src/commands/project/index.ts +++ b/packages/cli/src/commands/project/index.ts @@ -1,7 +1,7 @@ import * as commander from '@commander-js/extra-typings' import { currentProject, ProjectSource, requireMe } from 'src/api' -import { Format, parseFormat, projectOption } from 'src/options' +import { Format, parseFormat } from 'src/options' import { asBold, asFormattedError } from 'src/utils/format' import { renderTable } from 'src/utils/table' import { updateUserConfig } from 'src/utils/token-refresh' @@ -164,8 +164,7 @@ const useCommand = new commander.Command('use') }) const infoCommand = new commander.Command('info') - .description('show the current project, or the one passed with --project') - .addOption(projectOption) + .description('show the project commands run in') .addOption(formatOption) .action(async (opts) => { try { diff --git a/packages/cli/src/commands/template/list.ts b/packages/cli/src/commands/template/list.ts index 527fd5ddd2..a79bf8fb67 100644 --- a/packages/cli/src/commands/template/list.ts +++ b/packages/cli/src/commands/template/list.ts @@ -9,7 +9,8 @@ import { deprecatedTeamOption, Format, parseFormat, - projectOptionWithShortFlag, + projectIdFromOptions, + projectOption, } from '../../options' import { handleE2BRequestError } from '../../utils/errors' import { integer, zodParser } from '../../validation' @@ -20,7 +21,7 @@ const PAGE_LIMIT = 100 export const listCommand = new commander.Command('list') .description('list sandbox templates') .alias('ls') - .addOption(projectOptionWithShortFlag) + .addOption(projectOption) .addOption(deprecatedTeamOption) .option( '-l, --limit ', diff --git a/packages/cli/src/options.ts b/packages/cli/src/options.ts index 08b5e62142..b5ac1fa281 100644 --- a/packages/cli/src/options.ts +++ b/packages/cli/src/options.ts @@ -69,20 +69,9 @@ export function warnIgnoredConfigOption(opts: { config?: string }) { ) } -const projectDescription = `project to run the command in instead of the selected one: its ID, prj_ ID, slug or name. Defaults to E2B_PROJECT_ID, then the project chosen with ${asBold( - 'e2b project use' -)}.` - -/** Added to every command that calls the E2B API (see src/commands/index.ts). */ export const projectOption = new commander.Option( - '--project ', - projectDescription -) - -// `template list` accepted `-t` before every command took --project. -export const projectOptionWithShortFlag = new commander.Option( - '-t, --project ', - projectDescription + '-t, --project ', + 'specify the project ID that the operation will be associated with. You can find project ID in the project settings in the E2B dashboard (https://e2b.dev/dashboard?tab=general).' ) export const deprecatedTeamOption = new commander.Option( diff --git a/packages/cli/tests/auth.test.ts b/packages/cli/tests/auth.test.ts index 7773ce2c4a..610bbaa040 100644 --- a/packages/cli/tests/auth.test.ts +++ b/packages/cli/tests/auth.test.ts @@ -200,12 +200,10 @@ describe('ensureAuth', () => { project('other'), project('flag'), ]) - const { ensureAuth, setProjectOverride } = await import('../src/api') + const { ensureAuth } = await import('../src/api') - // `--project` reaches ensureAuth() through setProjectOverride(). const target = async (ref?: string) => { - setProjectOverride(ref) - const auth = (await ensureAuth()) as { + const auth = (await ensureAuth(ref)) as { apiHeaders: Record domain: string } @@ -225,27 +223,14 @@ describe('ensureAuth', () => { throw new Error(`exit ${code}`) }) as never) const error = vi.spyOn(console, 'error').mockImplementation(() => {}) - const { ensureAuth, setProjectOverride } = await import('../src/api') + const { ensureAuth } = await import('../src/api') - setProjectOverride('prj_missing') - await expect(ensureAuth()).rejects.toThrow('exit 1') + await expect(ensureAuth('prj_missing')).rejects.toThrow('exit 1') expect(String(error.mock.calls[0][0])).toContain( 'Project "prj_missing" not found' ) }) - test('warns that --project is ignored with E2B_API_KEY', async () => { - vi.stubEnv('E2B_API_KEY', 'e2b_env_key') - const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) - const { ensureAuth, setProjectOverride } = await import('../src/api') - - setProjectOverride('prj_other') - expect(await ensureAuth()).toEqual({ apiKey: 'e2b_env_key' }) - await ensureAuth() - expect(warn).toHaveBeenCalledTimes(1) - expect(String(warn.mock.calls[0][0])).toContain('--project') - }) - test('exits with the login hint when signed out', async () => { const exit = vi.spyOn(process, 'exit').mockImplementation((( code?: number diff --git a/packages/cli/tests/commands/project.test.ts b/packages/cli/tests/commands/project.test.ts index cbc627dc72..3ebeb8f3ed 100644 --- a/packages/cli/tests/commands/project.test.ts +++ b/packages/cli/tests/commands/project.test.ts @@ -51,7 +51,6 @@ const me: Me = { } const mocks = vi.hoisted(() => ({ - setProjectOverride: vi.fn(), updateUserConfig: vi.fn(), current: undefined as { ref: string; source: string } | undefined, })) @@ -62,7 +61,6 @@ vi.mock( ...(await importOriginal()), requireMe: async () => me, currentProject: () => mocks.current, - setProjectOverride: mocks.setProjectOverride, }) ) @@ -181,50 +179,19 @@ describe('e2b project use', () => { describe('e2b project info', () => { test('shows the current project and why it is current', async () => { - mocks.current = { ref: 'prj_shared', source: '--project' } - const { out } = await run([ - 'project', - 'info', - '--project', - 'prj_shared', - '-f', - 'json', - ]) + mocks.current = { ref: 'prj_shared', source: 'E2B_PROJECT_ID' } + const { out } = await run(['project', 'info', '-f', 'json']) expect(JSON.parse(out)).toMatchObject({ id: 'shared', control_plane_domain: 'shared.e2b.dev', workspace_name: 'Team', - selected_by: '--project', + selected_by: 'E2B_PROJECT_ID', }) }) -}) - -describe('--project', () => { - test('is passed to ensureAuth() before the action runs', async () => { - await run(['project', 'info', '--project', 'prj_shared']) - expect(mocks.setProjectOverride).toHaveBeenCalledWith('prj_shared') - }) - test('is offered by every command that calls the API', async () => { - const { program } = await import('../../src/commands') - const leaves = (cmd: typeof program, path: string[] = []): string[][] => - cmd.commands.flatMap((sub) => - sub.commands.length - ? leaves(sub as typeof program, [...path, sub.name()]) - : [[...path, sub.name()]] - ) - const withoutProject = leaves(program) - .filter(([group]) => group === 'sandbox' || group === 'template') - .filter((path) => { - let cmd = program as { - commands: readonly (typeof program)[] - } & typeof program - for (const name of path) { - cmd = cmd.commands.find((c) => c.name() === name) as typeof cmd - } - return !cmd.options.some((option) => option.long === '--project') - }) - .map((path) => path.join(' ')) - expect(withoutProject).toEqual(['template init', 'template migrate']) + test('fails when no project is selected', async () => { + mocks.current = undefined + const { err } = await run(['project', 'info']) + expect(err).toContain('No project selected') }) }) From ee1bb1d6e33cb39a2b3c00cd3b2504d398b40b9d Mon Sep 17 00:00:00 2001 From: Mish Ushakov <10400064+mishushakov@users.noreply.github.com> Date: Thu, 12 Nov 2026 10:25:18 +0100 Subject: [PATCH 4/6] refactor(cli): drop the project source from currentProject and project info Where the current project came from (`E2B_PROJECT_ID` or `project use`) isn't worth a concept of its own: `currentProject()` returns the reference, `project use` checks `E2B_PROJECT_ID` directly, and `project info` no longer prints `selected_by` / "Selected by". Co-Authored-By: Claude Fable 5.1 --- .changeset/cli-project-commands.md | 2 +- packages/cli/src/api.ts | 19 ++++--------------- packages/cli/src/commands/project/index.ts | 16 ++++------------ packages/cli/tests/commands/project.test.ts | 9 ++++----- 4 files changed, 13 insertions(+), 33 deletions(-) diff --git a/.changeset/cli-project-commands.md b/.changeset/cli-project-commands.md index 3d8a614e58..b67ce1d762 100644 --- a/.changeset/cli-project-commands.md +++ b/.changeset/cli-project-commands.md @@ -8,7 +8,7 @@ Add commands for workspaces and projects: e2b workspace list # workspaces you can access e2b project list [--workspace ] # projects, the current one marked with * e2b project use # set the project commands run in -e2b project info # the current project and why it is current +e2b project info # the current project ``` A project can be given by its ID, `prj_` ID, slug or name. Commands run in the project from `E2B_PROJECT_ID`, then the one chosen with `e2b project use` (or `e2b auth configure`, which picks it interactively). With `E2B_API_KEY` set, the API key determines the project. diff --git a/packages/cli/src/api.ts b/packages/cli/src/api.ts index 8703258a97..c179d7ed34 100644 --- a/packages/cli/src/api.ts +++ b/packages/cli/src/api.ts @@ -43,23 +43,12 @@ export type Auth = let me: Promise | undefined -/** Where the current project comes from; shown as `selected_by` in JSON. */ -export enum ProjectSource { - Env = 'E2B_PROJECT_ID', - Selected = 'selected', -} - /** - * The project commands run in and where that choice comes from, or - * `undefined` when signed out without `E2B_PROJECT_ID`. + * The project commands run in: `E2B_PROJECT_ID`, else the one selected with + * `e2b project use`; `undefined` when signed out without `E2B_PROJECT_ID`. */ -export function currentProject(): - { ref: string; source: ProjectSource } | undefined { - if (projectId) return { ref: projectId, source: ProjectSource.Env } - const config = getUserConfig() - return config - ? { ref: config.projectId, source: ProjectSource.Selected } - : undefined +export function currentProject(): string | undefined { + return projectId || getUserConfig()?.projectId } /** The logged-in user's workspaces and projects, fetched once per process. */ diff --git a/packages/cli/src/commands/project/index.ts b/packages/cli/src/commands/project/index.ts index f928aaaec6..4b067745d3 100644 --- a/packages/cli/src/commands/project/index.ts +++ b/packages/cli/src/commands/project/index.ts @@ -1,6 +1,6 @@ import * as commander from '@commander-js/extra-typings' -import { currentProject, ProjectSource, requireMe } from 'src/api' +import { currentProject, projectId, requireMe } from 'src/api' import { Format, parseFormat } from 'src/options' import { asBold, asFormattedError } from 'src/utils/format' import { renderTable } from 'src/utils/table' @@ -42,7 +42,7 @@ function findCurrent(me: Me): ProjectEntry | undefined { const current = currentProject() if (!current) return undefined try { - return findProject(me, current.ref) + return findProject(me, current) } catch { return undefined } @@ -147,8 +147,7 @@ const useCommand = new commander.Command('use') `Now using project ${asBold(project.name)} (${project.public_id}) in workspace ${asBold(workspace.name)}.` ) - const override = currentProject() - if (override?.source === ProjectSource.Env) { + if (projectId) { console.warn( `${asBold('E2B_PROJECT_ID')} is set and takes precedence over the selected project.` ) @@ -172,7 +171,7 @@ const infoCommand = new commander.Command('info') if (!current) { fail(`No project selected. Run ${asBold('e2b project use ')}.`) } - const { project, workspace } = findProject(await requireMe(), current.ref) + const { project, workspace } = findProject(await requireMe(), current) if (opts.format === Format.JSON) { console.log( @@ -180,7 +179,6 @@ const infoCommand = new commander.Command('info') { ...project, workspace_name: workspace.name, - selected_by: current.source, }, null, 2 @@ -199,12 +197,6 @@ const infoCommand = new commander.Command('info') ['Control plane', project.control_plane_domain], ['Status', projectStatus(project)], ['Your access', project.capabilities.join(', ') || 'none'], - [ - 'Selected by', - current.source === ProjectSource.Selected - ? 'e2b project use' - : current.source, - ], ] const width = Math.max(...rows.map(([label]) => label.length)) + 2 console.log(`\nProject info for ${asBold(project.name)}:`) diff --git a/packages/cli/tests/commands/project.test.ts b/packages/cli/tests/commands/project.test.ts index 3ebeb8f3ed..d7ad69ed08 100644 --- a/packages/cli/tests/commands/project.test.ts +++ b/packages/cli/tests/commands/project.test.ts @@ -52,7 +52,7 @@ const me: Me = { const mocks = vi.hoisted(() => ({ updateUserConfig: vi.fn(), - current: undefined as { ref: string; source: string } | undefined, + current: undefined as string | undefined, })) vi.mock( @@ -104,7 +104,7 @@ async function run(args: string[]) { beforeEach(() => { vi.resetModules() vi.clearAllMocks() - mocks.current = { ref: 'mine', source: 'selected' } + mocks.current = 'mine' }) afterEach(() => { @@ -178,14 +178,13 @@ describe('e2b project use', () => { }) describe('e2b project info', () => { - test('shows the current project and why it is current', async () => { - mocks.current = { ref: 'prj_shared', source: 'E2B_PROJECT_ID' } + test('shows the current project', async () => { + mocks.current = 'prj_shared' const { out } = await run(['project', 'info', '-f', 'json']) expect(JSON.parse(out)).toMatchObject({ id: 'shared', control_plane_domain: 'shared.e2b.dev', workspace_name: 'Team', - selected_by: 'E2B_PROJECT_ID', }) }) From 1e09c964e23c8de7e4e22a7db0bf98620ca1c3b7 Mon Sep 17 00:00:00 2001 From: Mish Ushakov <10400064+mishushakov@users.noreply.github.com> Date: Thu, 12 Nov 2026 10:36:24 +0100 Subject: [PATCH 5/6] feat(cli): let `project use` pick a project, scope it with --workspace `e2b project use` without a project asks for the workspace and then the project, like login and `auth configure`; `--workspace` offers only that workspace's projects, or scopes a given name to it. `project list --workspace` shares the lookup (findWorkspace). Co-Authored-By: Claude Fable 5.1 --- .changeset/cli-project-commands.md | 4 +- packages/cli/src/commands/project/index.ts | 67 ++++++++++++--------- packages/cli/src/workspaces.ts | 22 +++++++ packages/cli/tests/commands/project.test.ts | 63 +++++++++++++++++++ 4 files changed, 124 insertions(+), 32 deletions(-) diff --git a/.changeset/cli-project-commands.md b/.changeset/cli-project-commands.md index b67ce1d762..c53afd9500 100644 --- a/.changeset/cli-project-commands.md +++ b/.changeset/cli-project-commands.md @@ -7,8 +7,8 @@ Add commands for workspaces and projects: ```sh e2b workspace list # workspaces you can access e2b project list [--workspace ] # projects, the current one marked with * -e2b project use # set the project commands run in +e2b project use [project] [-w ] # set the project commands run in, or pick one e2b project info # the current project ``` -A project can be given by its ID, `prj_` ID, slug or name. Commands run in the project from `E2B_PROJECT_ID`, then the one chosen with `e2b project use` (or `e2b auth configure`, which picks it interactively). With `E2B_API_KEY` set, the API key determines the project. +A project can be given by its ID, `prj_` ID, slug or name. `project use` without a project asks for the workspace and then the project; `--workspace` skips the first step and scopes a name to that workspace. Commands run in the project from `E2B_PROJECT_ID`, then the one chosen with `e2b project use` (or `e2b auth configure`, which picks it interactively). With `E2B_API_KEY` set, the API key determines the project. diff --git a/packages/cli/src/commands/project/index.ts b/packages/cli/src/commands/project/index.ts index 4b067745d3..824bae7632 100644 --- a/packages/cli/src/commands/project/index.ts +++ b/packages/cli/src/commands/project/index.ts @@ -9,12 +9,20 @@ import { allProjects, blockedWarning, findProject, + findWorkspace, Me, + pickProject, Project, ProjectEntry, ProjectStatus, } from 'src/workspaces' +const workspaceOption = (purpose: string) => + new commander.Option( + '-w, --workspace ', + `${purpose}: its ID, wrk_ ID or name` + ) + const formatOption = new commander.Option( '-f, --format ', 'output format, eg. json, pretty' @@ -51,10 +59,7 @@ function findCurrent(me: Me): ProjectEntry | undefined { const listCommand = new commander.Command('list') .description('list the projects you can access') .alias('ls') - .option( - '-w, --workspace ', - 'only list projects in this workspace: its ID, wrk_ ID or name' - ) + .addOption(workspaceOption('only list projects in this workspace')) .addOption(formatOption) .action(async (opts) => { try { @@ -62,26 +67,7 @@ const listCommand = new commander.Command('list') let entries = allProjects(me) if (opts.workspace) { - const ref = opts.workspace - const exact = me.workspaces.find( - (w) => w.id === ref || w.public_id === ref - ) - const byName = me.workspaces.filter( - (w) => w.name.toLowerCase() === ref.toLowerCase() - ) - if (!exact && byName.length > 1) { - fail( - `More than one workspace is named "${ref}": ${byName - .map((w) => w.public_id) - .join(', ')}. Pass the workspace ID instead.` - ) - } - const workspace = exact ?? byName[0] - if (!workspace) { - fail( - `Workspace "${ref}" not found. Run ${asBold('e2b workspace list')} to see your workspaces.` - ) - } + const workspace = findWorkspace(me, opts.workspace) entries = entries.filter((e) => e.workspace.id === workspace.id) } const currentId = findCurrent(me)?.project.id @@ -122,12 +108,33 @@ const listCommand = new commander.Command('list') }) const useCommand = new commander.Command('use') - .description('set the project commands run in by default') - .argument('', 'the project: its ID, prj_ ID, slug or name') - .action(async (ref) => { + .description( + 'set the project commands run in by default; without a project, pick one' + ) + .argument('[project]', 'the project: its ID, prj_ ID, slug or name') + .addOption(workspaceOption('the workspace to pick from or look in')) + .action(async (ref, opts) => { try { - const entry = findProject(await requireMe(), ref) - const { project, workspace } = entry + const me = await requireMe() + const workspace = opts.workspace + ? findWorkspace(me, opts.workspace) + : undefined + let entry: ProjectEntry + if (ref) { + entry = findProject(me, ref, workspace) + } else { + if (!process.stdin.isTTY) { + fail( + `Pass the project to use: ${asBold('e2b project use ')}.` + ) + } + entry = await pickProject(me, { + message: 'Select project', + selectedId: findCurrent(me)?.project.id, + workspace, + }) + } + const { project, workspace: owner } = entry if (project.status !== ProjectStatus.Active) { fail( `Project ${project.name} is ${project.status} and can't be used. Pick an active project from ${asBold( @@ -144,7 +151,7 @@ const useCommand = new commander.Command('use') projectDomain: project.control_plane_domain, }) console.log( - `Now using project ${asBold(project.name)} (${project.public_id}) in workspace ${asBold(workspace.name)}.` + `Now using project ${asBold(project.name)} (${project.public_id}) in workspace ${asBold(owner.name)}.` ) if (projectId) { diff --git a/packages/cli/src/workspaces.ts b/packages/cli/src/workspaces.ts index 2e5f765cca..d9d751caef 100644 --- a/packages/cli/src/workspaces.ts +++ b/packages/cli/src/workspaces.ts @@ -141,6 +141,28 @@ export function personalProject(me: Me): ProjectEntry | undefined { ) } +/** Find a workspace by ID (UUID or `wrk_` public ID) or unambiguous name. */ +export function findWorkspace(me: Me, ref: string): Workspace { + const exact = me.workspaces.find((w) => w.id === ref || w.public_id === ref) + if (exact) return exact + const byName = me.workspaces.filter( + (w) => w.name.toLowerCase() === ref.toLowerCase() + ) + if (byName.length === 1) return byName[0] + if (byName.length > 1) { + throw new Error( + `More than one workspace is named "${ref}": ${byName + .map((w) => w.public_id) + .join(', ')}. Pass the workspace ID instead.` + ) + } + throw new Error( + `Workspace "${ref}" not found. Run ${asBold( + 'e2b workspace list' + )} to see your workspaces.` + ) +} + /** * Find a project by ID (UUID or `prj_` public ID), slug or name, among the * projects of `workspace` when given. Names are matched case-insensitively diff --git a/packages/cli/tests/commands/project.test.ts b/packages/cli/tests/commands/project.test.ts index d7ad69ed08..6a085fd06b 100644 --- a/packages/cli/tests/commands/project.test.ts +++ b/packages/cli/tests/commands/project.test.ts @@ -53,8 +53,11 @@ const me: Me = { const mocks = vi.hoisted(() => ({ updateUserConfig: vi.fn(), current: undefined as string | undefined, + select: vi.fn(), })) +vi.mock('@inquirer/prompts', () => ({ select: mocks.select })) + vi.mock( '../../src/api', async (importOriginal: () => Promise) => ({ @@ -175,6 +178,66 @@ describe('e2b project use', () => { expect(err).toContain("is provisioning and can't be used") expect(mocks.updateUserConfig).not.toHaveBeenCalled() }) + + test('looks a name up in --workspace only', async () => { + const { err } = await run([ + 'project', + 'use', + 'Project mine', + '--workspace', + 'Team', + ]) + expect(err).toContain('not found among the projects you can access in workspace Team') + expect(mocks.updateUserConfig).not.toHaveBeenCalled() + }) + + describe('without a project', () => { + afterEach(() => { + Object.defineProperty(process.stdin, 'isTTY', { + value: undefined, + configurable: true, + }) + }) + + test('asks for the workspace, then the project', async () => { + Object.defineProperty(process.stdin, 'isTTY', { + value: true, + configurable: true, + }) + type Prompt = { message: string; choices: { value: unknown }[] } + // Team has one active project, so only the workspace is asked for. + mocks.select.mockImplementationOnce( + async (p: Prompt) => p.choices[1].value + ) + + const { out } = await run(['project', 'use']) + + expect( + mocks.select.mock.calls.map(([p]: [Prompt]) => p.message) + ).toEqual(['Select workspace']) + expect(mocks.updateUserConfig).toHaveBeenCalledWith( + expect.objectContaining({ projectId: 'shared' }) + ) + expect(out).toContain('in workspace Team') + }) + + test('only offers --workspace', async () => { + Object.defineProperty(process.stdin, 'isTTY', { + value: true, + configurable: true, + }) + // Team has one active project, so nothing is asked. + const { out } = await run(['project', 'use', '--workspace', 'wrk_team']) + expect(mocks.select).not.toHaveBeenCalled() + expect(out).toContain('Now using project Project shared') + }) + + test('needs a terminal', async () => { + const { err } = await run(['project', 'use']) + expect(err).toContain('Pass the project to use') + expect(mocks.updateUserConfig).not.toHaveBeenCalled() + }) + }) }) describe('e2b project info', () => { From f075e5ed8e8e8b5ca4e22af9f2e7bdb3943a91cd Mon Sep 17 00:00:00 2001 From: Mish Ushakov <10400064+mishushakov@users.noreply.github.com> Date: Thu, 12 Nov 2026 10:45:25 +0100 Subject: [PATCH 6/6] test(cli): project use always prompts for the project Co-Authored-By: Claude Fable 5.1 --- .changeset/cli-project-commands.md | 2 +- packages/cli/tests/commands/project.test.ts | 21 ++++++++++++++------- 2 files changed, 15 insertions(+), 8 deletions(-) diff --git a/.changeset/cli-project-commands.md b/.changeset/cli-project-commands.md index c53afd9500..28fb616c2b 100644 --- a/.changeset/cli-project-commands.md +++ b/.changeset/cli-project-commands.md @@ -11,4 +11,4 @@ e2b project use [project] [-w ] # set the project commands run in, or pick e2b project info # the current project ``` -A project can be given by its ID, `prj_` ID, slug or name. `project use` without a project asks for the workspace and then the project; `--workspace` skips the first step and scopes a name to that workspace. Commands run in the project from `E2B_PROJECT_ID`, then the one chosen with `e2b project use` (or `e2b auth configure`, which picks it interactively). With `E2B_API_KEY` set, the API key determines the project. +A project can be given by its ID, `prj_` ID, slug or name. `project use` without a project asks for the workspace and then the project; `--workspace` picks from that workspace only and scopes a name to it. Commands run in the project from `E2B_PROJECT_ID`, then the one chosen with `e2b project use` (or `e2b auth configure`, which picks it interactively). With `E2B_API_KEY` set, the API key determines the project. diff --git a/packages/cli/tests/commands/project.test.ts b/packages/cli/tests/commands/project.test.ts index 6a085fd06b..d7580e5485 100644 --- a/packages/cli/tests/commands/project.test.ts +++ b/packages/cli/tests/commands/project.test.ts @@ -205,16 +205,17 @@ describe('e2b project use', () => { configurable: true, }) type Prompt = { message: string; choices: { value: unknown }[] } - // Team has one active project, so only the workspace is asked for. - mocks.select.mockImplementationOnce( - async (p: Prompt) => p.choices[1].value - ) + mocks.select + .mockImplementationOnce(async (p: Prompt) => p.choices[1].value) + .mockImplementationOnce(async (p: Prompt) => p.choices[0].value) const { out } = await run(['project', 'use']) expect( mocks.select.mock.calls.map(([p]: [Prompt]) => p.message) - ).toEqual(['Select workspace']) + ).toEqual(['Select workspace', 'Select project']) + // Team's only active project is still offered, not selected outright. + expect(mocks.select.mock.calls[1][0].choices).toHaveLength(1) expect(mocks.updateUserConfig).toHaveBeenCalledWith( expect.objectContaining({ projectId: 'shared' }) ) @@ -226,9 +227,15 @@ describe('e2b project use', () => { value: true, configurable: true, }) - // Team has one active project, so nothing is asked. + type Prompt = { message: string; choices: { value: unknown }[] } + mocks.select.mockImplementationOnce( + async (p: Prompt) => p.choices[0].value + ) const { out } = await run(['project', 'use', '--workspace', 'wrk_team']) - expect(mocks.select).not.toHaveBeenCalled() + // No workspace prompt; the project prompt lists Team's projects only. + expect( + mocks.select.mock.calls.map(([p]: [Prompt]) => p.message) + ).toEqual(['Select project']) expect(out).toContain('Now using project Project shared') })