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-volume-secret-commands.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@e2b/cli': patch

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 Publish new CLI commands with a minor bump

These changes add two public top-level command groups, but this entry tells Changesets to release @e2b/cli as 2.22.1. Comparable command additions such as sandbox snapshot and sandbox fork were minor releases, and SemVer reserves patch bumps for fixes; mark this minor so consumers and release automation identify the new CLI surface correctly.

AGENTS.md reference: AGENTS.md:L6-L6

Useful? React with 👍 / 👎.

---

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`).
4 changes: 4 additions & 0 deletions packages/cli/src/commands/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -23,3 +25,5 @@ Visit ${asPrimary(
.addCommand(authCommand)
.addCommand(templateCommand)
.addCommand(sandboxCommand)
.addCommand(volumeCommand)
.addCommand(secretCommand)
223 changes: 223 additions & 0 deletions packages/cli/src/commands/secret/commands.ts
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

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-11 — When a concept already has a named type, use it instead of re-spelling its shape. The SDK exports SecretInfo from e2b. If this inline copy drifts from it (e.g. a field gets added or made optional), it stops matching without warning.

Suggested change
export function formatSecretRow(secret: {
secretId: string
name: string
version: number
metadata: Record<string, string>
createdAt: Date
updatedAt: Date
}) {
export function formatSecretRow(secret: SecretInfo) {

(and import type { SecretInfo } from 'e2b' next to the Secret import)

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

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.

🟨 Secret values exposed in process arguments

Secret create and update require --value, exposing credentials in shell history and process listings. A write-only API response does not protect the input.

Devin Review


Was this helpful? React with 👍 or 👎 to provide feedback.

.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(','))

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.

🟡 Secret metadata values lose delimiters

When metadata values contain commas or equals signs, parseMetadata truncates them before Secret.create or Secret.update. The stored metadata differs from the supplied value.

Learn more

Secret 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 parseMetadata call.

Example: e2b secret create KEY --value x --metadata 'url=https://example.com?a=1,b=2' stores url=https://example.com?a instead of the supplied URL.

Recommended fix: Parse each repeated --metadata option independently, split only at its first =, and retain the entire remainder as the value. Reject malformed entries rather than silently discarding them.

Devin Review


Was this helpful? React with 👍 or 👎 to provide feedback.

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 Preserve delimiters inside secret metadata values

When a metadata value contains =—for example --metadata callback='https://host/?a=b'—this passes the joined string to parseMetadata, which destructures pair.split('=') and silently persists only https://host/?a; commas are likewise reinterpreted as new entries. The API schema accepts arbitrary string values, and updateCommand repeats this path, so parse each repeatable option at its first separator rather than recombining it.

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>>[] = []

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-11 — Same here. Awaited<ReturnType<typeof Secret.getInfo>> rebuilds a type that already has a name, and SecretPaginator.nextItems() returns SecretInfo[]. Use the named type:

Suggested change
const secrets: Awaited<ReturnType<typeof Secret.getInfo>>[] = []
const secrets: SecretInfo[] = []

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

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.

🟡 Negative limit hides existing secrets

With --limit -1, Secret.list never fetches a page because the loop compares zero items against a negative limit. The command reports no secrets despite existing ones.

Learn more

The --limit parser accepts signed numbers without checking their range. A negative limit makes secrets.length < limit false before the first request, so no secrets are returned even when the project has secrets. A malformed limit parsed as NaN has the same effect.

Example: With one stored secret, e2b secret list --limit -1 --format json prints [] without sending a list request.

Recommended fix: Validate --limit as a nonnegative integer in its Commander option parser; reject invalid input before creating the paginator.

Devin Review


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)
}
})
19 changes: 19 additions & 0 deletions packages/cli/src/commands/secret/index.ts
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)
106 changes: 106 additions & 0 deletions packages/cli/src/commands/volume/commands.ts
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

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.

🟡 Unsupported output formats silently succeed

With --format yaml, the new commands print pretty output and exit successfully. Existing list commands reject unsupported formats, so callers can mistake plain text for requested structured output.

Learn more

All new volume and secret commands branch on format === 'json' and treat every other string as pretty. Existing sandbox list and template list reject unknown formats. This matters for scripts that request a specific format and trust the exit status before processing the output.

Example: e2b volume list --format yaml succeeds with a plain-text table rather than rejecting yaml.

Recommended fix: Validate the format in all new create, list, and info/update commands, accepting only json and pretty, and exit nonzero for any other value.

Devin Review


Was this helpful? React with 👍 or 👎 to provide feedback.

Comment on lines +22 to +24

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 Reject unsupported output formats

When any new action that accepts --format is given yaml or a typo such as jsno, this binary branch and its counterparts in the volume and secret actions treat it as pretty output and exit successfully. Existing sandbox list, sandbox info, and snapshot-list commands reject values other than json or pretty, so automation can receive human-formatted output under an unsupported format; validate the option and fail before emitting output.

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))

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.

🟥 Volume credential leaked through JSON info

With --format json, Volume.getInfo includes a volume access token in the output. Logs or shared output expose that bearer credential.

Devin Review


Was this helpful? React with 👍 or 👎 to provide feedback.

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 Redact the volume bearer token from JSON info output

When e2b volume info <id> --format json is used, Volume.getInfo returns a VolumeAndToken and this line serializes its token; VolumeConnectionConfig installs that value as the Bearer credential for volume-content reads and writes. Pretty output and volume create --format json already omit it, so JSON output that is captured or logged unexpectedly discloses a scoped credential; project the public info fields instead or require an explicit token-export option.

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)
}
})
17 changes: 17 additions & 0 deletions packages/cli/src/commands/volume/index.ts
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)
Loading