From 35d928c63bf20a14a6536ff240542379c0d1df04 Mon Sep 17 00:00:00 2001 From: lemonzoey <25550414+lemonzoey@users.noreply.github.com> Date: Thu, 15 Oct 2026 14:14:59 +0800 Subject: [PATCH] feat(cli): add volume and secret management subcommands MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The SDKs ship full Volume and Secret management (create/list/info/delete, plus secret update), backed by the `/volumes` and `/secrets` endpoints, but the CLI exposed neither — a user working from the terminal could not manage volumes or secrets without dropping to the SDK or the raw API. sandbox and template are already first-class CLI resources; volumes and secrets were the gap. Add two top-level resource subcommands mirroring the SDK surface, following the existing `e2b sandbox` / `e2b sandbox snapshot` style (commander chain, `ensureAPIKey`, `renderTable`, `--format json|pretty`): e2b volume create e2b volume list [--format json] e2b volume info e2b volume delete (alias: rm) e2b secret create --value [--metadata key=value] e2b secret list [--limit N] [--format json] e2b secret info e2b secret update --value [--metadata key=value] e2b secret delete (alias: rm) Secret values are write-only (never returned by the API), so they are never printed; only ID/name/version/metadata/timestamps are. Volume metadata is out of scope (that's #1643, blocked on the NewVolume schema gaining a metadata field); the four CRUD operations need no spec change. Tests: a unit suite for the secret list row formatter plus a command-tree test asserting both groups register their operations and aliases. Closes #1988 Co-Authored-By: Kscc --- .changeset/cli-volume-secret-commands.md | 5 + packages/cli/src/commands/index.ts | 4 + packages/cli/src/commands/secret/commands.ts | 223 ++++++++++++++++++ packages/cli/src/commands/secret/index.ts | 19 ++ packages/cli/src/commands/volume/commands.ts | 106 +++++++++ packages/cli/src/commands/volume/index.ts | 17 ++ .../cli/tests/commands/secret/list.test.ts | 54 +++++ .../commands/volume_secret_commands.test.ts | 61 +++++ 8 files changed, 489 insertions(+) create mode 100644 .changeset/cli-volume-secret-commands.md create mode 100644 packages/cli/src/commands/secret/commands.ts create mode 100644 packages/cli/src/commands/secret/index.ts create mode 100644 packages/cli/src/commands/volume/commands.ts create mode 100644 packages/cli/src/commands/volume/index.ts create mode 100644 packages/cli/tests/commands/secret/list.test.ts create mode 100644 packages/cli/tests/commands/volume_secret_commands.test.ts diff --git a/.changeset/cli-volume-secret-commands.md b/.changeset/cli-volume-secret-commands.md new file mode 100644 index 0000000000..6e1cbc9ec1 --- /dev/null +++ b/.changeset/cli-volume-secret-commands.md @@ -0,0 +1,5 @@ +--- +'@e2b/cli': patch +--- + +Add `e2b volume` and `e2b secret` subcommands exposing the volume and secret management the SDK already provides. `e2b volume` supports `create`, `list`, `info`, and `delete` (`rm`); `e2b secret` supports `create` (with `--value` and `--metadata`), `list`, `info`, `update` (rotate the value into a new version), and `delete` (`rm`). Both follow the existing `e2b sandbox` command style (`--format json|pretty`, kubectl-style tables, `ensureAPIKey`). diff --git a/packages/cli/src/commands/index.ts b/packages/cli/src/commands/index.ts index 0dcba975af..c246d1b5e1 100644 --- a/packages/cli/src/commands/index.ts +++ b/packages/cli/src/commands/index.ts @@ -4,6 +4,8 @@ import { asPrimary } from 'src/utils/format' import { templateCommand } from './template' import { sandboxCommand } from './sandbox' import { authCommand } from './auth' +import { volumeCommand } from './volume' +import { secretCommand } from './secret' export const program = new commander.Command() // Fixed so help reads `e2b` however the entrypoint is invoked (npm bin @@ -23,3 +25,5 @@ Visit ${asPrimary( .addCommand(authCommand) .addCommand(templateCommand) .addCommand(sandboxCommand) + .addCommand(volumeCommand) + .addCommand(secretCommand) diff --git a/packages/cli/src/commands/secret/commands.ts b/packages/cli/src/commands/secret/commands.ts new file mode 100644 index 0000000000..83f5f7b5dd --- /dev/null +++ b/packages/cli/src/commands/secret/commands.ts @@ -0,0 +1,223 @@ +import * as commander from 'commander' +import { Secret } from 'e2b' + +import { ensureAPIKey } from 'src/api' +import { asBold } from 'src/utils/format' +import { renderTable } from 'src/utils/table' +import { parseMetadata } from '../sandbox/utils' + +const DEFAULT_LIMIT = 1000 +const PAGE_LIMIT = 100 + +// Secret values are write-only — never returned by the API — so they are +// never printed. Only the secret's ID/name/version/metadata/timestamps are. +export function formatSecretRow(secret: { + secretId: string + name: string + version: number + metadata: Record + createdAt: Date + updatedAt: Date +}) { + return { + ...secret, + createdAt: new Date(secret.createdAt).toLocaleString(), + updatedAt: new Date(secret.updatedAt).toLocaleString(), + metadata: JSON.stringify(secret.metadata), + } +} + +export const createCommand = new commander.Command('create') + .description('create a new secret') + .argument( + '', + `name for the secret, unique within the project, eg. ${asBold('API_KEY')}` + ) + .requiredOption( + '-v, --value ', + 'secret value (write-only — never returned by the API)' + ) + .option( + '-m, --metadata ', + 'metadata to store with the secret, eg. key1=value1', + (value, prev: string[]) => [...(prev ?? []), value], + [] + ) + .option('-f, --format ', 'output format, eg. json, pretty') + .action( + async ( + name: string, + options: { + value: string + metadata?: string[] + format?: string + } + ) => { + try { + const apiKey = ensureAPIKey() + const metadata = parseMetadata(options.metadata?.join(',')) + + const info = await Secret.create(name, options.value, { + apiKey, + metadata, + }) + + if (options.format === 'json') { + console.log(JSON.stringify(info, null, 2)) + } else { + console.log( + `Created secret ${asBold(info.name)} (ID: ${asBold(info.secretId)}, version ${info.version})` + ) + } + } catch (err: any) { + console.error(err) + process.exit(1) + } + } + ) + +export const listCommand = new commander.Command('list') + .description('list all secrets') + .alias('ls') + .option( + '-l, --limit ', + `limit the number of secrets returned (default: ${DEFAULT_LIMIT}, 0 for no limit)`, + (value: string) => parseInt(value) + ) + .option('-f, --format ', 'output format, eg. json, pretty') + .action(async (options: { limit?: number; format?: string }) => { + try { + const apiKey = ensureAPIKey() + const format = options.format || 'pretty' + const limit = + options.limit === 0 ? undefined : (options.limit ?? DEFAULT_LIMIT) + + let pageLimit = limit + if (!limit || limit > PAGE_LIMIT) { + pageLimit = PAGE_LIMIT + } + + const secrets: Awaited>[] = [] + const paginator = Secret.list({ apiKey, limit: pageLimit }) + + while (paginator.hasNext && (!limit || secrets.length < limit)) { + const batch = await paginator.nextItems() + secrets.push(...batch) + } + + const trimmed = limit ? secrets.slice(0, limit) : secrets + + if (format === 'json') { + console.log(JSON.stringify(trimmed, null, 2)) + } else { + if (!trimmed.length) { + console.log('No secrets found') + return + } + + renderTable(trimmed.map(formatSecretRow), [ + { header: 'Secret ID', value: (row) => row.secretId }, + { header: 'Name', value: (row) => row.name }, + { header: 'Version', value: (row) => String(row.version) }, + { header: 'Created at', value: (row) => row.createdAt }, + { header: 'Updated at', value: (row) => row.updatedAt }, + { header: 'Metadata', value: (row) => row.metadata }, + ]) + } + } catch (err: any) { + console.error(err) + process.exit(1) + } + }) + +export const infoCommand = new commander.Command('info') + .description('show details for a secret (by ID or name)') + .argument('', `the secret ${asBold('')} or name`) + .option('-f, --format ', 'output format, eg. json, pretty') + .action(async (secret: string, options: { format?: string }) => { + try { + const apiKey = ensureAPIKey() + const info = await Secret.getInfo(secret, { apiKey }) + + if (options.format === 'json') { + console.log(JSON.stringify(info, null, 2)) + } else { + console.log(`Secret ID: ${asBold(info.secretId)}`) + console.log(`Name: ${asBold(info.name)}`) + console.log(`Version: ${info.version}`) + console.log(`Created at: ${new Date(info.createdAt).toLocaleString()}`) + console.log(`Updated at: ${new Date(info.updatedAt).toLocaleString()}`) + console.log(`Metadata: ${JSON.stringify(info.metadata)}`) + } + } catch (err: any) { + console.error(err) + process.exit(1) + } + }) + +export const updateCommand = new commander.Command('update') + .description("update a secret's value, storing it as a new version") + .argument('', `the secret ${asBold('')} or name`) + .requiredOption( + '-v, --value ', + 'new secret value (write-only — never returned by the API)' + ) + .option( + '-m, --metadata ', + 'metadata to replace the stored metadata, eg. key1=value1', + (value, prev: string[]) => [...(prev ?? []), value], + [] + ) + .option('-f, --format ', 'output format, eg. json, pretty') + .action( + async ( + secret: string, + options: { + value: string + metadata?: string[] + format?: string + } + ) => { + try { + const apiKey = ensureAPIKey() + const metadata = parseMetadata(options.metadata?.join(',')) + + const info = await Secret.update(secret, options.value, { + apiKey, + metadata, + }) + + if (options.format === 'json') { + console.log(JSON.stringify(info, null, 2)) + } else { + console.log( + `Updated secret ${asBold(info.name)} (ID: ${asBold(info.secretId)}, version ${info.version})` + ) + } + } catch (err: any) { + console.error(err) + process.exit(1) + } + } + ) + +export const deleteCommand = new commander.Command('delete') + .description('delete a secret, making all its versions inaccessible') + .argument('', `the secret ${asBold('')} or name to delete`) + .alias('rm') + .action(async (secret: string) => { + try { + const apiKey = ensureAPIKey() + const destroyed = await Secret.destroy(secret, { apiKey }) + + if (destroyed) { + console.log(`Secret ${asBold(secret)} has been deleted`) + } else { + console.error(`Secret ${asBold(secret)} wasn't found`) + process.exit(1) + } + } catch (err: any) { + console.error(err) + process.exit(1) + } + }) diff --git a/packages/cli/src/commands/secret/index.ts b/packages/cli/src/commands/secret/index.ts new file mode 100644 index 0000000000..883c1b1006 --- /dev/null +++ b/packages/cli/src/commands/secret/index.ts @@ -0,0 +1,19 @@ +import * as commander from 'commander' + +import { + createCommand, + deleteCommand, + infoCommand, + listCommand, + updateCommand, +} from './commands' + +export const secretCommand = new commander.Command('secret') + .description( + 'manage secrets — values stored server-side and injected into sandboxes' + ) + .addCommand(createCommand) + .addCommand(listCommand) + .addCommand(infoCommand) + .addCommand(updateCommand) + .addCommand(deleteCommand) diff --git a/packages/cli/src/commands/volume/commands.ts b/packages/cli/src/commands/volume/commands.ts new file mode 100644 index 0000000000..e4e2d55475 --- /dev/null +++ b/packages/cli/src/commands/volume/commands.ts @@ -0,0 +1,106 @@ +import * as commander from 'commander' +import { Volume } from 'e2b' + +import { ensureAPIKey } from 'src/api' +import { asBold } from 'src/utils/format' +import { renderTable } from 'src/utils/table' + +export const createCommand = new commander.Command('create') + .description('create a new volume') + .argument('', `name for the volume, eg. ${asBold('my-volume')}`) + .option('-f, --format ', 'output format, eg. json, pretty') + .action(async (name: string, options: { format?: string }) => { + try { + const apiKey = ensureAPIKey() + const volume = await Volume.create(name, { apiKey }) + + const info = { + volumeId: volume.volumeId, + name, + } + + if (options.format === 'json') { + console.log(JSON.stringify(info, null, 2)) + } else { + console.log( + `Created volume ${asBold(info.name)} (ID: ${asBold(info.volumeId)})` + ) + } + } catch (err: any) { + console.error(err) + process.exit(1) + } + }) + +export const listCommand = new commander.Command('list') + .description('list all volumes') + .alias('ls') + .option('-f, --format ', 'output format, eg. json, pretty') + .action(async (options: { format?: string }) => { + try { + const apiKey = ensureAPIKey() + const volumes = await Volume.list({ apiKey }) + + if (options.format === 'json') { + console.log(JSON.stringify(volumes, null, 2)) + } else { + if (!volumes.length) { + console.log('No volumes found') + return + } + + renderTable(volumes, [ + { header: 'Volume ID', value: (row) => row.volumeId }, + { header: 'Name', value: (row) => row.name }, + ]) + } + } catch (err: any) { + console.error(err) + process.exit(1) + } + }) + +export const infoCommand = new commander.Command('info') + .description('show details for a volume') + .argument('', `the volume ${asBold('')}`) + .option('-f, --format ', 'output format, eg. json, pretty') + .action(async (volumeID: string, options: { format?: string }) => { + try { + const apiKey = ensureAPIKey() + const info = await Volume.getInfo(volumeID, { apiKey }) + + if (options.format === 'json') { + console.log(JSON.stringify(info, null, 2)) + } else { + console.log(`Volume ID: ${asBold(info.volumeId)}`) + console.log(`Name: ${asBold(info.name)}`) + if (info.domain) { + console.log(`Domain: ${info.domain}`) + } + } + } catch (err: any) { + console.error(err) + process.exit(1) + } + }) + +export const deleteCommand = new commander.Command('delete') + .description('delete a volume') + .argument('', `the volume ${asBold('')} to delete`) + .alias('rm') + .action(async (volumeID: string) => { + try { + const apiKey = ensureAPIKey() + const destroyed = await Volume.destroy(volumeID, { apiKey }) + + if (destroyed) { + console.log(`Volume ${asBold(volumeID)} has been deleted`) + } else { + console.error(`Volume ${asBold(volumeID)} wasn't found`) + process.exit(1) + } + } catch (err: any) { + console.error(err) + process.exit(1) + } + }) diff --git a/packages/cli/src/commands/volume/index.ts b/packages/cli/src/commands/volume/index.ts new file mode 100644 index 0000000000..d564fa0b4f --- /dev/null +++ b/packages/cli/src/commands/volume/index.ts @@ -0,0 +1,17 @@ +import * as commander from 'commander' + +import { + createCommand, + deleteCommand, + infoCommand, + listCommand, +} from './commands' + +export const volumeCommand = new commander.Command('volume') + .description( + 'manage volumes — persistent storage that can be mounted into sandboxes' + ) + .addCommand(createCommand) + .addCommand(listCommand) + .addCommand(infoCommand) + .addCommand(deleteCommand) diff --git a/packages/cli/tests/commands/secret/list.test.ts b/packages/cli/tests/commands/secret/list.test.ts new file mode 100644 index 0000000000..e365305c03 --- /dev/null +++ b/packages/cli/tests/commands/secret/list.test.ts @@ -0,0 +1,54 @@ +import { describe, expect, test } from 'vitest' + +import { formatSecretRow } from '../../../src/commands/secret/commands' + +function secret(overrides: Partial[0]> = {}) { + return { + secretId: 'secret-id', + name: 'API_KEY', + version: 1, + metadata: {}, + createdAt: new Date('2026-10-14T10:00:00Z'), + updatedAt: new Date('2026-10-14T10:00:00Z'), + ...overrides, + } +} + +describe('secret list table rows', () => { + test('stringifies metadata for display', () => { + const row = formatSecretRow( + secret({ metadata: { env: 'prod', owner: 'team-a' } }) + ) + + expect(row.metadata).toBe('{"env":"prod","owner":"team-a"}') + }) + + test('formats timestamps as locale strings', () => { + const createdAt = new Date('2026-10-14T10:00:00Z') + const updatedAt = new Date('2026-10-15T12:00:00Z') + const row = formatSecretRow( + secret({ createdAt, updatedAt }) + ) + + expect(row.createdAt).toBe(createdAt.toLocaleString()) + expect(row.updatedAt).toBe(updatedAt.toLocaleString()) + }) + + test('preserves id, name, and version', () => { + const row = formatSecretRow( + secret({ secretId: 's-123', name: 'DB_PASSWORD', version: 3 }) + ) + + expect(row.secretId).toBe('s-123') + expect(row.name).toBe('DB_PASSWORD') + expect(row.version).toBe(3) + }) + + test('does not mutate the input', () => { + const input = secret() + formatSecretRow(input) + + expect(input.metadata).toEqual({}) + expect(input.createdAt).toEqual(new Date('2026-10-14T10:00:00Z')) + }) +}) diff --git a/packages/cli/tests/commands/volume_secret_commands.test.ts b/packages/cli/tests/commands/volume_secret_commands.test.ts new file mode 100644 index 0000000000..d5da13c6c6 --- /dev/null +++ b/packages/cli/tests/commands/volume_secret_commands.test.ts @@ -0,0 +1,61 @@ +import { Command } from 'commander' +import { describe, expect, test } from 'vitest' + +import { volumeCommand } from '../../src/commands/volume' +import { secretCommand } from '../../src/commands/secret' + +// Verifies the volume and secret resource subcommands are wired into their +// command trees with the operations the SDK exposes. We import the command +// groups directly (rather than the top-level `program`) to avoid pulling in +// `src/index.ts`, which reads `package.json` at import time and is not set up +// under vitest. No backend connection — this only inspects the commander tree. + +describe('volume CLI subcommands', () => { + test('exposes create/list/info/delete', () => { + expect(volumeCommand.commands.map((c: Command) => c.name()).sort()).toEqual( + ['create', 'delete', 'info', 'list'] + ) + }) + + test('list is aliased to ls', () => { + const list = volumeCommand.commands.find((c: Command) => c.name() === 'list')! + expect(list.aliases()).toContain('ls') + }) + + test('delete is aliased to rm', () => { + const del = volumeCommand.commands.find( + (c: Command) => c.name() === 'delete' + )! + expect(del.aliases()).toContain('rm') + }) +}) + +describe('secret CLI subcommands', () => { + test('exposes create/list/info/update/delete', () => { + expect(secretCommand.commands.map((c: Command) => c.name()).sort()).toEqual([ + 'create', + 'delete', + 'info', + 'list', + 'update', + ]) + }) + + test('delete is aliased to rm', () => { + const del = secretCommand.commands.find( + (c: Command) => c.name() === 'delete' + )! + expect(del.aliases()).toContain('rm') + }) + + test('create requires --value and notes it is write-only', () => { + const create = secretCommand.commands.find( + (c: Command) => c.name() === 'create' + )! + + expect(create.options.map((o: { long?: string }) => o.long)).toContain( + '--value' + ) + expect(create.helpInformation()).toContain('write-only') + }) +})