Skip to content
Draft
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
27 changes: 27 additions & 0 deletions .changeset/cli-profile-support.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
---
'@e2b/cli': minor
---

Add `--profile` support to the CLI.

Named profiles can be defined in `~/.config/e2b/config` (extensionless TOML):

```toml
[profiles.default]
api_key = "e2b_..."
team_id = "..."

[profiles.staging]
api_key = "e2b_..."
domain = "staging.e2b.app"
team_id = "..."
```

All fields (`api_key`, `team_id`, `domain`) are optional. Use a profile with any command:

```
e2b sandbox list --profile staging
e2b auth configure --profile staging
```

The existing `~/.e2b/config.json` is still read as a fallback for the `default` profile. On next `e2b auth login` or `e2b auth configure`, it will be migrated to the new TOML format.
59 changes: 42 additions & 17 deletions packages/cli/src/api.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,14 @@
import * as boxen from 'boxen'
import * as e2b from 'e2b'

import { getUserConfig, UserConfig } from './user'
import { getUserConfig, saveUserConfig, UserConfig } from './user'
import { asBold, asPrimary } from './utils/format'

export let apiKey = process.env.E2B_API_KEY
export let accessToken = process.env.E2B_ACCESS_TOKEN
export let domain: string | undefined = process.env.E2B_DOMAIN
export const teamId = process.env.E2B_TEAM_ID
export let currentProfileName: string = 'default'

