Repository navigation
feat(cli): add volume and secret management subcommands #1989
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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`). | ||
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| @@ -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<string, string> | ||||||||||||||||||||
| createdAt: Date | ||||||||||||||||||||
| updatedAt: Date | ||||||||||||||||||||
| }) { | ||||||||||||||||||||
|
Comment on lines
+14
to
+21
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. T-11 — When a concept already has a named type, use it instead of re-spelling its shape. The SDK exports
Suggested change
(and |
||||||||||||||||||||
| 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>', | ||||||||||||||||||||
| `name for the secret, unique within the project, eg. ${asBold('API_KEY')}` | ||||||||||||||||||||
| ) | ||||||||||||||||||||
| .requiredOption( | ||||||||||||||||||||
| '-v, --value <value>', | ||||||||||||||||||||
| 'secret value (write-only — never returned by the API)' | ||||||||||||||||||||
| ) | ||||||||||||||||||||
|
Comment on lines
+36
to
+39
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. |
||||||||||||||||||||
| .option( | ||||||||||||||||||||
| '-m, --metadata <metadata>', | ||||||||||||||||||||
| 'metadata to store with the secret, eg. key1=value1', | ||||||||||||||||||||
| (value, prev: string[]) => [...(prev ?? []), value], | ||||||||||||||||||||
| [] | ||||||||||||||||||||
| ) | ||||||||||||||||||||
| .option('-f, --format <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(',')) | ||||||||||||||||||||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🟡 Secret metadata values lose delimiters When metadata values contain commas or equals signs, Learn moreSecret metadata is stored as a string map; the API does not restrict values to strings without commas or equals signs SecretMetadata. Both create and update join repeated options with commas and pass them through parseMetadata. That parser splits first on commas and then on every equals sign, silently dropping the rest. The same failure affects the update path at the second Example: Recommended fix: Parse each repeated Was this helpful? React with 👍 or 👎 to provide feedback. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
When a metadata value contains Useful? React with 👍 / 👎. |
||||||||||||||||||||
|
|
||||||||||||||||||||
| 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>', | ||||||||||||||||||||
| `limit the number of secrets returned (default: ${DEFAULT_LIMIT}, 0 for no limit)`, | ||||||||||||||||||||
| (value: string) => parseInt(value) | ||||||||||||||||||||
| ) | ||||||||||||||||||||
| .option('-f, --format <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<ReturnType<typeof Secret.getInfo>>[] = [] | ||||||||||||||||||||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. T-11 — Same here.
Suggested change
|
||||||||||||||||||||
| const paginator = Secret.list({ apiKey, limit: pageLimit }) | ||||||||||||||||||||
|
|
||||||||||||||||||||
| while (paginator.hasNext && (!limit || secrets.length < limit)) { | ||||||||||||||||||||
| const batch = await paginator.nextItems() | ||||||||||||||||||||
| secrets.push(...batch) | ||||||||||||||||||||
|
Comment on lines
+103
to
+105
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🟡 Negative limit hides existing secrets With Learn moreThe Example: With one stored secret, Recommended fix: Validate Was this helpful? React with 👍 or 👎 to provide feedback. |
||||||||||||||||||||
| } | ||||||||||||||||||||
|
|
||||||||||||||||||||
| 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('<secret>', `the secret ${asBold('<secretID>')} or name`) | ||||||||||||||||||||
| .option('-f, --format <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('<secret>', `the secret ${asBold('<secretID>')} or name`) | ||||||||||||||||||||
| .requiredOption( | ||||||||||||||||||||
| '-v, --value <value>', | ||||||||||||||||||||
| 'new secret value (write-only — never returned by the API)' | ||||||||||||||||||||
| ) | ||||||||||||||||||||
| .option( | ||||||||||||||||||||
| '-m, --metadata <metadata>', | ||||||||||||||||||||
| 'metadata to replace the stored metadata, eg. key1=value1', | ||||||||||||||||||||
| (value, prev: string[]) => [...(prev ?? []), value], | ||||||||||||||||||||
| [] | ||||||||||||||||||||
| ) | ||||||||||||||||||||
| .option('-f, --format <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('<secret>', `the secret ${asBold('<secretID>')} 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) | ||||||||||||||||||||
| } | ||||||||||||||||||||
| }) | ||||||||||||||||||||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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) |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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>', `name for the volume, eg. ${asBold('my-volume')}`) | ||
| .option('-f, --format <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 { | ||
|
Comment on lines
+22
to
+24
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🟡 Unsupported output formats silently succeed With Learn moreAll new volume and secret commands branch on Example: Recommended fix: Validate the format in all new create, list, and info/update commands, accepting only Was this helpful? React with 👍 or 👎 to provide feedback.
Comment on lines
+22
to
+24
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
When any new action that accepts Useful? React with 👍 / 👎. |
||
| 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 <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('<volumeID>', `the volume ${asBold('<volumeID>')}`) | ||
| .option('-f, --format <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)) | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
When Useful? React with 👍 / 👎. |
||
| } 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('<volumeID>', `the volume ${asBold('<volumeID>')} 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) | ||
| } | ||
| }) | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
These changes add two public top-level command groups, but this entry tells Changesets to release
@e2b/clias 2.22.1. Comparable command additions such assandbox snapshotandsandbox forkwere minor releases, and SemVer reserves patch bumps for fixes; mark thisminorso consumers and release automation identify the new CLI surface correctly.AGENTS.md reference: AGENTS.md:L6-L6
Useful? React with 👍 / 👎.