Skip to content
Open
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-pty-user-cwd-env.md
Original file line number Diff line number Diff line change
@@ -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.
59 changes: 43 additions & 16 deletions packages/cli/src/commands/sandbox/connect.ts
Original file line number Diff line number Diff line change
@@ -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('<sandboxID>', `connect to sandbox with ${asBold('<sandboxID>')}`)
.option('-u, --user <user>', 'user to start the terminal session as')
.option('-c, --cwd <dir>', 'working directory for the terminal session')
.option(
'-e, --env <KEY=VALUE>',
'set environment variable for the terminal session (repeatable)',
parseEnv,
{} as Record<string, string>
)
.alias('cn')
.action(async (sandboxID: string) => {
try {
const apiKey = ensureAPIKey()
.action(
async (
sandboxID: string,
opts: { user?: string; cwd?: string; env?: Record<string, string> }
) => {
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 })

Expand All @@ -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)}`
)
Expand Down
26 changes: 24 additions & 2 deletions packages/cli/src/commands/sandbox/create.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand Down Expand Up @@ -42,6 +43,14 @@ export function createCommand(
'enable sandbox auto-resume, requires --lifecycle.ontimeout pause'
)
.option('--timeout <seconds>', 'sandbox timeout in seconds', parseTimeout)
.option('-u, --user <user>', 'user to start the terminal session as')
.option('-c, --cwd <dir>', 'working directory for the terminal session')
.option(
'-e, --env <KEY=VALUE>',
'set environment variable for the terminal session (repeatable)',
parseEnv,
{} as Record<string, string>
)
.alias(alias)
.action(
async (
Expand All @@ -54,6 +63,9 @@ export function createCommand(
'lifecycle.ontimeout'?: SandboxLifecycle['onTimeout']
'lifecycle.autoresume'?: boolean
timeout?: number
user?: string
cwd?: string
env?: Record<string, string>
}
) => {
if (deprecated) {
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -177,10 +197,12 @@ export async function connectSandbox({
sandbox,
template,
timeoutMs,
terminal,
}: {
sandbox: e2b.Sandbox
template: Pick<e2b.components['schemas']['Template'], 'templateID'>
timeoutMs?: number
terminal?: TerminalOpts
}) {
// keep-alive loop — track the in-flight promise so we can await it on shutdown
let pendingKeepAlive: Promise<void> = Promise.resolve()
Expand All @@ -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(() => {})
Expand Down
9 changes: 2 additions & 7 deletions packages/cli/src/commands/sandbox/exec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -27,13 +28,7 @@ export const execCommand = new commander.Command('exec')
.option(
'-e, --env <KEY=VALUE>',
'set environment variable (repeatable)',
(value: string, previous: Record<string, string>) => {
const [key, ...rest] = value.split('=')
if (key && rest.length > 0) {
previous[key] = rest.join('=')
}
return previous
},
parseEnv,
{} as Record<string, string>
)
.alias('ex')
Expand Down
14 changes: 13 additions & 1 deletion packages/cli/src/terminal.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,16 @@ function getStdoutSize() {
}
}

export async function spawnConnectedTerminal(sandbox: e2b.Sandbox) {
export interface TerminalOpts {
user?: e2b.Username
cwd?: string
envs?: Record<string, string>
}

export async function spawnConnectedTerminal(
sandbox: e2b.Sandbox,
opts: TerminalOpts = {}
) {
// Clear local terminal emulator before starting terminal
// process.stdout.write('\x1b[2J\x1b[0f')

Expand All @@ -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<Buffer>(async (batch) => {
Expand Down
16 changes: 16 additions & 0 deletions packages/cli/src/utils/env.ts
Original file line number Diff line number Diff line change
@@ -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<string, string>
): Record<string, string> {
const [key, ...rest] = value.split('=')
if (key && rest.length > 0) {
previous[key] = rest.join('=')
}
return previous
}
68 changes: 67 additions & 1 deletion packages/cli/tests/commands/sandbox/create_lifecycle.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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')
Expand Down
30 changes: 30 additions & 0 deletions packages/cli/tests/utils/env.test.ts
Original file line number Diff line number Diff line change
@@ -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<string, string> = {}
parseEnv('FOO=bar', acc)
parseEnv('BAZ=qux', acc)
expect(acc).toEqual({ FOO: 'bar', BAZ: 'qux' })
})

test('keeps "=" inside the value', () => {
const acc: Record<string, string> = {}
parseEnv('TOKEN=a=b=c', acc)
expect(acc).toEqual({ TOKEN: 'a=b=c' })
})

test('ignores entries without a value', () => {
const acc: Record<string, string> = {}
parseEnv('NOVALUE', acc)
expect(acc).toEqual({})
})

test('preserves an empty string value', () => {
const acc: Record<string, string> = {}
parseEnv('EMPTY=', acc)
expect(acc).toEqual({ EMPTY: '' })
})
})
Loading