const authErrorBox = (keyName: string) => {
let link
Expand Down Expand Up @@ -44,11 +46,41 @@ Visit ${asPrimary(link)} to get the ${msg}.`,
)
}

function buildConnectionConfig() {
return new e2b.ConnectionConfig({
accessToken,
apiKey,
domain,
})
}

// Initialize from the default profile at startup
const _defaultConfig = getUserConfig('default')
if (!process.env.E2B_API_KEY) apiKey = _defaultConfig?.teamApiKey
if (!process.env.E2B_ACCESS_TOKEN) accessToken = _defaultConfig?.accessToken
if (!process.env.E2B_DOMAIN) domain = _defaultConfig?.domain

export let connectionConfig = buildConnectionConfig()
export let client = new e2b.ApiClient(connectionConfig)

export function setProfile(profileName: string) {
currentProfileName = profileName
const config = getUserConfig(profileName)
if (!config) {
console.error(`Profile '${profileName}' not found in ~/.e2b/config`)
process.exit(1)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

setProfile blocks new profile login

Medium Severity

The root preAction always calls setProfile when --profile is set, and setProfile exits if that name is missing from TOML, so e2b auth login --profile <name> cannot create a new profile that is not already defined in the config file.

Additional Locations (1)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 043597f. Configure here.

}
if (!process.env.E2B_API_KEY) apiKey = config.teamApiKey
if (!process.env.E2B_ACCESS_TOKEN) accessToken = config.accessToken
if (!process.env.E2B_DOMAIN) domain = config.domain
connectionConfig = buildConnectionConfig()
client = new e2b.ApiClient(connectionConfig)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Profile domain unused for sandboxes

Medium Severity

Profile domain is applied to ConnectionConfig and ApiClient, but sandbox commands still call the SDK with only apiKey, so sandbox API traffic may keep using the default host instead of the profile’s domain.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 043597f. Configure here.

}

export function ensureAPIKey() {
// If apiKey is not already set (either from env var or from user config), try to get it from config file
if (!apiKey) {
const userConfig = getUserConfig()
apiKey = userConfig?.teamApiKey
const config = getUserConfig(currentProfileName)
apiKey = config?.teamApiKey
}

if (!apiKey) {
Expand All @@ -60,7 +92,7 @@ export function ensureAPIKey() {
}

export function ensureUserConfig(): UserConfig {
const userConfig = getUserConfig()
const userConfig = getUserConfig(currentProfileName)
if (!userConfig) {
console.error('No user config found, run `e2b auth login` to log in first.')
process.exit(1)
Expand All @@ -69,10 +101,9 @@ export function ensureUserConfig(): UserConfig {
}

export function ensureAccessToken() {
// If accessToken is not already set (either from env var or from user config), try to get it from config file
if (!accessToken) {
const userConfig = getUserConfig()
accessToken = userConfig?.accessToken
const config = getUserConfig(currentProfileName)
accessToken = config?.accessToken
}

if (!accessToken) {
Expand All @@ -88,7 +119,7 @@ export function ensureAccessToken() {
* 1. CLI --team flag
* 2. E2B_TEAM_ID env var
* 3. Local e2b.toml team_id (if provided)
* 4. ~/.e2b/config.json teamId (only if E2B_API_KEY env var is NOT set,
* 4. Profile config teamId (only if E2B_API_KEY env var is NOT set,
* to avoid mismatch between env var API key and config file team ID)
*/
export function resolveTeamId(
Expand All @@ -99,16 +130,10 @@ export function resolveTeamId(
if (teamId) return teamId
if (localConfigTeamId) return localConfigTeamId
if (!process.env.E2B_API_KEY) {
const config = getUserConfig()
const config = getUserConfig(currentProfileName)
return config?.teamId
}
return undefined
}

const userConfig = getUserConfig()

export const connectionConfig = new e2b.ConnectionConfig({
accessToken: process.env.E2B_ACCESS_TOKEN || userConfig?.accessToken,
apiKey: process.env.E2B_API_KEY || userConfig?.teamApiKey,
})
export const client = new e2b.ApiClient(connectionConfig)
export { saveUserConfig }
19 changes: 9 additions & 10 deletions packages/cli/src/commands/auth/configure.ts
Original file line number Diff line number Diff line change
@@ -1,15 +1,13 @@
import * as commander from 'commander'
import * as fs from 'fs'
import * as chalk from 'chalk'
import * as e2b from 'e2b'
import * as path from 'path'

import { USER_CONFIG_PATH } from 'src/user'
import { getUserConfig, saveUserConfig } from 'src/user'
import {
client,
connectionConfig,
currentProfileName,
ensureAccessToken,
ensureUserConfig,
} from 'src/api'
import { asBold, asFormattedTeam } from '../../utils/format'
import { handleE2BRequestError } from '../../utils/errors'
Expand All @@ -21,12 +19,14 @@ export const configureCommand = new commander.Command('configure')

console.log('Configuring user...\n')

if (!fs.existsSync(USER_CONFIG_PATH)) {
console.log('No user config found, run `e2b auth login` to log in first.')
const userConfig = getUserConfig(currentProfileName)
if (!userConfig) {
console.log(
`No config found for profile '${currentProfileName}', run 'e2b auth login' to log in first.`
)
return
}

const userConfig = ensureUserConfig()
ensureAccessToken()
const signal = connectionConfig.getSignal()

Expand All @@ -42,7 +42,7 @@ export const configureCommand = new commander.Command('configure')
type: 'list',
pageSize: 50,
choices: res.data.map((team: e2b.components['schemas']['Team']) => ({
name: asFormattedTeam(team, userConfig.teamId),
name: asFormattedTeam(team, userConfig.teamId ?? ''),
value: team,
})),
},
Expand All @@ -52,8 +52,7 @@ export const configureCommand = new commander.Command('configure')
userConfig.teamName = team.name
userConfig.teamId = team.teamID
userConfig.teamApiKey = team.apiKey
fs.mkdirSync(path.dirname(USER_CONFIG_PATH), { recursive: true })
fs.writeFileSync(USER_CONFIG_PATH, JSON.stringify(userConfig, null, 2))
saveUserConfig(userConfig, currentProfileName)

console.log(`Team ${asBold(team.name)} (${team.teamID}) selected.\n`)
})
20 changes: 6 additions & 14 deletions packages/cli/src/commands/auth/login.ts
Original file line number Diff line number Diff line change
@@ -1,20 +1,13 @@
import * as listen from 'async-listen'
import * as commander from 'commander'
import * as fs from 'fs'
import * as http from 'http'
import * as open from 'open'
import * as path from 'path'
import * as e2b from 'e2b'

import { pkg } from 'src'
import {
DOCS_BASE,
getUserConfig,
USER_CONFIG_PATH,
UserConfig,
} from 'src/user'
import { DOCS_BASE, getUserConfig, saveUserConfig, UserConfig } from 'src/user'
import { asBold, asFormattedConfig, asFormattedError } from 'src/utils/format'
import { connectionConfig } from 'src/api'
import { connectionConfig, currentProfileName } from 'src/api'
import { handleE2BRequestError } from '../../utils/errors'

export const loginCommand = new commander.Command('login')
Expand All @@ -23,7 +16,7 @@ export const loginCommand = new commander.Command('login')
let userConfig: UserConfig | null = null

try {
userConfig = getUserConfig()
userConfig = getUserConfig(currentProfileName)
} catch (err) {
console.error(asFormattedError('Failed to read user config', err))
}
Expand Down Expand Up @@ -72,13 +65,12 @@ export const loginCommand = new commander.Command('login')
teamApiKey: defaultTeam.apiKey,
}

fs.mkdirSync(path.dirname(USER_CONFIG_PATH), { recursive: true })
fs.writeFileSync(USER_CONFIG_PATH, JSON.stringify(userConfig, null, 2))
saveUserConfig(userConfig, currentProfileName)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Login overwrites profile domain

High Severity

e2b auth login builds a fresh userConfig without domain (and other optional TOML-only fields) and saveUserConfig replaces the entire profile table entry, so a preconfigured domain on that profile is removed from ~/.e2b/config after login.

Additional Locations (1)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 043597f. Configure here.

}

console.log(
`Logged in as ${asBold(userConfig.email)} with selected team ${asBold(
userConfig.teamName
`Logged in as ${asBold(userConfig.email!)} with selected team ${asBold(
userConfig.teamName!
)}`
)
process.exit(0)
Expand Down
20 changes: 14 additions & 6 deletions packages/cli/src/commands/auth/logout.ts
Original file line number Diff line number Diff line change
@@ -1,15 +1,23 @@
import * as commander from 'commander'
import * as fs from 'fs'

import { USER_CONFIG_PATH } from 'src/user'
import { getUserConfig, deleteUserProfile } from 'src/user'
import { currentProfileName } from 'src/api'

export const logoutCommand = new commander.Command('logout')
.description('log out of CLI')
.action(() => {
if (fs.existsSync(USER_CONFIG_PATH)) {
fs.unlinkSync(USER_CONFIG_PATH) // Delete user config
console.log('Logged out.')
if (getUserConfig(currentProfileName)) {
deleteUserProfile(currentProfileName)
console.log(
currentProfileName === 'default'
? 'Logged out.'
: `Profile '${currentProfileName}' removed.`
)
} else {
console.log('Not logged in, nothing to do')
console.log(
currentProfileName === 'default'
? 'Not logged in, nothing to do'
: `Profile '${currentProfileName}' not found, nothing to do`
)
}
})
9 changes: 9 additions & 0 deletions packages/cli/src/commands/index.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import * as commander from 'commander'

import { asPrimary } from 'src/utils/format'
import { profileOption } from 'src/options'
import { setProfile } from 'src/api'
import { templateCommand } from './template'
import { sandboxCommand } from './sandbox'
import { authCommand } from './auth'
Expand All @@ -16,6 +18,13 @@ Visit ${asPrimary(
)} to learn how to create sandbox templates and start sandboxes.
`
)
.addOption(profileOption)
.hook('preAction', (thisCommand) => {
const profile = thisCommand.opts().profile as string | undefined
if (profile) {
setProfile(profile)
}
})
.addCommand(authCommand)
.addCommand(templateCommand)
.addCommand(sandboxCommand)
5 changes: 5 additions & 0 deletions packages/cli/src/options.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,3 +30,8 @@ export const teamOption = new commander.Option(
'-t, --team <team-id>',
'specify the team ID that the operation will be associated with. You can find team ID in the team settings in the E2B dashboard (https://e2b.dev/dashboard?tab=team).'
)

export const profileOption = new commander.Option(
'--profile <profile>',
`use a named profile from ${asBold('~/.e2b/config')}. Defaults to ${asBold('default')}.`
)
Loading
Loading