From 513121331054517d5a355ac3590ea3df069c56fb Mon Sep 17 00:00:00 2001 From: Mish Ushakov <10400064+mishushakov@users.noreply.github.com> Date: Fri, 6 Nov 2026 19:11:03 +0100 Subject: [PATCH 1/2] feat(cli): show the project ID in sandbox, snapshot and template lists None of the list responses carry a project: each list is scoped to the project the request went to (X-Team-ID). The lists now show that project's prj_ ID as a `Project ID` column, and as `projectId` (`projectID` for templates, matching `templateID`) in JSON output. The prj_ ID is the TypeID encoding of the project UUID, computed locally (publicProjectId), so lists don't need a /v1/me call. With E2B_API_KEY the key decides the project and the CLI can't tell which, so the column and field are left out. Co-Authored-By: Claude Opus 5.5 (1M context) --- .changeset/cli-list-project-ids.md | 5 + packages/cli/src/api.ts | 19 ++- packages/cli/src/commands/sandbox/list.ts | 42 +++-- packages/cli/src/commands/sandbox/snapshot.ts | 19 ++- packages/cli/src/commands/template/list.ts | 39 +++-- packages/cli/src/workspaces.ts | 19 +++ .../tests/commands/list_project_id.test.ts | 152 ++++++++++++++++++ packages/cli/tests/workspaces.test.ts | 16 ++ 8 files changed, 276 insertions(+), 35 deletions(-) create mode 100644 .changeset/cli-list-project-ids.md create mode 100644 packages/cli/tests/commands/list_project_id.test.ts diff --git a/.changeset/cli-list-project-ids.md b/.changeset/cli-list-project-ids.md new file mode 100644 index 0000000000..f4c6eca71d --- /dev/null +++ b/.changeset/cli-list-project-ids.md @@ -0,0 +1,5 @@ +--- +'@e2b/cli': patch +--- + +`e2b sandbox list`, `e2b sandbox snapshot list` and `e2b template list` show the `prj_` ID of the project they list, as a `Project ID` column and as `projectId` (`projectID` for templates) in `--format json`. With `E2B_API_KEY`, where the key decides the project, it is left out. diff --git a/packages/cli/src/api.ts b/packages/cli/src/api.ts index 0ab565740d..304b0ededd 100644 --- a/packages/cli/src/api.ts +++ b/packages/cli/src/api.ts @@ -5,7 +5,13 @@ import * as packageJSON from '../package.json' import { getUserConfig, UserConfig } from './user' import { asBold, asPrimary } from './utils/format' import { ensureValidAccessToken, updateUserConfig } from './utils/token-refresh' -import { bootstrapUser, fetchMe, findProject, Me } from './workspaces' +import { + bootstrapUser, + fetchMe, + findProject, + Me, + publicProjectId, +} from './workspaces' // Must run before any ConnectionConfig is constructed (including the // module-level one below) — configs read the integration at construction time. @@ -155,6 +161,17 @@ export async function ensureAuth(cliProjectId?: string): Promise { } } +/** + * The `prj_` ID of the project `auth` sends requests to, or `undefined` with + * `E2B_API_KEY`, where the key selects the project and the CLI can't tell + * which one it is. + */ +export function projectIdOf(auth: Auth): string | undefined { + return 'apiHeaders' in auth + ? publicProjectId(auth.apiHeaders['X-Team-ID']) + : undefined +} + /** An E2B API client authenticated with {@link ensureAuth}. */ export async function apiClient(cliProjectId?: string) { return new e2b.ApiClient( diff --git a/packages/cli/src/commands/sandbox/list.ts b/packages/cli/src/commands/sandbox/list.ts index c26efd149e..84d8d2487a 100644 --- a/packages/cli/src/commands/sandbox/list.ts +++ b/packages/cli/src/commands/sandbox/list.ts @@ -3,7 +3,7 @@ import { Sandbox, SandboxInfo, SandboxListOrder, SandboxState } from 'e2b' import { z } from 'zod' -import { ensureAuth } from 'src/api' +import { Auth, ensureAuth, projectIdOf } from 'src/api' import { Format, parseFormat } from 'src/options' import { renderTable } from 'src/utils/table' import { integer, zodParser } from 'src/validation' @@ -69,7 +69,9 @@ export const listCommand = new commander.Command('list') const state = options.state || ['running'] const limit = options.limit === 0 ? undefined : (options.limit ?? DEFAULT_LIMIT) - const { sandboxes, hasMore } = await listSandboxes({ + const auth = await ensureAuth() + const projectId = projectIdOf(auth) + const { sandboxes, hasMore } = await listSandboxes(auth, { limit, state, metadata: options.metadata, @@ -79,7 +81,7 @@ export const listCommand = new commander.Command('list') }) if (options.format === Format.PRETTY) { - renderSandboxTable(sandboxes, options.order) + renderSandboxTable(sandboxes, options.order, projectId) if (hasMore) { console.log( `Showing first ${limit} sandboxes. Use --limit to change.` @@ -87,7 +89,13 @@ export const listCommand = new commander.Command('list') } } else { console.log( - JSON.stringify(sortSandboxes(sandboxes, options.order), null, 2) + JSON.stringify( + sortSandboxes(sandboxes, options.order).map((sandbox) => + projectId ? { ...sandbox, projectId } : sandbox + ), + null, + 2 + ) ) } } catch (err: any) { @@ -126,7 +134,8 @@ export function buildTableRows( function renderSandboxTable( sandboxes: SandboxInfo[], - order?: SandboxListOrder + order?: SandboxListOrder, + projectId?: string ) { if (!sandboxes?.length) { console.log('No sandboxes found') @@ -144,6 +153,8 @@ function renderSandboxTable( { header: 'RAM MiB', value: (row) => String(row.memoryMB) }, { header: 'Envd version', value: (row) => row.envdVersion }, { header: 'Metadata', value: (row) => row.metadata }, + // Omitted with E2B_API_KEY, where the CLI doesn't know the key's project. + ...(projectId ? [{ header: 'Project ID', value: () => projectId }] : []), ]) } @@ -161,16 +172,17 @@ type ListSandboxesResult = { hasMore: boolean } -async function listSandboxes({ - limit, - state, - metadata, - template, - startedAfter, - order, -}: ListSandboxesOptions = {}): Promise { - const auth = await ensureAuth() - +async function listSandboxes( + auth: Auth, + { + limit, + state, + metadata, + template, + startedAfter, + order, + }: ListSandboxesOptions = {} +): Promise { let pageLimit = limit if (!limit || limit > PAGE_LIMIT) { pageLimit = PAGE_LIMIT diff --git a/packages/cli/src/commands/sandbox/snapshot.ts b/packages/cli/src/commands/sandbox/snapshot.ts index 7a2b24e19d..d6de229b11 100644 --- a/packages/cli/src/commands/sandbox/snapshot.ts +++ b/packages/cli/src/commands/sandbox/snapshot.ts @@ -1,7 +1,7 @@ import * as commander from '@commander-js/extra-typings' import { NotFoundError, Sandbox, SnapshotInfo } from 'e2b' -import { ensureAuth } from 'src/api' +import { ensureAuth, projectIdOf } from 'src/api' import { Format, parseFormat } from 'src/options' import { integer, zodParser } from 'src/validation' import { asBold } from 'src/utils/format' @@ -67,6 +67,7 @@ const listSnapshotsCommand = new commander.Command('list') .action(async (sandboxID, options) => { try { const auth = await ensureAuth() + const projectId = projectIdOf(auth) const limit = options.limit === 0 ? undefined : (options.limit ?? DEFAULT_LIMIT) @@ -93,14 +94,22 @@ const listSnapshotsCommand = new commander.Command('list') paginator.hasNext || (limit ? allSnapshots.length > limit : false) if (options.format === Format.PRETTY) { - renderSnapshotTable(snapshots) + renderSnapshotTable(snapshots, projectId) if (hasMore) { console.log( `Showing first ${limit} snapshots. Use --limit to change.` ) } } else { - console.log(JSON.stringify(snapshots, null, 2)) + console.log( + JSON.stringify( + snapshots.map((snapshot) => + projectId ? { ...snapshot, projectId } : snapshot + ), + null, + 2 + ) + ) } } catch (err: any) { console.error(err) @@ -153,7 +162,7 @@ export const snapshotCommand = new commander.Command('snapshot') .addCommand(listSnapshotsCommand) .addCommand(deleteSnapshotCommand) -function renderSnapshotTable(snapshots: SnapshotInfo[]) { +function renderSnapshotTable(snapshots: SnapshotInfo[], projectId?: string) { if (!snapshots?.length) { console.log('No snapshots found') return @@ -165,6 +174,8 @@ function renderSnapshotTable(snapshots: SnapshotInfo[]) { header: 'Names', value: (snapshot) => snapshot.names.map(stripControlChars).join(', '), }, + // Omitted with E2B_API_KEY, where the CLI doesn't know the key's project. + ...(projectId ? [{ header: 'Project ID', value: () => projectId }] : []), ]) } diff --git a/packages/cli/src/commands/template/list.ts b/packages/cli/src/commands/template/list.ts index a79bf8fb67..05b6c510e1 100644 --- a/packages/cli/src/commands/template/list.ts +++ b/packages/cli/src/commands/template/list.ts @@ -4,7 +4,7 @@ import * as e2b from 'e2b' import { listAliases } from '../../utils/format' import { renderTable } from 'src/utils/table' import { sortTemplatesAliases } from 'src/utils/templateSort' -import { apiClient } from 'src/api' +import { Auth, ensureAuth, projectIdOf } from 'src/api' import { deprecatedTeamOption, Format, @@ -41,8 +41,9 @@ export const listCommand = new commander.Command('list') const limit = opts.limit === 0 ? undefined : (opts.limit ?? defaultLimit) process.stdout.write('\n') - const { templates, hasMore } = await listSandboxTemplates({ - projectId: projectIdFromOptions(opts), + const auth = await ensureAuth(projectIdFromOptions(opts)) + const projectId = projectIdOf(auth) + const { templates, hasMore } = await listSandboxTemplates(auth, { limit, }) @@ -51,14 +52,22 @@ export const listCommand = new commander.Command('list') } if (opts.format === Format.PRETTY) { - renderTemplateTable(templates) + renderTemplateTable(templates, projectId) if (hasMore) { console.log( `Showing first ${limit} templates. Use --limit to change.` ) } } else { - console.log(JSON.stringify(templates, null, 2)) + console.log( + JSON.stringify( + templates.map((template) => + projectId ? { ...template, projectID: projectId } : template + ), + null, + 2 + ) + ) } } catch (err: any) { console.error(err) @@ -67,7 +76,8 @@ export const listCommand = new commander.Command('list') }) function renderTemplateTable( - templates: e2b.components['schemas']['Template'][] + templates: e2b.components['schemas']['Template'][], + projectId?: string ) { if (!templates?.length) { console.log('No templates found.') @@ -99,6 +109,8 @@ function renderTemplateTable( value: (template) => String(template.diskSizeMB), }, { header: 'Envd version', value: (template) => template.envdVersion }, + // Omitted with E2B_API_KEY, where the CLI doesn't know the key's project. + ...(projectId ? [{ header: 'Project ID', value: () => projectId }] : []), ]) } @@ -107,22 +119,19 @@ type ListSandboxTemplatesResult = { hasMore: boolean } -async function listSandboxTemplates({ - projectId, - limit, -}: { - projectId?: string - limit?: number -}): Promise { +async function listSandboxTemplates( + auth: Auth, + { limit }: { limit?: number } +): Promise { let pageLimit = limit if (!limit || limit > PAGE_LIMIT) { pageLimit = PAGE_LIMIT } - // The project (an ID, slug or name, or the selected one) is resolved by + // The project (an ID, slug or name, or the selected one) was resolved by // ensureAuth: with an access token X-Team-ID selects it, with an API key // the key does. The teamID query param accepts IDs only, so it's not sent. - const client = await apiClient(projectId) + const client = new e2b.ApiClient(new e2b.ConnectionConfig(auth)) const templates: e2b.components['schemas']['Template'][] = [] let nextToken: string | undefined diff --git a/packages/cli/src/workspaces.ts b/packages/cli/src/workspaces.ts index d6bfe297ba..c517b28c23 100644 --- a/packages/cli/src/workspaces.ts +++ b/packages/cli/src/workspaces.ts @@ -306,3 +306,22 @@ export async function pickProject( default: current?.workspace.id === chosen.id ? current : undefined, }) } + +const TYPEID_ALPHABET = '0123456789abcdefghjkmnpqrstvwxyz' + +/** + * The `prj_` public ID of a project UUID: the TypeID encoding workspace-api + * uses for `public_id`, computed locally so lists don't need `/v1/me`. + * Anything that isn't a UUID is returned as is. + */ +export function publicProjectId(id: string): string { + const hex = id.replace(/-/g, '') + if (!/^[0-9a-f]{32}$/i.test(hex)) return id + + const value = BigInt(`0x${hex}`) + let suffix = '' + for (let i = 25; i >= 0; i--) { + suffix += TYPEID_ALPHABET[Number((value >> BigInt(5 * i)) & 31n)] + } + return `prj_${suffix}` +} diff --git a/packages/cli/tests/commands/list_project_id.test.ts b/packages/cli/tests/commands/list_project_id.test.ts new file mode 100644 index 0000000000..d36de9ac53 --- /dev/null +++ b/packages/cli/tests/commands/list_project_id.test.ts @@ -0,0 +1,152 @@ +import type { CommandUnknownOpts } from '@commander-js/extra-typings' +import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest' + +const PROJECT_UUID = '4151943a-a079-4e90-a768-5329467f5c67' +const PROJECT_ID = 'prj_21a6a3n83s9t8aet2k5537yq37' + +const mocks = vi.hoisted(() => { + const paginator = (items: T[]) => ({ + hasNext: true, + async nextItems() { + this.hasNext = false + return items + }, + }) + return { + paginator, + auth: {} as Record, + templatesGet: vi.fn(), + } +}) + +vi.mock('e2b', () => ({ + Sandbox: { + list: () => + mocks.paginator([ + { + sandboxId: 'sbx', + templateId: 'tpl', + metadata: {}, + startedAt: new Date('2026-11-06T10:00:00Z'), + endAt: new Date('2026-11-06T11:00:00Z'), + state: 'running', + cpuCount: 2, + memoryMB: 512, + envdVersion: '0.2.0', + }, + ]), + listSnapshots: () => + mocks.paginator([{ snapshotId: 'snap', names: ['my-snap'] }]), + }, + ConnectionConfig: class { + static setIntegration() {} + }, + ApiClient: class { + api = { GET: mocks.templatesGet } + }, +})) + +vi.mock( + '../../src/api', + async (importOriginal: () => Promise) => ({ + ...(await importOriginal()), + ensureAuth: async () => mocks.auth, + }) +) + +const bearer = { + apiHeaders: { Authorization: 'Bearer token', 'X-Team-ID': PROJECT_UUID }, + domain: 'e2b-foxtrot.dev', +} + +async function run(load: () => Promise, args: string[]) { + const out: string[] = [] + vi.spyOn(console, 'log').mockImplementation((...a: unknown[]) => { + out.push(a.join(' ')) + }) + vi.spyOn(process.stdout, 'write').mockImplementation( + (chunk: string | Uint8Array) => { + out.push(String(chunk)) + return true + } + ) + vi.spyOn(process, 'exit').mockImplementation(((code?: number) => { + throw new Error(`exit ${code}`) + }) as never) + await (await load()).parseAsync(args, { from: 'user' }) + return out.join('\n') +} + +const sandboxList = async () => + (await import('../../src/commands/sandbox/list')).listCommand +const snapshotList = async () => + (await import('../../src/commands/sandbox/snapshot')).snapshotCommand +const templateList = async () => + (await import('../../src/commands/template/list')).listCommand + +beforeEach(() => { + vi.resetModules() + mocks.templatesGet.mockResolvedValue({ + response: { ok: true, headers: new Headers() }, + data: [ + { + templateID: 'tpl', + aliases: ['my-tpl'], + public: false, + cpuCount: 2, + memoryMB: 512, + createdAt: '2026-11-06T10:00:00Z', + diskSizeMB: 1024, + envdVersion: '0.2.0', + }, + ], + }) +}) + +afterEach(() => { + vi.restoreAllMocks() +}) + +describe('lists with the access token', () => { + beforeEach(() => { + mocks.auth = bearer + }) + + test('sandbox list shows the project ID', async () => { + const pretty = await run(sandboxList, []) + expect(pretty).toContain('PROJECT ID') + expect(pretty).toContain(PROJECT_ID) + + const json = JSON.parse(await run(sandboxList, ['-f', 'json'])) + expect(json[0]).toMatchObject({ sandboxId: 'sbx', projectId: PROJECT_ID }) + }) + + test('snapshot list shows the project ID', async () => { + expect(await run(snapshotList, ['list'])).toContain(PROJECT_ID) + + const json = JSON.parse(await run(snapshotList, ['list', '-f', 'json'])) + expect(json[0]).toMatchObject({ snapshotId: 'snap', projectId: PROJECT_ID }) + }) + + test('template list shows the project ID', async () => { + expect(await run(templateList, [])).toContain(PROJECT_ID) + + const json = JSON.parse((await run(templateList, ['-f', 'json'])).trim()) + expect(json[0]).toMatchObject({ templateID: 'tpl', projectID: PROJECT_ID }) + }) +}) + +describe('lists with E2B_API_KEY', () => { + beforeEach(() => { + mocks.auth = { apiKey: 'e2b_key' } + }) + + test('leave the project ID out, since the key decides the project', async () => { + expect(await run(sandboxList, [])).not.toContain('PROJECT ID') + expect(await run(snapshotList, ['list'])).not.toContain('PROJECT ID') + expect(await run(templateList, [])).not.toContain('PROJECT ID') + + const json = JSON.parse(await run(sandboxList, ['-f', 'json'])) + expect(json[0]).not.toHaveProperty('projectId') + }) +}) diff --git a/packages/cli/tests/workspaces.test.ts b/packages/cli/tests/workspaces.test.ts index bca364a974..077be0a13a 100644 --- a/packages/cli/tests/workspaces.test.ts +++ b/packages/cli/tests/workspaces.test.ts @@ -12,6 +12,7 @@ import { personalProject, pickProject, Project, + publicProjectId, Workspace, workspaceApiUrl, } from '../src/workspaces' @@ -344,3 +345,18 @@ describe('blockedWarning', () => { expect(blockedWarning({ ...entry, project: project('ok') })).toBeUndefined() }) }) + +describe('publicProjectId', () => { + test('encodes a project UUID as its prj_ TypeID', () => { + expect(publicProjectId('4151943a-a079-4e90-a768-5329467f5c67')).toBe( + 'prj_21a6a3n83s9t8aet2k5537yq37' + ) + }) + + test('returns anything else unchanged', () => { + expect(publicProjectId('prj_21a6a3n83s9t8aet2k5537yq37')).toBe( + 'prj_21a6a3n83s9t8aet2k5537yq37' + ) + expect(publicProjectId('team-id')).toBe('team-id') + }) +}) From 35386600514b7644cd9cb18298dd2b9429b29c52 Mon Sep 17 00:00:00 2001 From: Mish Ushakov <10400064+mishushakov@users.noreply.github.com> Date: Tue, 10 Nov 2026 14:52:34 +0100 Subject: [PATCH 2/2] fix(cli): spell the JSON project key projectID in every list sandbox list and snapshot list used projectId while template list used projectID; one spelling means one jq path. Co-Authored-By: Claude Fable 5.1 --- .changeset/cli-list-project-ids.md | 2 +- packages/cli/src/commands/sandbox/list.ts | 2 +- packages/cli/src/commands/sandbox/snapshot.ts | 2 +- packages/cli/tests/commands/list_project_id.test.ts | 6 +++--- 4 files changed, 6 insertions(+), 6 deletions(-) diff --git a/.changeset/cli-list-project-ids.md b/.changeset/cli-list-project-ids.md index f4c6eca71d..f12a12ce53 100644 --- a/.changeset/cli-list-project-ids.md +++ b/.changeset/cli-list-project-ids.md @@ -2,4 +2,4 @@ '@e2b/cli': patch --- -`e2b sandbox list`, `e2b sandbox snapshot list` and `e2b template list` show the `prj_` ID of the project they list, as a `Project ID` column and as `projectId` (`projectID` for templates) in `--format json`. With `E2B_API_KEY`, where the key decides the project, it is left out. +`e2b sandbox list`, `e2b sandbox snapshot list` and `e2b template list` show the `prj_` ID of the project they list, as a `Project ID` column and as `projectID` in `--format json`. With `E2B_API_KEY`, where the key decides the project, it is left out. diff --git a/packages/cli/src/commands/sandbox/list.ts b/packages/cli/src/commands/sandbox/list.ts index 84d8d2487a..a5d884c68e 100644 --- a/packages/cli/src/commands/sandbox/list.ts +++ b/packages/cli/src/commands/sandbox/list.ts @@ -91,7 +91,7 @@ export const listCommand = new commander.Command('list') console.log( JSON.stringify( sortSandboxes(sandboxes, options.order).map((sandbox) => - projectId ? { ...sandbox, projectId } : sandbox + projectId ? { ...sandbox, projectID: projectId } : sandbox ), null, 2 diff --git a/packages/cli/src/commands/sandbox/snapshot.ts b/packages/cli/src/commands/sandbox/snapshot.ts index d6de229b11..92b7524239 100644 --- a/packages/cli/src/commands/sandbox/snapshot.ts +++ b/packages/cli/src/commands/sandbox/snapshot.ts @@ -104,7 +104,7 @@ const listSnapshotsCommand = new commander.Command('list') console.log( JSON.stringify( snapshots.map((snapshot) => - projectId ? { ...snapshot, projectId } : snapshot + projectId ? { ...snapshot, projectID: projectId } : snapshot ), null, 2 diff --git a/packages/cli/tests/commands/list_project_id.test.ts b/packages/cli/tests/commands/list_project_id.test.ts index d36de9ac53..ec359d50d1 100644 --- a/packages/cli/tests/commands/list_project_id.test.ts +++ b/packages/cli/tests/commands/list_project_id.test.ts @@ -118,14 +118,14 @@ describe('lists with the access token', () => { expect(pretty).toContain(PROJECT_ID) const json = JSON.parse(await run(sandboxList, ['-f', 'json'])) - expect(json[0]).toMatchObject({ sandboxId: 'sbx', projectId: PROJECT_ID }) + expect(json[0]).toMatchObject({ sandboxId: 'sbx', projectID: PROJECT_ID }) }) test('snapshot list shows the project ID', async () => { expect(await run(snapshotList, ['list'])).toContain(PROJECT_ID) const json = JSON.parse(await run(snapshotList, ['list', '-f', 'json'])) - expect(json[0]).toMatchObject({ snapshotId: 'snap', projectId: PROJECT_ID }) + expect(json[0]).toMatchObject({ snapshotId: 'snap', projectID: PROJECT_ID }) }) test('template list shows the project ID', async () => { @@ -147,6 +147,6 @@ describe('lists with E2B_API_KEY', () => { expect(await run(templateList, [])).not.toContain('PROJECT ID') const json = JSON.parse(await run(sandboxList, ['-f', 'json'])) - expect(json[0]).not.toHaveProperty('projectId') + expect(json[0]).not.toHaveProperty('projectID') }) })