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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/cli-list-project-ids.md
Original file line number Diff line number Diff line change
@@ -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` in `--format json`. With `E2B_API_KEY`, where the key decides the project, it is left out.
19 changes: 18 additions & 1 deletion packages/cli/src/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -155,6 +161,17 @@ export async function ensureAuth(cliProjectId?: string): Promise<Auth> {
}
}

/**
* 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(
Expand Down
42 changes: 27 additions & 15 deletions packages/cli/src/commands/sandbox/list.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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,
Expand All @@ -79,15 +81,21 @@ 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.`
)
}
} else {
console.log(
JSON.stringify(sortSandboxes(sandboxes, options.order), null, 2)
JSON.stringify(
sortSandboxes(sandboxes, options.order).map((sandbox) =>
projectId ? { ...sandbox, projectID: projectId } : sandbox

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Use projectId for SDK-shaped JSON records

When access-token users run sandbox list --format json, these records otherwise preserve the SDK's camel-cased SandboxInfo fields (sandboxId, templateId), so emitting projectID makes the new public field inconsistent and causes consumers using the expected .projectId path to miss it. The snapshot JSON path has the same issue at packages/cli/src/commands/sandbox/snapshot.ts:107, where the existing identifier is snapshotId; use projectId for these two SDK-shaped outputs while retaining projectID for template API records.

Useful? React with 👍 / 👎.

),
null,
2
)
)
}
} catch (err: any) {
Expand Down Expand Up @@ -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')
Expand All @@ -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 }] : []),
Comment on lines 155 to +157

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟡 nit (optional): With a login, every e2b sandbox list row is now padded to the longest Metadata JSON before the Project ID, so lines get much wider and wrap. renderTable leaves only the last column unpadded (table.ts). The new column follows Metadata, so Metadata is no longer last and every cell is space-filled to the widest metadata plus 3. Fix: put the Project ID column before Metadata so the unbounded column stays last (or print the project once above the table), while keeping it out when projectId is undefined.

Why this was flagged

Trigger: a logged-in user (access-token auth, so projectIdOf returns a prj_ ID) runs e2b sandbox list and one listed sandbox has long metadata, e.g. a few hundred characters once buildTableRows applies JSON.stringify (list.ts:131). renderTable (packages/cli/src/utils/table.ts) pads every cell except the one at i === line.length - 1 to widths[i] + COLUMN_PADDING. On the base branch Metadata was the last column, so rows with short metadata stayed short. After the change the 'Project ID' column added at list.ts:157 comes last. Every row's Metadata cell is then padded to the widest metadata, followed by the 30-character prj_ ID, so each line is as wide as the worst sandbox and wraps in a normal terminal. Template and snapshot lists end with short, bounded columns (Envd version, Names), so this only affects sandbox list. The E2B_API_KEY path keeps the old layout because the column is left out there.

Verification: In packages/cli/src/utils/table.ts, renderTable leaves only the last cell unpadded. In list.ts:155-157, the new Project ID column is added after { header: 'Metadata', ... }, so Metadata is no longer the last column. As a result, every row is now space-filled to the longest metadata string plus 3. On the base branch, rows with short metadata stayed short.

])
}

Expand All @@ -161,16 +172,17 @@ type ListSandboxesResult = {
hasMore: boolean
}

async function listSandboxes({
limit,
state,
metadata,
template,
startedAfter,
order,
}: ListSandboxesOptions = {}): Promise<ListSandboxesResult> {
const auth = await ensureAuth()

async function listSandboxes(
auth: Auth,
{
limit,
state,
metadata,
template,
startedAfter,
order,
}: ListSandboxesOptions = {}
): Promise<ListSandboxesResult> {
let pageLimit = limit
if (!limit || limit > PAGE_LIMIT) {
pageLimit = PAGE_LIMIT
Expand Down
19 changes: 15 additions & 4 deletions packages/cli/src/commands/sandbox/snapshot.ts
Original file line number Diff line number Diff line change
@@ -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'
Expand Down Expand Up @@ -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)

Expand All @@ -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: projectId } : snapshot
),
null,
2
)
)
}
} catch (err: any) {
console.error(err)
Expand Down Expand Up @@ -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
Expand All @@ -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 }] : []),
])
}

Expand Down
39 changes: 24 additions & 15 deletions packages/cli/src/commands/template/list.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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,
})

Expand All @@ -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)
Expand All @@ -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.')
Expand Down Expand Up @@ -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 }] : []),
])
}

Expand All @@ -107,22 +119,19 @@ type ListSandboxTemplatesResult = {
hasMore: boolean
}

async function listSandboxTemplates({
projectId,
limit,
}: {
projectId?: string
limit?: number
}): Promise<ListSandboxTemplatesResult> {
async function listSandboxTemplates(
auth: Auth,
{ limit }: { limit?: number }
): Promise<ListSandboxTemplatesResult> {
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

Expand Down
19 changes: 19 additions & 0 deletions packages/cli/src/workspaces.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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}`
}
Comment on lines +310 to +327

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

T-52 — the backend is the source of truth; client code must never mirror backend business rules, because a duplicated rule drifts from the server's the moment the API changes.

publicProjectId() re-implements workspace-api's public_id encoding (the TypeID prefix, the alphabet and the 26-char base32 layout) on the client. If workspace-api changes the prefix, the ID scheme or how it derives public_id from the UUID, the CLI keeps printing IDs that look valid but match nothing in e2b project list, and nothing fails. The fallback makes this worse: anything that isn't a UUID goes through unchanged, so the Project ID column can show a raw value that isn't a prj_ ID with no warning.

A compliant version uses the public_id the server already returns instead of computing it. /v1/me returns it, getMe() caches it per process, and resolveProject() already gets the whole project from findProject(...). One way to do this:

  • resolveProject() returns { id, publicId: project.public_id, domain }.
  • e2b project use stores projectPublicId next to projectDomain, so the fast path still needs no round trip.
  • The lookup-once fallback fills in projectPublicId the same way it fills in projectDomain.

projectIdOf(auth) then reads that value and stops parsing it back out of X-Team-ID.

Loading
Loading