diff --git a/.changeset/cli-pty-user-cwd-env.md b/.changeset/cli-pty-user-cwd-env.md new file mode 100644 index 0000000000..c6a16463ed --- /dev/null +++ b/.changeset/cli-pty-user-cwd-env.md @@ -0,0 +1,5 @@ +--- +"@e2b/cli": patch +--- + +Add `--user`, `--cwd`, and `--env` flags to `e2b sandbox create` (and the deprecated `spawn` alias) and `e2b sandbox connect`. These are forwarded to the underlying PTY session so the connected terminal starts as the given user, in the given working directory, and with the given environment variables. `--env` accepts repeatable `KEY=VALUE` pairs. diff --git a/packages/cli/src/commands/sandbox/connect.ts b/packages/cli/src/commands/sandbox/connect.ts index 1f6ba0b5bc..0a46e70d8c 100644 --- a/packages/cli/src/commands/sandbox/connect.ts +++ b/packages/cli/src/commands/sandbox/connect.ts @@ -1,40 +1,67 @@ import * as e2b from 'e2b' import * as commander from 'commander' -import { spawnConnectedTerminal } from 'src/terminal' +import { spawnConnectedTerminal, TerminalOpts } from 'src/terminal' import { asBold, asPrimary } from 'src/utils/format' import { ensureAPIKey } from '../../api' +import { parseEnv } from 'src/utils/env' import { printDashboardSandboxInspectUrl } from 'src/utils/urls' export const connectCommand = new commander.Command('connect') .description('connect terminal to already running sandbox') .argument('', `connect to sandbox with ${asBold('')}`) + .option('-u, --user ', 'user to start the terminal session as') + .option('-c, --cwd ', 'working directory for the terminal session') + .option( + '-e, --env ', + 'set environment variable for the terminal session (repeatable)', + parseEnv, + {} as Record + ) .alias('cn') - .action(async (sandboxID: string) => { - try { - const apiKey = ensureAPIKey() + .action( + async ( + sandboxID: string, + opts: { user?: string; cwd?: string; env?: Record } + ) => { + try { + const apiKey = ensureAPIKey() + + if (!sandboxID) { + console.error('You need to specify sandbox ID') + process.exit(1) + } - if (!sandboxID) { - console.error('You need to specify sandbox ID') + await connectToSandbox({ + apiKey, + sandboxID, + terminal: { + user: opts.user, + cwd: opts.cwd, + envs: + opts.env && Object.keys(opts.env).length > 0 + ? opts.env + : undefined, + }, + }) + // We explicitly call exit because the sandbox is keeping the program alive. + // We also don't want to call sandbox.close because that would disconnect other users from the edit session. + process.exit(0) + } catch (err: any) { + console.error(err) process.exit(1) } - - await connectToSandbox({ apiKey, sandboxID }) - // We explicitly call exit because the sandbox is keeping the program alive. - // We also don't want to call sandbox.close because that would disconnect other users from the edit session. - process.exit(0) - } catch (err: any) { - console.error(err) - process.exit(1) } - }) + ) async function connectToSandbox({ apiKey, sandboxID, + terminal, }: { apiKey: string sandboxID: string + terminal?: TerminalOpts }) { const sandbox = await e2b.Sandbox.connect(sandboxID, { apiKey }) @@ -43,7 +70,7 @@ async function connectToSandbox({ console.log( `Terminal connecting to sandbox ${asPrimary(`${sandbox.sandboxId}`)}` ) - await spawnConnectedTerminal(sandbox) + await spawnConnectedTerminal(sandbox, terminal) console.log( `Closing terminal connection to sandbox ${asPrimary(sandbox.sandboxId)}` ) diff --git a/packages/cli/src/commands/sandbox/create.ts b/packages/cli/src/commands/sandbox/create.ts index 6cf856e55e..89af35f3fc 100644 --- a/packages/cli/src/commands/sandbox/create.ts +++ b/packages/cli/src/commands/sandbox/create.ts @@ -3,12 +3,13 @@ import * as commander from 'commander' import * as path from 'path' import { ensureAPIKey } from 'src/api' -import { spawnConnectedTerminal } from 'src/terminal' +import { spawnConnectedTerminal, TerminalOpts } from 'src/terminal' import { asBold, asFormattedSandboxTemplate } from 'src/utils/format' import { getRoot } from '../../utils/filesystem' import { getConfigPath, loadConfig } from '../../config' import fs from 'fs' import { configOption, pathOption } from '../../options' +import { parseEnv } from '../../utils/env' import { printDashboardSandboxInspectUrl } from 'src/utils/urls' type SandboxLifecycle = { @@ -42,6 +43,14 @@ export function createCommand( 'enable sandbox auto-resume, requires --lifecycle.ontimeout pause' ) .option('--timeout ', 'sandbox timeout in seconds', parseTimeout) + .option('-u, --user ', 'user to start the terminal session as') + .option('-c, --cwd ', 'working directory for the terminal session') + .option( + '-e, --env ', + 'set environment variable for the terminal session (repeatable)', + parseEnv, + {} as Record + ) .alias(alias) .action( async ( @@ -54,6 +63,9 @@ export function createCommand( 'lifecycle.ontimeout'?: SandboxLifecycle['onTimeout'] 'lifecycle.autoresume'?: boolean timeout?: number + user?: string + cwd?: string + env?: Record } ) => { if (deprecated) { @@ -109,6 +121,14 @@ export function createCommand( sandbox, template: { templateID }, timeoutMs: opts.timeout, + terminal: { + user: opts.user, + cwd: opts.cwd, + envs: + opts.env && Object.keys(opts.env).length > 0 + ? opts.env + : undefined, + }, }) } else { console.log( @@ -177,10 +197,12 @@ export async function connectSandbox({ sandbox, template, timeoutMs, + terminal, }: { sandbox: e2b.Sandbox template: Pick timeoutMs?: number + terminal?: TerminalOpts }) { // keep-alive loop — track the in-flight promise so we can await it on shutdown let pendingKeepAlive: Promise = Promise.resolve() @@ -195,7 +217,7 @@ export async function connectSandbox({ )} with sandbox ID ${asBold(`${sandbox.sandboxId}`)}` ) try { - await spawnConnectedTerminal(sandbox) + await spawnConnectedTerminal(sandbox, terminal) } finally { clearInterval(intervalId) await pendingKeepAlive.catch(() => {}) diff --git a/packages/cli/src/commands/sandbox/exec.ts b/packages/cli/src/commands/sandbox/exec.ts index 959ee56ae1..f337f5803e 100644 --- a/packages/cli/src/commands/sandbox/exec.ts +++ b/packages/cli/src/commands/sandbox/exec.ts @@ -7,6 +7,7 @@ import * as commander from 'commander' import { ensureAPIKey } from '../../api' import { setupSignalHandlers } from 'src/utils/signal' +import { parseEnv } from 'src/utils/env' import { buildCommand, isPipedStdin, streamStdinChunks } from './exec_helpers' interface ExecOptions { @@ -27,13 +28,7 @@ export const execCommand = new commander.Command('exec') .option( '-e, --env ', 'set environment variable (repeatable)', - (value: string, previous: Record) => { - const [key, ...rest] = value.split('=') - if (key && rest.length > 0) { - previous[key] = rest.join('=') - } - return previous - }, + parseEnv, {} as Record ) .alias('ex') diff --git a/packages/cli/src/terminal.ts b/packages/cli/src/terminal.ts index 713314e638..242143dbdb 100644 --- a/packages/cli/src/terminal.ts +++ b/packages/cli/src/terminal.ts @@ -9,7 +9,16 @@ function getStdoutSize() { } } -export async function spawnConnectedTerminal(sandbox: e2b.Sandbox) { +export interface TerminalOpts { + user?: e2b.Username + cwd?: string + envs?: Record +} + +export async function spawnConnectedTerminal( + sandbox: e2b.Sandbox, + opts: TerminalOpts = {} +) { // Clear local terminal emulator before starting terminal // process.stdout.write('\x1b[2J\x1b[0f') @@ -22,6 +31,9 @@ export async function spawnConnectedTerminal(sandbox: e2b.Sandbox) { }, ...getStdoutSize(), timeoutMs: 0, + ...(opts.user !== undefined ? { user: opts.user } : {}), + ...(opts.cwd !== undefined ? { cwd: opts.cwd } : {}), + ...(opts.envs !== undefined ? { envs: opts.envs } : {}), }) const inputQueue = new BatchedQueue(async (batch) => { diff --git a/packages/cli/src/utils/env.ts b/packages/cli/src/utils/env.ts new file mode 100644 index 0000000000..5a7c372461 --- /dev/null +++ b/packages/cli/src/utils/env.ts @@ -0,0 +1,16 @@ +/** + * Commander arg parser for repeatable `--env KEY=VALUE` flags. + * + * Accumulates parsed pairs into `previous` so the flag can be passed multiple + * times. Values may contain `=` (only the first `=` separates key from value). + */ +export function parseEnv( + value: string, + previous: Record +): Record { + const [key, ...rest] = value.split('=') + if (key && rest.length > 0) { + previous[key] = rest.join('=') + } + return previous +} diff --git a/packages/cli/tests/commands/sandbox/create_lifecycle.test.ts b/packages/cli/tests/commands/sandbox/create_lifecycle.test.ts index d4200f0cb4..4650cb49cd 100644 --- a/packages/cli/tests/commands/sandbox/create_lifecycle.test.ts +++ b/packages/cli/tests/commands/sandbox/create_lifecycle.test.ts @@ -137,13 +137,79 @@ describe('sandbox create lifecycle options', () => { { from: 'user' } ) - expect(mocks.spawnConnectedTerminal).toHaveBeenCalledWith(sandbox) + expect(mocks.spawnConnectedTerminal).toHaveBeenCalledWith(sandbox, { + user: undefined, + cwd: undefined, + envs: undefined, + }) expect(sandbox.setTimeout).toHaveBeenCalledWith(120_000) expect(sandbox.setTimeout).not.toHaveBeenCalledWith(1_000) expect(exitSpy).toHaveBeenCalledWith(0) vi.useRealTimers() }) + test('passes user, cwd and envs to the connected terminal', async () => { + const exitSpy = vi + .spyOn(process, 'exit') + .mockImplementation((() => undefined) as never) + const sandbox = { + sandboxId: 'sandbox-id', + setTimeout: vi.fn().mockResolvedValue(undefined), + } + mocks.create.mockResolvedValue(sandbox) + + const { createCommand } = await import( + '../../../src/commands/sandbox/create' + ) + await createCommand('create', 'cr', false).parseAsync( + [ + 'base', + '--user', + 'root', + '--cwd', + '/app', + '--env', + 'FOO=bar', + '--env', + 'BAZ=qux=quux', + ], + { from: 'user' } + ) + + expect(mocks.spawnConnectedTerminal).toHaveBeenCalledWith(sandbox, { + user: 'root', + cwd: '/app', + envs: { FOO: 'bar', BAZ: 'qux=quux' }, + }) + expect(exitSpy).toHaveBeenCalledWith(0) + }) + + test('omits empty envs when no --env flags are provided', async () => { + const exitSpy = vi + .spyOn(process, 'exit') + .mockImplementation((() => undefined) as never) + const sandbox = { + sandboxId: 'sandbox-id', + setTimeout: vi.fn().mockResolvedValue(undefined), + } + mocks.create.mockResolvedValue(sandbox) + + const { createCommand } = await import( + '../../../src/commands/sandbox/create' + ) + await createCommand('create', 'cr', false).parseAsync( + ['base', '--user', 'root'], + { from: 'user' } + ) + + expect(mocks.spawnConnectedTerminal).toHaveBeenCalledWith(sandbox, { + user: 'root', + cwd: undefined, + envs: undefined, + }) + expect(exitSpy).toHaveBeenCalledWith(0) + }) + test('rejects autoresume without pause on timeout', async () => { const exitSpy = vi .spyOn(process, 'exit') diff --git a/packages/cli/tests/utils/env.test.ts b/packages/cli/tests/utils/env.test.ts new file mode 100644 index 0000000000..8ba362081a --- /dev/null +++ b/packages/cli/tests/utils/env.test.ts @@ -0,0 +1,30 @@ +import { describe, expect, test } from 'vitest' + +import { parseEnv } from '../../src/utils/env' + +describe('parseEnv', () => { + test('accumulates repeated KEY=VALUE pairs', () => { + const acc: Record = {} + parseEnv('FOO=bar', acc) + parseEnv('BAZ=qux', acc) + expect(acc).toEqual({ FOO: 'bar', BAZ: 'qux' }) + }) + + test('keeps "=" inside the value', () => { + const acc: Record = {} + parseEnv('TOKEN=a=b=c', acc) + expect(acc).toEqual({ TOKEN: 'a=b=c' }) + }) + + test('ignores entries without a value', () => { + const acc: Record = {} + parseEnv('NOVALUE', acc) + expect(acc).toEqual({}) + }) + + test('preserves an empty string value', () => { + const acc: Record = {} + parseEnv('EMPTY=', acc) + expect(acc).toEqual({ EMPTY: '' }) + }) +})