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 3a1bc91d9f..35d0545a8f 100644 --- a/packages/cli/src/api.ts +++ b/packages/cli/src/api.ts @@ -5,7 +5,7 @@ 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 { fetchMe, findProject, Me } from './workspaces' +import { 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. @@ -167,6 +167,17 @@ export async function ensureAuth(): 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() { return new e2b.ApiClient(new e2b.ConnectionConfig(await ensureAuth())) 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 67e534d31c..b128eddc46 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, resolveProjectId } from 'src/api' +import { Auth, ensureAuth, projectIdOf, resolveProjectId } from 'src/api' import { deprecatedTeamOption, Format, @@ -40,8 +40,10 @@ 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: resolveProjectId(), + const auth = await ensureAuth() + const projectId = projectIdOf(auth) + const { templates, hasMore } = await listSandboxTemplates(auth, { + teamId: resolveProjectId(), limit, }) @@ -50,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) @@ -66,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.') @@ -98,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 }] : []), ]) } @@ -106,13 +119,16 @@ type ListSandboxTemplatesResult = { hasMore: boolean } -async function listSandboxTemplates({ - projectId, - limit, -}: { - projectId?: string - limit?: number -}): Promise { +async function listSandboxTemplates( + auth: Auth, + { + teamId, + limit, + }: { + teamId?: string + limit?: number + } +): Promise { let pageLimit = limit if (!limit || limit > PAGE_LIMIT) { pageLimit = PAGE_LIMIT @@ -121,7 +137,7 @@ async function listSandboxTemplates({ // With an access token, X-Team-ID (from the same project ID) selects the // project; with an API key, the key does and the teamID query param is // ignored. - const client = await apiClient() + const client = new e2b.ApiClient(new e2b.ConnectionConfig(auth)) const templates: e2b.components['schemas']['Template'][] = [] let nextToken: string | undefined @@ -129,7 +145,7 @@ async function listSandboxTemplates({ const res = await client.api.GET('/v2/templates', { params: { // the backend API still calls the project ID "teamID" - query: { teamID: projectId, limit: pageLimit, nextToken }, + query: { teamID: teamId, limit: pageLimit, nextToken }, }, }) diff --git a/packages/cli/src/workspaces.ts b/packages/cli/src/workspaces.ts index 2e1bb9ee21..05d723283d 100644 --- a/packages/cli/src/workspaces.ts +++ b/packages/cli/src/workspaces.ts @@ -218,3 +218,22 @@ export async function pickProject( default: entries.find(({ project }) => project.id === selectedId), }) } + +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 59f25cda21..e694ba0a09 100644 --- a/packages/cli/tests/workspaces.test.ts +++ b/packages/cli/tests/workspaces.test.ts @@ -9,6 +9,7 @@ import { personalProject, pickProject, Project, + publicProjectId, Workspace, workspaceApiUrl, } from '../src/workspaces' @@ -182,3 +183,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') + }) +})