diff --git a/.changeset/cli-profile-support.md b/.changeset/cli-profile-support.md new file mode 100644 index 0000000000..e04f271edf --- /dev/null +++ b/.changeset/cli-profile-support.md @@ -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. diff --git a/packages/cli/src/api.ts b/packages/cli/src/api.ts index 29b39a50e1..03df52d952 100644 --- a/packages/cli/src/api.ts +++ b/packages/cli/src/api.ts @@ -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 @@ -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) + } + 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) +} + 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) { @@ -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) @@ -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) { @@ -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( @@ -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 } diff --git a/packages/cli/src/commands/auth/configure.ts b/packages/cli/src/commands/auth/configure.ts index 0f2b248169..65e850ecf8 100644 --- a/packages/cli/src/commands/auth/configure.ts +++ b/packages/cli/src/commands/auth/configure.ts @@ -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' @@ -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() @@ -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, })), }, @@ -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`) }) diff --git a/packages/cli/src/commands/auth/login.ts b/packages/cli/src/commands/auth/login.ts index d14d2fc847..576002b7d3 100644 --- a/packages/cli/src/commands/auth/login.ts +++ b/packages/cli/src/commands/auth/login.ts @@ -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') @@ -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)) } @@ -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) } 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) diff --git a/packages/cli/src/commands/auth/logout.ts b/packages/cli/src/commands/auth/logout.ts index 2ac5d41c33..0738482c56 100644 --- a/packages/cli/src/commands/auth/logout.ts +++ b/packages/cli/src/commands/auth/logout.ts @@ -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` + ) } }) diff --git a/packages/cli/src/commands/index.ts b/packages/cli/src/commands/index.ts index 30d444c553..9177717457 100644 --- a/packages/cli/src/commands/index.ts +++ b/packages/cli/src/commands/index.ts @@ -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' @@ -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) diff --git a/packages/cli/src/options.ts b/packages/cli/src/options.ts index 08e1f5facf..25a6945173 100644 --- a/packages/cli/src/options.ts +++ b/packages/cli/src/options.ts @@ -30,3 +30,8 @@ export const teamOption = new commander.Option( '-t, --team ', '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 ', + `use a named profile from ${asBold('~/.e2b/config')}. Defaults to ${asBold('default')}.` +) diff --git a/packages/cli/src/user.ts b/packages/cli/src/user.ts index be1e86d8c0..59d3d47cb5 100644 --- a/packages/cli/src/user.ts +++ b/packages/cli/src/user.ts @@ -1,20 +1,22 @@ import * as os from 'os' import * as path from 'path' import * as fs from 'fs' +import * as toml from '@iarna/toml' -/** - * User configuration stored in ~/.e2b/config.json - */ export interface UserConfig { - email: string - accessToken: string - teamName: string - teamId: string - teamApiKey: string + email?: string + accessToken?: string + teamName?: string + teamId?: string + teamApiKey?: string + domain?: string dockerProxySet?: boolean } -export const USER_CONFIG_PATH = path.join(os.homedir(), '.e2b', 'config.json') // TODO: Keep in Keychain +// New extensionless TOML config (~/.e2b/config) +export const USER_CONFIG_TOML_PATH = path.join(os.homedir(), '.e2b', 'config') +// Legacy JSON config kept for read-only fallback +export const USER_CONFIG_PATH = path.join(os.homedir(), '.e2b', 'config.json') export const DOCS_BASE = process.env.E2B_DOCS_BASE || @@ -27,7 +29,103 @@ export const DASHBOARD_BASE = export const SANDBOX_INSPECT_URL = (sandboxId: string) => `${DASHBOARD_BASE}/inspect/sandbox/${sandboxId}` -export function getUserConfig(): UserConfig | null { - if (!fs.existsSync(USER_CONFIG_PATH)) return null - return JSON.parse(fs.readFileSync(USER_CONFIG_PATH, 'utf8')) +function fromTomlProfile(profile: toml.JsonMap): UserConfig { + return { + email: profile['email'] as string | undefined, + accessToken: profile['access_token'] as string | undefined, + teamName: profile['team_name'] as string | undefined, + teamId: profile['team_id'] as string | undefined, + teamApiKey: profile['api_key'] as string | undefined, + domain: profile['domain'] as string | undefined, + dockerProxySet: profile['docker_proxy_set'] as boolean | undefined, + } +} + +function toTomlProfile(config: UserConfig): toml.JsonMap { + const profile: toml.JsonMap = {} + if (config.email !== undefined) profile['email'] = config.email + if (config.accessToken !== undefined) + profile['access_token'] = config.accessToken + if (config.teamName !== undefined) profile['team_name'] = config.teamName + if (config.teamId !== undefined) profile['team_id'] = config.teamId + if (config.teamApiKey !== undefined) profile['api_key'] = config.teamApiKey + if (config.domain !== undefined) profile['domain'] = config.domain + if (config.dockerProxySet !== undefined) + profile['docker_proxy_set'] = config.dockerProxySet + return profile +} + +export function getUserConfig( + profileName: string = 'default' +): UserConfig | null { + if (fs.existsSync(USER_CONFIG_TOML_PATH)) { + const raw = fs.readFileSync(USER_CONFIG_TOML_PATH, 'utf8') + const parsed = toml.parse(raw) as any + const profile = parsed?.profiles?.[profileName] + if (!profile) return null + return fromTomlProfile(profile) + } + + // Fall back to legacy JSON (treated as the default profile) + if (profileName === 'default' && fs.existsSync(USER_CONFIG_PATH)) { + const json = JSON.parse(fs.readFileSync(USER_CONFIG_PATH, 'utf8')) + return { + email: json.email, + accessToken: json.accessToken, + teamName: json.teamName, + teamId: json.teamId, + teamApiKey: json.teamApiKey, + dockerProxySet: json.dockerProxySet, + } + } + + return null +} + +export function saveUserConfig( + config: UserConfig, + profileName: string = 'default' +): void { + let existing: any = { profiles: {} } + + if (fs.existsSync(USER_CONFIG_TOML_PATH)) { + existing = toml.parse(fs.readFileSync(USER_CONFIG_TOML_PATH, 'utf8')) + } else if (fs.existsSync(USER_CONFIG_PATH)) { + // Migrate existing JSON into TOML as the default profile + const json = JSON.parse(fs.readFileSync(USER_CONFIG_PATH, 'utf8')) + existing.profiles['default'] = toTomlProfile({ + email: json.email, + accessToken: json.accessToken, + teamName: json.teamName, + teamId: json.teamId, + teamApiKey: json.teamApiKey, + dockerProxySet: json.dockerProxySet, + }) + } + + if (!existing.profiles) existing.profiles = {} + existing.profiles[profileName] = toTomlProfile(config) + + fs.mkdirSync(path.dirname(USER_CONFIG_TOML_PATH), { recursive: true }) + fs.writeFileSync(USER_CONFIG_TOML_PATH, toml.stringify(existing)) +} + +export function deleteUserProfile(profileName: string = 'default'): void { + if (fs.existsSync(USER_CONFIG_TOML_PATH)) { + const raw = fs.readFileSync(USER_CONFIG_TOML_PATH, 'utf8') + const parsed = toml.parse(raw) as any + if (parsed?.profiles?.[profileName]) { + delete parsed.profiles[profileName] + if (Object.keys(parsed.profiles).length === 0) { + fs.unlinkSync(USER_CONFIG_TOML_PATH) + } else { + fs.writeFileSync(USER_CONFIG_TOML_PATH, toml.stringify(parsed)) + } + } + } + + // Always clean up legacy JSON on default profile logout + if (profileName === 'default' && fs.existsSync(USER_CONFIG_PATH)) { + fs.unlinkSync(USER_CONFIG_PATH) + } } diff --git a/packages/cli/src/utils/format.ts b/packages/cli/src/utils/format.ts index 53b8825fdb..a8b7a1ae25 100644 --- a/packages/cli/src/utils/format.ts +++ b/packages/cli/src/utils/format.ts @@ -9,11 +9,11 @@ import { UserConfig } from '../user' export const primaryColor = '#FFB766' export function asFormattedConfig(config: UserConfig) { - const email = asBold(config.email) + const email = config.email ? asBold(config.email) : asRed('(not set)') const team = config.teamName ? asBold(config.teamName) : asRed('Log out and log in to get team name') - const teamId = asBold(config.teamId) + const teamId = config.teamId ? asBold(config.teamId) : asRed('(not set)') return `You are logged in as ${email},\nSelected team: ${team} (${teamId})` }