chore: import upstream snapshot with attribution

This commit is contained in:
wehub-resource-sync
2026-07-13 12:47:58 +08:00
commit b16403ea71
789 changed files with 115226 additions and 0 deletions
+3
View File
@@ -0,0 +1,3 @@
source_up
dotenv ../../../.env.local
+45
View File
@@ -0,0 +1,45 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# dependencies
/node_modules
tests/temp
coverage
/.pnp
.pnp.js
# testing
/coverage
# misc
.DS_Store
.env.local
.env.development.local
.env.test.local
.env.production.local
npm-debug.log*
yarn-debug.log*
yarn-error.log*
# Optional npm cache directory
.npm
# Optional eslint cache
.eslintcache
# Output of 'npm pack'
*.tgz
# Yarn Integrity file
.yarn-integrity
# generate output
dist
# compiled binary addons (https://nodejs.org/api/addons.html)
build/Release
/test.md
logs.txt
/dist
/files
+2
View File
@@ -0,0 +1,2 @@
*.hbs
tests/**/fixtures/**/*
+9
View File
@@ -0,0 +1,9 @@
MIT License
Copyright (c) 2025 FOUNDRYLABS, INC.
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
+44
View File
@@ -0,0 +1,44 @@
<p align="center">
<picture>
<source media="(prefers-color-scheme: dark)" srcset="https://raw.githubusercontent.com/e2b-dev/E2B/refs/heads/main/readme-assets/logo-white.png">
<source media="(prefers-color-scheme: light)" srcset="https://raw.githubusercontent.com/e2b-dev/E2B/refs/heads/main/readme-assets/logo-black.png">
<img alt="E2B Logo" src="https://raw.githubusercontent.com/e2b-dev/E2B/refs/heads/main/readme-assets/logo-black.png" width="200">
</picture>
</p>
# E2B CLI
This CLI tool allows you to build manager your running E2B sandbox and sandbox templates. Learn more in [our documentation](https://e2b.dev/docs).
### 1. Install the CLI
**Using Homebrew (on macOS)**
```bash
brew install e2b
```
**Using NPM**
```bash
npm install -g @e2b/cli
```
### 2. Authenticate
```bash
e2b auth login
```
> [!NOTE]
> To authenticate without the ability to open the browser, provide
> `E2B_ACCESS_TOKEN` as an environment variable. You can find your token
> in Account Settings under the Team selector at [e2b.dev/dashboard](https://e2b.dev/dashboard). Then use the CLI like this:
> `E2B_ACCESS_TOKEN=sk_e2b_... e2b template create`.
> [!IMPORTANT]
> Note the distinction between `E2B_ACCESS_TOKEN` and `E2B_API_KEY`.
### 3. Check out docs
Visit our [CLI documentation](https://e2b.dev/docs) to learn more.
+94
View File
@@ -0,0 +1,94 @@
{
"name": "@e2b/cli",
"version": "2.13.1",
"description": "CLI for managing e2b sandbox templates",
"homepage": "https://e2b.dev",
"license": "MIT",
"author": {
"name": "FoundryLabs, Inc.",
"email": "hello@e2b.dev",
"url": "https://e2b.dev"
},
"bugs": "https://github.com/e2b-dev/e2b/issues",
"repository": {
"type": "git",
"url": "https://github.com/e2b-dev/e2b",
"directory": "packages/cli"
},
"publishConfig": {
"access": "public"
},
"keywords": [
"e2b",
"ai-agents",
"agents",
"ai",
"code-interpreter",
"sandbox",
"code",
"cli",
"runtime",
"vm",
"nodejs",
"javascript",
"typescript"
],
"sideEffects": false,
"scripts": {
"prepublishOnly": "pnpm build",
"build": "tsc --noEmit --skipLibCheck && tsdown --minify",
"dev": "tsdown --watch",
"typecheck": "tsc --noEmit --skipLibCheck",
"lint": "oxlint --config ../../.oxlintrc.json src",
"format": "prettier --write src",
"test:interactive": "pnpm build && ./dist/index.js",
"test": "vitest run",
"test:watch": "vitest watch",
"test:coverage": "vitest run --coverage",
"check-deps": "knip"
},
"devDependencies": {
"@types/handlebars": "^4.1.0",
"@types/inquirer": "^9.0.7",
"@types/json2md": "^1.5.4",
"@types/node": "^20.19.19",
"@types/npmcli__package-json": "^4.0.4",
"@types/statuses": "^2.0.5",
"@typescript/native": "npm:typescript@^7.0.2",
"@vitest/coverage-v8": "^4.1.0",
"json2md": "^2.0.1",
"knip": "^5.43.6",
"tsdown": "^0.22.3",
"typescript": "npm:@typescript/typescript6@^6.0.2",
"vitest": "^4.1.8"
},
"files": [
"dist",
"LICENSE",
"README",
"package.json"
],
"bin": {
"e2b": "dist/index.js"
},
"dependencies": {
"@iarna/toml": "^2.2.5",
"@inquirer/prompts": "^7.9.0",
"@npmcli/package-json": "^5.2.1",
"async-listen": "^3.0.1",
"boxen": "^7.1.1",
"chalk": "^5.3.0",
"cli-highlight": "^2.1.11",
"commander": "^11.1.0",
"console-table-printer": "^2.11.2",
"e2b": "^2.32.0",
"handlebars": "^4.7.9",
"inquirer": "^12.10.0",
"simple-update-notifier": "^2.0.0",
"statuses": "^2.0.1",
"yup": "^1.3.2"
},
"engines": {
"node": ">=20.18.1 <21 || >=22"
}
}
+114
View File
@@ -0,0 +1,114 @@
import * as boxen from 'boxen'
import * as e2b from 'e2b'
import { getUserConfig, UserConfig } from './user'
import { asBold, asPrimary } from './utils/format'
export type Teams =
e2b.paths['/teams']['get']['responses'][200]['content']['application/json']
export let apiKey = process.env.E2B_API_KEY
export let accessToken = process.env.E2B_ACCESS_TOKEN
export const teamId = process.env.E2B_TEAM_ID
const authErrorBox = (keyName: 'E2B_API_KEY' | 'E2B_ACCESS_TOKEN') => {
const link =
keyName === 'E2B_API_KEY'
? 'https://e2b.dev/dashboard?tab=keys'
: 'https://e2b.dev/dashboard?tab=personal'
const msg = keyName === 'E2B_API_KEY' ? 'API key' : 'access token'
const body = `You must be logged in to use this command. Run ${asBold(
'e2b auth login'
)}.
If you are seeing this message in CI/CD you may need to set the ${asBold(
keyName
)} environment variable.
Visit ${asPrimary(link)} to get the ${msg}.`
return boxen.default(body, {
width: 70,
float: 'center',
padding: 0.5,
margin: 1,
borderStyle: 'round',
borderColor: 'redBright',
})
}
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
}
if (!apiKey) {
console.error(authErrorBox('E2B_API_KEY'))
process.exit(1)
} else {
return apiKey
}
}
export function ensureUserConfig(): UserConfig {
const userConfig = getUserConfig()
if (!userConfig) {
console.error('No user config found, run `e2b auth login` to log in first.')
process.exit(1)
}
return 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?.tokens.access_token
}
if (!accessToken) {
console.error(authErrorBox('E2B_ACCESS_TOKEN'))
process.exit(1)
} else {
return accessToken
}
}
/**
* Resolve team ID with proper precedence:
* 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,
* to avoid mismatch between env var API key and config file team ID)
*/
export function resolveTeamId(
cliTeamId?: string,
localConfigTeamId?: string
): string | undefined {
if (cliTeamId) return cliTeamId
if (teamId) return teamId
if (localConfigTeamId) return localConfigTeamId
if (!process.env.E2B_API_KEY) {
const config = getUserConfig()
return config?.teamId
}
return undefined
}
const userConfig = getUserConfig()
const resolvedAccessToken =
process.env.E2B_ACCESS_TOKEN || userConfig?.tokens.access_token
export const connectionConfig = new e2b.ConnectionConfig({
apiKey: process.env.E2B_API_KEY || userConfig?.teamApiKey,
apiHeaders: resolvedAccessToken
? { Authorization: `Bearer ${resolvedAccessToken}` }
: undefined,
})
// The CLI authenticates team-scoped endpoints (e.g. `/teams`) with the access
// token instead of an API key, so don't require an API key here.
export const client = new e2b.ApiClient(connectionConfig, {
requireApiKey: false,
})
@@ -0,0 +1,68 @@
import * as commander from 'commander'
import * as fs from 'fs'
import * as chalk from 'chalk'
import * as e2b from 'e2b'
import {
USER_CONFIG_PATH,
getConfigRefreshTimestamp,
writeUserConfig,
} from 'src/user'
import { ensureUserConfig, Teams } from 'src/api'
import { ensureValidAccessToken } from 'src/utils/token-refresh'
import { asBold, asFormattedTeam } from '../../utils/format'
import { handleE2BRequestError } from '../../utils/errors'
export const configureCommand = new commander.Command('configure')
.description('configure user')
.action(async () => {
const inquirer = await import('inquirer')
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.')
return
}
// ensureValidAccessToken may refresh tokens and write them to disk.
// Re-read the config afterwards so we persist the refreshed tokens
// instead of overwriting them with stale in-memory copies.
const accessToken = await ensureValidAccessToken()
const userConfig = ensureUserConfig()
const config = new e2b.ConnectionConfig({
apiHeaders: { Authorization: `Bearer ${accessToken}` },
})
const authClient = new e2b.ApiClient(config, { requireApiKey: false })
const res = await authClient.api.GET('/teams', {
signal: config.getSignal(),
})
handleE2BRequestError(res, 'Error getting teams')
const teams = res.data as Teams
const team = (
await inquirer.default.prompt([
{
name: 'team',
message: chalk.default.underline('Select team'),
type: 'list',
pageSize: 50,
choices: teams.map((team) => ({
name: asFormattedTeam(team, userConfig.teamId),
value: team,
})),
},
])
)['team']
userConfig.teamName = team.name
userConfig.teamId = team.teamID
userConfig.teamApiKey = team.apiKey
userConfig.last_refresh = getConfigRefreshTimestamp()
writeUserConfig(USER_CONFIG_PATH, userConfig)
console.log(`Team ${asBold(team.name)} (${team.teamID}) selected.\n`)
})
+12
View File
@@ -0,0 +1,12 @@
import * as commander from 'commander'
import { loginCommand } from './login'
import { logoutCommand } from './logout'
import { infoCommand } from './info'
import { configureCommand } from './configure'
export const authCommand = new commander.Command('auth')
.description('authentication commands')
.addCommand(loginCommand)
.addCommand(logoutCommand)
.addCommand(infoCommand)
.addCommand(configureCommand)
+24
View File
@@ -0,0 +1,24 @@
import * as commander from 'commander'
import { getUserConfig } from 'src/user'
import { asFormattedConfig, asFormattedError } from 'src/utils/format'
export const infoCommand = new commander.Command('info')
.description('get information about the current user')
.action(async () => {
let userConfig
try {
userConfig = getUserConfig()
} catch (err) {
console.error(asFormattedError('Failed to read user config', err))
}
if (!userConfig) {
console.log('Not logged in')
return
}
console.log(asFormattedConfig(userConfig))
process.exit(0)
})
+169
View File
@@ -0,0 +1,169 @@
import * as listen from 'async-listen'
import * as commander from 'commander'
import * as http from 'http'
import * as e2b from 'e2b'
import { pkg } from 'src'
import {
DOCS_BASE,
getConfigRefreshTimestamp,
getUserConfig,
writeUserConfig,
USER_CONFIG_PATH,
UserConfig,
} from 'src/user'
import { asBold, asFormattedConfig, asFormattedError } from 'src/utils/format'
import { openUrlInBrowser } from 'src/utils/openBrowser'
import { connectionConfig, Teams } from 'src/api'
import { handleE2BRequestError } from '../../utils/errors'
export const loginCommand = new commander.Command('login')
.description('log in to CLI')
.action(async () => {
let userConfig: UserConfig | null = null
try {
userConfig = getUserConfig()
} catch (err) {
console.error(asFormattedError('Failed to read user config', err))
}
if (userConfig) {
console.log(
`\nAlready logged in. ${asFormattedConfig(
userConfig
)}.\n\nIf you want to log in as a different user, log out first by running 'e2b auth logout'.\nTo change the team, run 'e2b auth configure'.\n`
)
return
} else if (!userConfig) {
console.log('Attempting to log in...')
const signInResponse = await signInWithBrowser()
if (!signInResponse) {
console.info('Login aborted')
return
}
const accessToken = signInResponse.accessToken
const signal = connectionConfig.getSignal()
const config = new e2b.ConnectionConfig({
apiHeaders: { Authorization: `Bearer ${accessToken}` },
})
const client = new e2b.ApiClient(config, { requireApiKey: false })
const res = await client.api.GET('/teams', {
signal,
})
handleE2BRequestError(res, 'Error getting teams')
const teams = res.data as Teams
const defaultTeam = teams.find((team) => team.isDefault)
if (!defaultTeam) {
console.error(
asFormattedError('No default team found, please contact support')
)
process.exit(1)
}
userConfig = {
version: 1,
identity: {
email: signInResponse.email,
},
oauth: {
token_endpoint: signInResponse.tokenEndpoint,
revoke_endpoint: signInResponse.revokeEndpoint,
client_id: signInResponse.clientId,
},
tokens: {
access_token: accessToken,
refresh_token: signInResponse.refreshToken,
},
last_refresh: getConfigRefreshTimestamp(),
teamName: defaultTeam.name,
teamId: defaultTeam.teamID,
teamApiKey: defaultTeam.apiKey,
}
writeUserConfig(USER_CONFIG_PATH, userConfig)
}
console.log(
`Logged in as ${asBold(
userConfig.identity.email
)} with selected team ${asBold(userConfig.teamName)}`
)
process.exit(0)
})
interface SignInWithBrowserResponse {
email: string
accessToken: string
refreshToken: string
tokenEndpoint: string
revokeEndpoint: string
clientId: string
}
async function signInWithBrowser(): Promise<SignInWithBrowserResponse> {
const server = http.createServer()
const { port } = await listen.default(server, 0, '127.0.0.1')
const loginUrl = new URL(`${DOCS_BASE}/api/cli`)
loginUrl.searchParams.set('next', `http://localhost:${port}`)
loginUrl.searchParams.set('cliVersion', pkg.version)
return new Promise((resolve, reject) => {
server.once('request', (req, res) => {
// Close the HTTP connection to prevent `server.close()` from hanging
res.setHeader('connection', 'close')
const followUpUrl = new URL(`${DOCS_BASE}/api/cli`)
const searchParams = new URL(req.url || '/', 'http://localhost')
.searchParams
const searchParamsObj = Object.fromEntries(
searchParams.entries()
) as unknown as SignInWithBrowserResponse & {
error?: string
}
const { error } = searchParamsObj
if (error) {
reject(new Error(error))
followUpUrl.searchParams.set('state', 'error')
followUpUrl.searchParams.set('error', error)
} else if (
!searchParamsObj.email ||
!searchParamsObj.accessToken ||
!searchParamsObj.refreshToken ||
!searchParamsObj.tokenEndpoint ||
!searchParamsObj.revokeEndpoint ||
!searchParamsObj.clientId
) {
reject(new Error('Incomplete login response from server'))
followUpUrl.searchParams.set('state', 'error')
followUpUrl.searchParams.set(
'error',
'Incomplete login response from server'
)
} else {
resolve(searchParamsObj)
followUpUrl.searchParams.set('state', 'success')
followUpUrl.searchParams.set('email', searchParamsObj.email!)
}
res.statusCode = 302
res.setHeader('location', followUpUrl.href)
res.end()
})
let manualUrlPrinted = false
const printManualUrl = () => {
if (manualUrlPrinted) return
manualUrlPrinted = true
console.log(
`\nCould not open a browser automatically. Please open the following URL manually to continue:\n\n${loginUrl.toString()}\n\nIf interactive login is unavailable, you can also authenticate by setting the ${asBold(
'E2B_API_KEY'
)} environment variable instead.\n`
)
}
openUrlInBrowser(loginUrl.toString(), printManualUrl)
})
}
+36
View File
@@ -0,0 +1,36 @@
import * as commander from 'commander'
import * as fs from 'fs'
import { getUserConfig, USER_CONFIG_PATH } from 'src/user'
export const logoutCommand = new commander.Command('logout')
.description('log out of CLI')
.action(async () => {
if (!fs.existsSync(USER_CONFIG_PATH)) {
console.log('Not logged in, nothing to do')
return
}
let config
try {
config = getUserConfig()
} catch {
// Malformed config file — proceed to delete it below
}
if (config) {
await fetch(config.oauth.revoke_endpoint, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({
token: config.tokens.refresh_token,
token_type_hint: 'refresh_token',
client_id: config.oauth.client_id,
}),
}).catch(() => {})
}
if (fs.existsSync(USER_CONFIG_PATH)) {
fs.unlinkSync(USER_CONFIG_PATH)
}
console.log('Logged out.')
})
+21
View File
@@ -0,0 +1,21 @@
import * as commander from 'commander'
import { asPrimary } from 'src/utils/format'
import { templateCommand } from './template'
import { sandboxCommand } from './sandbox'
import { authCommand } from './auth'
export const program = new commander.Command()
.description(
`Create sandbox templates from Dockerfiles by running ${asPrimary(
'e2b template create'
)} then use our SDKs to create sandboxes from these templates.
Visit ${asPrimary(
'E2B docs (https://e2b.dev/docs)'
)} to learn how to create sandbox templates and start sandboxes.
`
)
.addCommand(authCommand)
.addCommand(templateCommand)
.addCommand(sandboxCommand)
@@ -0,0 +1,50 @@
import * as e2b from 'e2b'
import * as commander from 'commander'
import { spawnConnectedTerminal } from 'src/terminal'
import { asBold, asPrimary } from 'src/utils/format'
import { ensureAPIKey } from '../../api'
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>')}`)
.alias('cn')
.action(async (sandboxID: string) => {
try {
const apiKey = ensureAPIKey()
if (!sandboxID) {
console.error('You need to specify sandbox ID')
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,
}: {
apiKey: string
sandboxID: string
}) {
const sandbox = await e2b.Sandbox.connect(sandboxID, { apiKey })
printDashboardSandboxInspectUrl(sandbox.sandboxId)
console.log(
`Terminal connecting to sandbox ${asPrimary(`${sandbox.sandboxId}`)}`
)
await spawnConnectedTerminal(sandbox)
console.log(
`Closing terminal connection to sandbox ${asPrimary(sandbox.sandboxId)}`
)
}
+209
View File
@@ -0,0 +1,209 @@
import * as e2b from 'e2b'
import * as commander from 'commander'
import * as path from 'path'
import { ensureAPIKey } from 'src/api'
import { spawnConnectedTerminal } 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 { printDashboardSandboxInspectUrl } from 'src/utils/urls'
type SandboxLifecycle = {
onTimeout: 'pause' | 'kill'
autoResume?: boolean
}
const MIN_TIMEOUT_MS = 30_000
export function createCommand(
name: string,
alias: string,
deprecated: boolean
) {
return new commander.Command(name)
.description('create sandbox and connect terminal to it')
.argument(
'[template]',
`create and connect to sandbox specified by ${asBold('[template]')}`
)
.addOption(pathOption)
.addOption(configOption)
.option('-d, --detach', 'create sandbox without connecting terminal to it')
.option(
'--lifecycle.ontimeout <action>',
'action when sandbox timeout is reached: pause or kill',
parseOnTimeout
)
.option(
'--lifecycle.autoresume',
'enable sandbox auto-resume, requires --lifecycle.ontimeout pause'
)
.option('--timeout <seconds>', 'sandbox timeout in seconds', parseTimeout)
.alias(alias)
.action(
async (
template: string | undefined,
opts: {
name?: string
path?: string
config?: string
detach?: boolean
'lifecycle.ontimeout'?: SandboxLifecycle['onTimeout']
'lifecycle.autoresume'?: boolean
timeout?: number
}
) => {
if (deprecated) {
console.warn(
`Warning: The '${name}' command is deprecated and will be removed in future releases. Please use 'e2b sandbox create' instead.`
)
}
try {
const apiKey = ensureAPIKey()
let templateID = template
const root = getRoot(opts.path)
const configPath = getConfigPath(root, opts.config)
const config = fs.existsSync(configPath)
? await loadConfig(configPath)
: undefined
const relativeConfigPath = path.relative(root, configPath)
if (!templateID && config) {
console.log(
`Found sandbox template ${asFormattedSandboxTemplate(
{
templateID: config.template_id,
aliases: config.template_name
? [config.template_name]
: undefined,
},
relativeConfigPath
)}`
)
templateID = config.template_id
}
if (!templateID) {
templateID = 'base'
}
const lifecycle = buildLifecycle(
opts['lifecycle.ontimeout'],
opts['lifecycle.autoresume']
)
const sandboxOpts = {
apiKey,
...(lifecycle ? { lifecycle } : {}),
...(opts.timeout !== undefined ? { timeoutMs: opts.timeout } : {}),
}
const sandbox = await e2b.Sandbox.create(templateID, sandboxOpts)
printDashboardSandboxInspectUrl(sandbox.sandboxId)
if (!opts.detach) {
await connectSandbox({
sandbox,
template: { templateID },
timeoutMs: opts.timeout,
})
} else {
console.log(
`Sandbox created with ID ${sandbox.sandboxId} using template ${templateID}`
)
}
process.exit(0)
} catch (err: any) {
console.error(err)
process.exit(1)
}
}
)
}
function parseTimeout(timeoutRaw: string): number {
const timeoutSeconds = Number(timeoutRaw)
const timeoutMs = Math.floor(timeoutSeconds * 1000)
if (
!Number.isFinite(timeoutSeconds) ||
timeoutSeconds <= 0 ||
timeoutMs < MIN_TIMEOUT_MS
) {
throw new commander.InvalidArgumentError(
'--timeout must be at least 30 seconds'
)
}
return timeoutMs
}
function parseOnTimeout(onTimeout: string): SandboxLifecycle['onTimeout'] {
if (onTimeout !== 'pause' && onTimeout !== 'kill') {
throw new commander.InvalidArgumentError(
'--lifecycle.ontimeout must be "pause" or "kill"'
)
}
return onTimeout
}
function buildLifecycle(
onTimeout: SandboxLifecycle['onTimeout'] | undefined,
autoResume: boolean | undefined
): SandboxLifecycle | undefined {
if (!onTimeout && !autoResume) {
return undefined
}
if (autoResume && onTimeout !== 'pause') {
throw new commander.InvalidArgumentError(
'--lifecycle.autoresume requires --lifecycle.ontimeout pause'
)
}
if (!onTimeout) {
throw new commander.InvalidArgumentError(
'--lifecycle.ontimeout is required when using --lifecycle.autoresume'
)
}
return { onTimeout, ...(autoResume ? { autoResume: true } : {}) }
}
export async function connectSandbox({
sandbox,
template,
timeoutMs,
}: {
sandbox: e2b.Sandbox
template: Pick<e2b.components['schemas']['Template'], 'templateID'>
timeoutMs?: number
}) {
// keep-alive loop — track the in-flight promise so we can await it on shutdown
let pendingKeepAlive: Promise<void> = Promise.resolve()
const keepAliveTimeoutMs = timeoutMs ?? 30_000
const intervalId = setInterval(() => {
pendingKeepAlive = sandbox.setTimeout(keepAliveTimeoutMs)
}, 5_000)
console.log(
`Terminal connecting to template ${asFormattedSandboxTemplate(
template
)} with sandbox ID ${asBold(`${sandbox.sandboxId}`)}`
)
try {
await spawnConnectedTerminal(sandbox)
} finally {
clearInterval(intervalId)
await pendingKeepAlive.catch(() => {})
await sandbox.setTimeout(timeoutMs ?? 1_000)
console.log(
`Closing terminal connection to template ${asFormattedSandboxTemplate(
template
)} with sandbox ID ${asBold(`${sandbox.sandboxId}`)}`
)
}
}
+218
View File
@@ -0,0 +1,218 @@
/**
* Execute a command in a running sandbox.
*/
import { Sandbox, CommandExitError, NotFoundError } from 'e2b'
import * as commander from 'commander'
import { ensureAPIKey } from '../../api'
import { setupSignalHandlers } from 'src/utils/signal'
import { buildCommand, isPipedStdin, streamStdinChunks } from './exec_helpers'
interface ExecOptions {
background?: boolean
cwd?: string
user?: string
env?: Record<string, string>
}
const NO_COMMAND_TIMEOUT = 0
export const execCommand = new commander.Command('exec')
.description('execute a command in a running sandbox')
.argument('<sandboxID>', 'sandbox ID to execute command in')
.argument('<command...>', 'command to execute')
.option('-b, --background', 'run in background and return immediately')
.option('-c, --cwd <dir>', 'working directory')
.option('-u, --user <user>', 'run as specified user')
.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
},
{} as Record<string, string>
)
.alias('ex')
.action(
async (sandboxID: string, commandParts: string[], opts: ExecOptions) => {
const hasPipedStdin = isPipedStdin()
const command = buildCommand(commandParts)
try {
const apiKey = ensureAPIKey()
const sandbox = await Sandbox.connect(sandboxID, { apiKey })
if (hasPipedStdin && !sandbox.commands.supportsStdinClose) {
console.error(
'e2b: Warning: Piped stdin is not supported by this sandbox version.\n' +
'e2b: Rebuild your template to pick up the latest sandbox version.\n' +
'e2b: Ignoring piped stdin.'
)
}
const canPipeStdin =
hasPipedStdin && sandbox.commands.supportsStdinClose
if (opts.background) {
const handle = await sandbox.commands.run(command, {
background: true,
cwd: opts.cwd,
user: opts.user,
envs: opts.env,
timeoutMs: NO_COMMAND_TIMEOUT,
...(canPipeStdin ? { stdin: true } : {}),
})
const removeSignalHandlers = setupSignalHandlers(async () => {
await handle.kill()
})
try {
if (canPipeStdin) {
await sendStdin(sandbox, handle.pid)
}
} finally {
removeSignalHandlers()
}
console.error(handle.pid)
await handle.disconnect()
// We always exit with code 0 when running in background.
process.exit(0)
}
const exitCode = await runCommand(sandbox, command, opts, canPipeStdin)
process.exit(exitCode)
} catch (err: any) {
console.error(err)
process.exit(1)
}
}
)
async function runCommand(
sandbox: Sandbox,
command: string,
opts: ExecOptions,
openStdin: boolean
): Promise<number> {
const handle = await sandbox.commands.run(command, {
background: true,
cwd: opts.cwd,
user: opts.user,
envs: opts.env,
timeoutMs: NO_COMMAND_TIMEOUT,
onStdout: async (data) => {
try {
process.stdout.write(data)
} catch (err: any) {
console.error(err)
await handle.kill()
}
},
onStderr: async (data) => {
try {
process.stderr.write(data)
} catch (err: any) {
console.error(err)
await handle.kill()
}
},
...(openStdin ? { stdin: true } : {}),
})
const removeSignalHandlers = setupSignalHandlers(async () => {
// Kill the remote process - main loop handles exit code.
await handle.kill()
})
try {
if (openStdin) {
await sendStdin(sandbox, handle.pid)
}
const result = await handle.wait()
return result.exitCode
} catch (err) {
if (handle.error) {
console.error(handle.error)
}
if (err instanceof CommandExitError) {
return err.exitCode
}
// If exit code is not from the command we throw the error.
throw err
} finally {
removeSignalHandlers()
}
}
async function sendStdin(sandbox: Sandbox, pid: number): Promise<void> {
const chunkSizeBytes = 64 * 1024
let processExited = false
await streamStdinChunks(
process.stdin,
async (chunk) => {
if (processExited) {
return false
}
try {
await sandbox.commands.sendStdin(pid, chunk)
} catch (err) {
if (err instanceof NotFoundError) {
processExited = true
console.error(
'e2b: Remote command exited before stdin could be delivered.'
)
return false
}
throw err
}
},
chunkSizeBytes
)
if (processExited) {
return
}
// Signal EOF so commands like cat/wc/grep terminate.
try {
await sandbox.commands.closeStdin(pid)
} catch (err) {
if (err instanceof NotFoundError) {
// Process already exited — EOF is moot.
return
}
// Fail fast instead of leaving a command blocked on stdin forever.
await killProcessBestEffort(sandbox, pid)
throw err
}
}
async function killProcessBestEffort(
sandbox: Sandbox,
pid: number
): Promise<void> {
try {
await sandbox.commands.kill(pid)
} catch (killErr) {
console.error(
'e2b: Failed to kill remote process after stdin EOF signaling failed.'
)
console.error(killErr)
}
}
@@ -0,0 +1,121 @@
import * as fs from 'fs'
const SHELL_SAFE_RE = /^[A-Za-z0-9_@%+=:,./-]+$/
export const shellQuote = (arg: string): string => {
if (arg === '') {
return "''"
}
if (SHELL_SAFE_RE.test(arg)) {
return arg
}
const q = "'\"'\"'"
return `'${arg.replace(/'/g, q)}'`
}
export const buildCommand = (commandParts: string[]): string => {
if (commandParts.length === 1) {
return commandParts[0]
}
return commandParts.map(shellQuote).join(' ')
}
type StatLike = {
isFIFO?: () => boolean
isFile?: () => boolean
isSocket?: () => boolean
isCharacterDevice?: () => boolean
}
type FsLike = { fstatSync: (fd: number) => StatLike }
export const isPipedStdin = (fd = 0, fsModule: FsLike = fs as FsLike) => {
try {
const stdinStats = fsModule.fstatSync(fd)
// Treat any non-interactive stdin as "piped": FIFO pipes and file redirection (`< file`).
// Keep this conservative so normal terminal stdin doesn't get eagerly drained.
if (stdinStats.isCharacterDevice?.()) {
return false
}
return Boolean(
stdinStats.isFIFO?.() || stdinStats.isFile?.() || stdinStats.isSocket?.()
)
} catch {
return false
}
}
type ReadStdinOptions = {
fd?: number
fsModule?: FsLike
stream?: NodeJS.ReadableStream
}
type StdinChunk = Uint8Array | Buffer | string
export const readStdinIfPiped = async (
options: ReadStdinOptions = {}
): Promise<Buffer | undefined> => {
const fd = options.fd ?? 0
const fsModule = options.fsModule ?? (fs as FsLike)
if (!isPipedStdin(fd, fsModule)) {
return undefined
}
const stream = options.stream ?? process.stdin
return await readStdinFrom(stream)
}
export const chunkBytesBySize = (
data: Uint8Array,
maxBytes: number
): Uint8Array[] => {
if (maxBytes <= 0) {
throw new Error('maxBytes must be greater than 0')
}
const chunks: Uint8Array[] = []
for (let offset = 0; offset < data.length; offset += maxBytes) {
chunks.push(data.subarray(offset, offset + maxBytes))
}
return chunks
}
export async function streamStdinChunks(
stream: NodeJS.ReadableStream,
onChunk: (chunk: Uint8Array) => Promise<void | boolean> | void | boolean,
maxBytes: number
): Promise<void> {
if (maxBytes <= 0) {
throw new Error('maxBytes must be greater than 0')
}
for await (const rawChunk of stream as AsyncIterable<StdinChunk>) {
const chunk =
typeof rawChunk === 'string'
? Buffer.from(rawChunk)
: Buffer.from(rawChunk)
if (chunk.byteLength === 0) {
continue
}
const pieces = chunkBytesBySize(chunk, maxBytes)
for (const piece of pieces) {
const shouldContinue = await onChunk(piece)
if (shouldContinue === false) {
return
}
}
}
}
export async function readStdinFrom(
stream: NodeJS.ReadableStream
): Promise<Buffer> {
return await new Promise((resolve, reject) => {
const chunks: Buffer[] = []
stream.on('data', (chunk) => chunks.push(Buffer.from(chunk)))
stream.on('end', () => resolve(Buffer.concat(chunks)))
stream.on('error', reject)
})
}
@@ -0,0 +1,27 @@
import * as commander from 'commander'
import { connectCommand } from './connect'
import { infoCommand } from './info'
import { listCommand } from './list'
import { killCommand } from './kill'
import { pauseCommand } from './pause'
import { resumeCommand } from './resume'
import { createCommand } from './create'
import { logsCommand } from './logs'
import { metricsCommand } from './metrics'
import { execCommand } from './exec'
export const sandboxCommand = new commander.Command('sandbox')
.description('work with sandboxes')
.alias('sbx')
.addCommand(connectCommand)
.addCommand(infoCommand)
.addCommand(listCommand)
.addCommand(killCommand)
.addCommand(pauseCommand)
.addCommand(resumeCommand)
.addCommand(createCommand('create', 'cr', false))
.addCommand(createCommand('spawn', 'sp', true), { hidden: true })
.addCommand(logsCommand)
.addCommand(metricsCommand)
.addCommand(execCommand)
+118
View File
@@ -0,0 +1,118 @@
import * as commander from 'commander'
import { NotFoundError, Sandbox } from 'e2b'
import { ensureAPIKey } from 'src/api'
import { asBold } from 'src/utils/format'
const fieldLabels: Partial<Record<string, string>> = {
sandboxId: 'Sandbox ID',
templateId: 'Template ID',
name: 'Alias',
startedAt: 'Started at',
endAt: 'End at',
state: 'State',
cpuCount: 'vCPUs',
memoryMB: 'RAM MiB',
envdVersion: 'Envd version',
allowInternetAccess: 'Internet access',
lifecycle: 'Lifecycle',
network: 'Network',
sandboxDomain: 'Sandbox domain',
metadata: 'Metadata',
}
const fieldOrder = [
'sandboxId',
'templateId',
'name',
'state',
'startedAt',
'endAt',
'cpuCount',
'memoryMB',
'envdVersion',
'allowInternetAccess',
'lifecycle',
'network',
'sandboxDomain',
'metadata',
]
export const infoCommand = new commander.Command('info')
.description('show information for a sandbox')
.argument(
'<sandboxID>',
`show information for sandbox specified by ${asBold('<sandboxID>')}`
)
.alias('in')
.option('-f, --format <format>', 'output format, eg. json, pretty')
.action(async (sandboxID: string, options: { format?: string }) => {
try {
const format = options.format || 'pretty'
const apiKey = ensureAPIKey()
const info = await Sandbox.getInfo(sandboxID, { apiKey })
if (format === 'pretty') {
renderPrettyInfo(info as unknown as Record<string, unknown>)
} else if (format === 'json') {
console.log(JSON.stringify(info, null, 2))
} else {
console.error(`Unsupported output format: ${format}`)
process.exit(1)
}
} catch (err: any) {
if (err instanceof NotFoundError) {
console.error(`Sandbox ${asBold(sandboxID)} wasn't found`)
process.exit(1)
return
}
console.error(err)
process.exit(1)
}
})
function renderPrettyInfo(info: Record<string, unknown>) {
console.log(
`\nSandbox info for ${asBold(String(info.sandboxId ?? 'unknown'))}:`
)
const orderedKeys = [
...fieldOrder.filter((key) => key in info),
...Object.keys(info).filter((key) => !fieldOrder.includes(key)),
]
for (const key of orderedKeys) {
const value = info[key]
if (value === undefined) {
continue
}
const label = fieldLabels[key] ?? key
const formattedValue = formatValue(value)
if (formattedValue.includes('\n')) {
const indentedValue = formattedValue
.split('\n')
.map((line) => ` ${line}`)
.join('\n')
console.log(`${asBold(label)}:\n${indentedValue}`)
continue
}
console.log(`${asBold(label)}: ${formattedValue}`)
}
process.stdout.write('\n')
}
function formatValue(value: unknown): string {
if (value instanceof Date) {
return value.toLocaleString()
}
if (Array.isArray(value) || (typeof value === 'object' && value !== null)) {
return JSON.stringify(value, null, 2)
}
return String(value)
}
+107
View File
@@ -0,0 +1,107 @@
import * as commander from 'commander'
import { ensureAPIKey } from 'src/api'
import { asBold } from 'src/utils/format'
import * as e2b from 'e2b'
import { Sandbox, components } from 'e2b'
import { parseMetadata } from './utils'
async function killSandbox(sandboxID: string, apiKey: string) {
const killed = await e2b.Sandbox.kill(sandboxID, { apiKey })
if (killed) {
console.log(`Sandbox ${asBold(sandboxID)} has been killed`)
} else {
console.error(`Sandbox ${asBold(sandboxID)} wasn't found`)
}
}
export const killCommand = new commander.Command('kill')
.description('kill sandbox')
.argument(
'[sandboxIDs...]',
`kill the sandboxes specified by ${asBold('[sandboxIDs...]')}`
)
.alias('kl')
.option('-a, --all', 'kill all sandboxes')
.option(
'-s, --state <state>',
'when used with -a/--all flag, filter by state, eg. running, paused. Defaults to running',
(value) => value.split(',')
)
.option(
'-m, --metadata <metadata>',
'when used with -a/--all flag, filter by metadata, eg. key1=value1'
)
.action(
async (
sandboxIDs: string[],
{
all,
state,
metadata,
}: {
all: boolean
state: components['schemas']['SandboxState'][]
metadata: string
}
) => {
try {
const apiKey = ensureAPIKey()
const sandboxesState = state || ['running']
const sandboxesMetadata = parseMetadata(metadata)
if ((!sandboxIDs || sandboxIDs.length === 0) && !all) {
console.error(
`You need to specify ${asBold('[sandboxIDs...]')} or use ${asBold(
'-a/--all'
)} flag`
)
process.exit(1)
}
if (all && sandboxIDs && sandboxIDs.length > 0) {
console.error(
`You cannot use ${asBold(
'-a/--all'
)} flag while specifying ${asBold('[sandboxIDs...]')}`
)
process.exit(1)
}
if (all) {
let total = 0
const iterator = Sandbox.list({
apiKey,
query: {
state: sandboxesState,
metadata: sandboxesMetadata,
},
})
while (iterator.hasNext) {
const sandboxes = await iterator.nextItems()
total += sandboxes.length
await Promise.all(
sandboxes.map((sandbox) => killSandbox(sandbox.sandboxId, apiKey))
)
}
if (total === 0) {
console.log('No running sandboxes')
} else {
console.log(`Killed ${total} running sandboxes`)
}
process.exit(0)
} else {
await Promise.all(
sandboxIDs.map((sandboxID) => killSandbox(sandboxID, apiKey))
)
}
} catch (err: any) {
console.error(err)
process.exit(1)
}
}
)
+178
View File
@@ -0,0 +1,178 @@
import * as tablePrinter from 'console-table-printer'
import * as commander from 'commander'
import { components, Sandbox, SandboxInfo } from 'e2b'
import { ensureAPIKey } from 'src/api'
import { parseMetadata } from './utils'
const DEFAULT_LIMIT = 1000
const PAGE_LIMIT = 100
function getStateTitle(state?: components['schemas']['SandboxState'][]) {
if (state?.length === 1) {
if (state?.includes('running')) return 'Running sandboxes'
if (state?.includes('paused')) return 'Paused sandboxes'
}
return 'Sandboxes'
}
export const listCommand = new commander.Command('list')
.description('list all sandboxes, by default it list only running ones')
.alias('ls')
.option(
'-s, --state <state>',
'filter by state, eg. running, paused. Defaults to running',
(value) => value.split(',')
)
.option('-m, --metadata <metadata>', 'filter by metadata, eg. key1=value1')
.option(
'-l, --limit <limit>',
`limit the number of sandboxes returned (default: ${DEFAULT_LIMIT}, 0 for no limit)`,
(value) => parseInt(value)
)
.option('-f, --format <format>', 'output format, eg. json, pretty')
.action(async (options) => {
try {
const state = options.state || ['running']
const format = options.format || 'pretty'
const limit =
options.limit === 0 ? undefined : (options.limit ?? DEFAULT_LIMIT)
const { sandboxes, hasMore } = await listSandboxes({
limit,
state,
metadataRaw: options.metadata,
})
if (format === 'pretty') {
renderTable(sandboxes, state)
if (hasMore) {
console.log(
`Showing first ${limit} sandboxes. Use --limit to change.`
)
}
} else if (format === 'json') {
console.log(JSON.stringify(sandboxes, null, 2))
} else {
console.error(`Unsupported output format: ${format}`)
process.exit(1)
}
} catch (err: any) {
console.error(err)
process.exit(1)
}
})
function renderTable(
sandboxes: SandboxInfo[],
state: components['schemas']['SandboxState'][]
) {
if (!sandboxes?.length) {
console.log('No sandboxes found')
return
}
const table = new tablePrinter.Table({
title: getStateTitle(state),
columns: [
{ name: 'sandboxId', alignment: 'left', title: 'Sandbox ID' },
{
name: 'templateId',
alignment: 'left',
title: 'Template ID',
maxLen: 20,
},
{ name: 'name', alignment: 'left', title: 'Alias' },
{ name: 'startedAt', alignment: 'left', title: 'Started at' },
{ name: 'endAt', alignment: 'left', title: 'End at' },
{ name: 'state', alignment: 'left', title: 'State' },
{ name: 'cpuCount', alignment: 'left', title: 'vCPUs' },
{ name: 'memoryMB', alignment: 'left', title: 'RAM MiB' },
{ name: 'envdVersion', alignment: 'left', title: 'Envd version' },
{ name: 'metadata', alignment: 'left', title: 'Metadata' },
],
disabledColumns: ['clientID'],
rows: sandboxes
.map((sandbox) => ({
...sandbox,
startedAt: new Date(sandbox.startedAt).toLocaleString(),
endAt: new Date(sandbox.endAt).toLocaleString(),
state: sandbox.state.charAt(0).toUpperCase() + sandbox.state.slice(1), // capitalize
metadata: JSON.stringify(sandbox.metadata),
}))
.sort(
(a, b) =>
a.startedAt.localeCompare(b.startedAt) ||
a.sandboxId.localeCompare(b.sandboxId)
),
style: {
headerTop: {
left: '',
right: '',
mid: '',
other: '',
},
headerBottom: {
left: '',
right: '',
mid: '',
other: '',
},
tableBottom: {
left: '',
right: '',
mid: '',
other: '',
},
vertical: '',
},
colorMap: {
orange: '\x1b[38;5;216m',
},
})
table.printTable()
process.stdout.write('\n')
}
type ListSandboxesOptions = {
limit?: number
state?: components['schemas']['SandboxState'][]
metadataRaw?: string
}
type ListSandboxesResult = {
sandboxes: SandboxInfo[]
hasMore: boolean
}
export async function listSandboxes({
limit,
state,
metadataRaw,
}: ListSandboxesOptions = {}): Promise<ListSandboxesResult> {
const apiKey = ensureAPIKey()
const metadata = parseMetadata(metadataRaw)
let pageLimit = limit
if (!limit || limit > PAGE_LIMIT) {
pageLimit = PAGE_LIMIT
}
const sandboxes: SandboxInfo[] = []
const iterator = Sandbox.list({
apiKey: apiKey,
limit: pageLimit,
query: { state, metadata },
})
while (iterator.hasNext && (!limit || sandboxes.length < limit)) {
const batch = await iterator.nextItems()
sandboxes.push(...batch)
}
return {
sandboxes: limit ? sandboxes.slice(0, limit) : sandboxes,
// We can't change the page size during iteration, so we may have to check if we have more sandboxes than the limit
hasMore: iterator.hasNext || (limit ? sandboxes.length > limit : false),
}
}
+265
View File
@@ -0,0 +1,265 @@
import * as commander from 'commander'
import * as e2b from 'e2b'
import * as util from 'util'
import * as chalk from 'chalk'
import { client, connectionConfig } from 'src/api'
import { asBold, asTimestamp, withUnderline } from 'src/utils/format'
import { wait } from 'src/utils/wait'
import { handleE2BRequestError } from '../../utils/errors'
import { waitForSandboxEnd, formatEnum, Format, isRunning } from './utils'
enum LogLevel {
DEBUG = 'DEBUG',
INFO = 'INFO',
WARN = 'WARN',
ERROR = 'ERROR',
}
function isLevelIncluded(level: LogLevel, allowedLevel?: LogLevel) {
if (!allowedLevel) {
return true
}
switch (allowedLevel) {
case LogLevel.DEBUG:
return true
case LogLevel.INFO:
return (
level === LogLevel.INFO ||
level === LogLevel.WARN ||
level === LogLevel.ERROR
)
case LogLevel.WARN:
return level === LogLevel.WARN || level === LogLevel.ERROR
case LogLevel.ERROR:
return level === LogLevel.ERROR
}
}
function cleanLogger(logger?: string) {
if (!logger) {
return ''
}
return logger.replaceAll('Svc', '')
}
export const logsCommand = new commander.Command('logs')
.description('show logs for sandbox')
.argument(
'<sandboxID>',
`show logs for sandbox specified by ${asBold('<sandboxID>')}`
)
.alias('lg')
.option(
'--level <level>',
`filter logs by level (${formatEnum(
LogLevel
)}). The logs with the higher levels will be also shown.`,
LogLevel.INFO
)
.option('-f, --follow', 'keep streaming logs until the sandbox is closed')
.option(
'--format <format>',
`specify format for printing logs (${formatEnum(Format)})`,
Format.PRETTY
)
.option(
'--loggers [loggers]',
'filter logs by loggers. Specify multiple loggers by separating them with a comma.',
(val: string) => val.split(',')
)
.action(
async (
sandboxID: string,
opts?: {
level: string
follow: boolean
format: Format
loggers?: string[]
}
) => {
try {
const level = opts?.level.toUpperCase() as LogLevel | undefined
if (level && !Object.values(LogLevel).includes(level)) {
throw new Error(`Invalid log level: ${level}`)
}
const format = opts?.format.toLowerCase() as Format | undefined
if (format && !Object.values(Format).includes(format)) {
throw new Error(`Invalid log format: ${format}`)
}
const getIsRunning = opts?.follow
? waitForSandboxEnd(sandboxID)
: () => false
let start: number | undefined
let isFirstRun = true
let firstLogsPrinted = false
if (format === Format.PRETTY) {
console.log(`\nLogs for sandbox ${asBold(sandboxID)}:`)
}
do {
const logs = await listSandboxLogs({ sandboxID, start })
if (logs.length !== 0 && firstLogsPrinted === false) {
firstLogsPrinted = true
process.stdout.write('\n')
}
for (const log of logs) {
printLog(
log.timestamp,
log.line,
level,
format,
opts?.loggers ?? undefined
)
}
if (!opts?.follow) break
const isSandboxRunning = await isRunning(sandboxID)
if (!isSandboxRunning && logs.length === 0 && isFirstRun) {
if (format === Format.PRETTY) {
console.log(
`\nStopped printing logs — sandbox ${withUnderline(
'not found'
)}`
)
}
break
}
if (!isSandboxRunning) {
if (format === Format.PRETTY) {
console.log(
`\nStopped printing logs — sandbox is ${withUnderline(
'closed'
)}`
)
}
break
}
const lastLog = logs.length > 0 ? logs[logs.length - 1] : undefined
if (lastLog) {
// TODO: Use the timestamp from the last log instead of the current time?
start = new Date(lastLog.timestamp).getTime() + 1
}
await wait(400)
isFirstRun = false
} while (getIsRunning() && opts?.follow)
} catch (err: any) {
console.error(err)
process.exit(1)
}
}
)
function printLog(
timestamp: string,
line: string,
allowedLevel: LogLevel | undefined,
format: Format | undefined,
allowedLoggers?: string[] | undefined
) {
const log = JSON.parse(line)
let level = log['level'].toUpperCase()
log.logger = cleanLogger(log.logger)
// Check if the current logger startsWith any of the allowed loggers. If there are no specified loggers, print logs from all loggers.
if (
allowedLoggers !== undefined &&
Array.isArray(allowedLoggers) &&
!allowedLoggers.some((allowedLogger) =>
log.logger.startsWith(allowedLogger)
)
) {
return
}
if (!isLevelIncluded(level, allowedLevel)) {
return
}
switch (level) {
case LogLevel.DEBUG:
level = chalk.default.black(chalk.default.bgWhite(level))
break
case LogLevel.INFO:
level = chalk.default.black(chalk.default.bgGreen(level) + ' ')
break
case LogLevel.WARN:
level = chalk.default.black(chalk.default.bgYellow(level) + ' ')
break
case LogLevel.ERROR:
level = chalk.default.white(chalk.default.bgRed(level))
break
}
delete log['traceID']
delete log['instanceID']
delete log['source_type']
delete log['teamID']
delete log['source']
delete log['service']
delete log['envID']
delete log['sandboxID']
if (format === Format.JSON) {
console.log(
JSON.stringify({
timestamp: new Date(timestamp).toISOString(),
level,
...log,
})
)
} else {
const time = `[${new Date(timestamp).toISOString().replace(/T/, ' ')}]`
delete log['level']
console.log(
`${asTimestamp(time)} ${level} ` +
util.inspect(log, {
colors: true,
depth: null,
maxArrayLength: Infinity,
sorted: true,
compact: true,
breakLength: Infinity,
})
)
}
}
export async function listSandboxLogs({
sandboxID,
start,
}: {
sandboxID: string
start?: number
}): Promise<e2b.components['schemas']['SandboxLog'][]> {
const signal = connectionConfig.getSignal()
const res = await client.api.GET('/sandboxes/{sandboxID}/logs', {
signal,
params: {
path: {
sandboxID,
},
query: {
start,
},
},
})
handleE2BRequestError(res, 'Error while getting sandbox logs')
return res.data.logs
}
@@ -0,0 +1,148 @@
import * as chalk from 'chalk'
import * as commander from 'commander'
import { asBold, asTimestamp, withUnderline } from 'src/utils/format'
import { wait } from 'src/utils/wait'
import { formatEnum, Format, isRunning } from './utils'
import { Sandbox } from 'e2b'
import { ensureAPIKey } from '../../api'
export const metricsCommand = new commander.Command('metrics')
.description('show metrics for sandbox')
.argument(
'<sandboxID>',
`show metrics for sandbox specified by ${asBold('<sandboxID>')}`
)
.alias('mt')
.option('-f, --follow', 'keep streaming metrics until the sandbox is closed')
.option(
'--format <format>',
`specify format for printing metrics (${formatEnum(Format)})`,
Format.PRETTY
)
.action(
async (
sandboxID: string,
opts?: {
follow: boolean
format: Format
}
) => {
try {
const format = opts?.format.toLowerCase() as Format | undefined
if (format && !Object.values(Format).includes(format)) {
throw new Error(`Invalid log format: ${format}`)
}
let start: Date | undefined
let isFirstRun = true
let firstMetricsPrinted = false
if (format === Format.PRETTY) {
console.log(`\nMetrics for sandbox ${asBold(sandboxID)}:`)
}
const apiKey = ensureAPIKey()
const isRunningPromise = isRunning(sandboxID)
do {
const metrics = await Sandbox.getMetrics(sandboxID, { start, apiKey })
if (metrics.length !== 0 && !firstMetricsPrinted) {
firstMetricsPrinted = true
process.stdout.write('\n')
}
for (const metric of metrics) {
if (start && metric.timestamp <= start) {
// Skip the metric if it has the same timestamp as the last one
continue
}
start = metric.timestamp
printMetric(metric.timestamp, JSON.stringify(metric), format)
}
const isRunning = await isRunningPromise
if (!isRunning && metrics.length === 0 && isFirstRun) {
if (format === Format.PRETTY) {
console.log(
`\nStopped printing metrics — sandbox ${withUnderline(
'not found'
)}`
)
}
break
}
if (!isRunning) {
if (format === Format.PRETTY) {
console.log(
`\nStopped printing metrics — sandbox is ${withUnderline(
'closed'
)}`
)
}
break
}
await wait(400)
isFirstRun = false
} while (opts?.follow)
} catch (err: any) {
console.error(err)
process.exit(1)
}
}
)
function printMetric(
timestamp: Date,
line: string,
format: Format | undefined
) {
const metric = JSON.parse(line)
const level = chalk.default.green()
if (format === Format.JSON) {
console.log(
JSON.stringify({
timestamp: timestamp.toISOString(),
...metric,
})
)
} else {
const time = `[${timestamp
.toISOString()
.replace(/\.\d{3}Z/, 'Z')
.replace(/T/, ' ')}]`
delete metric['timestamp']
const multipleCores = metric.cpuCount > 1
metric.cpuCount += 0
console.log(
`${asTimestamp(time)} ${level} ` +
asBold('CPU') +
`: ${metric.cpuUsedPct.toString().padStart(5)}% / ${metric.cpuCount
.toString()
.padStart(2)} Core${multipleCores && 's'} | ` +
asBold('Memory') +
`: ${toMB(metric.memUsed).toFixed(0).padStart(5)} / ${toMB(
metric.memTotal
)
.toFixed(0)
.padEnd(5)} MiB | ` +
asBold('Disk') +
`: ${toMB(metric.diskUsed).toFixed(0).padStart(5)} / ${toMB(
metric.diskTotal
)
.toFixed(0)
.padEnd(5)} MiB`
)
}
}
// we can't use bite shift here because shift is 32 bit operation and disk sizes can be greater than 2^32
function toMB(bytes: number): number {
return bytes / 1024 / 1024
}
@@ -0,0 +1,40 @@
import * as commander from 'commander'
import { ensureAPIKey } from 'src/api'
import { asBold } from 'src/utils/format'
import * as e2b from 'e2b'
import { NotFoundError } from 'e2b'
async function pauseSandbox(sandboxID: string, apiKey: string) {
try {
const paused = await e2b.Sandbox.betaPause(sandboxID, { apiKey })
if (paused) {
console.log(`Sandbox ${asBold(sandboxID)} has been paused`)
} else {
console.log(`Sandbox ${asBold(sandboxID)} is already paused`)
}
} catch (err: unknown) {
if (err instanceof NotFoundError) {
console.error(`Sandbox ${asBold(sandboxID)} wasn't found`)
process.exit(1)
}
throw err
}
}
export const pauseCommand = new commander.Command('pause')
.description('pause sandbox')
.argument(
'<sandboxID>',
`pause the sandbox specified by ${asBold('<sandboxID>')}`
)
.alias('ps')
.action(async (sandboxID: string) => {
try {
const apiKey = ensureAPIKey()
await pauseSandbox(sandboxID, apiKey)
} catch (err: unknown) {
console.error(err)
process.exit(1)
}
})
@@ -0,0 +1,36 @@
import * as commander from 'commander'
import { ensureAPIKey } from 'src/api'
import { asBold } from 'src/utils/format'
import * as e2b from 'e2b'
import { NotFoundError } from 'e2b'
async function resumeSandbox(sandboxID: string, apiKey: string) {
try {
await e2b.Sandbox.connect(sandboxID, { apiKey })
console.log(`Sandbox ${asBold(sandboxID)} has been resumed`)
} catch (err: unknown) {
if (err instanceof NotFoundError) {
console.error(`Sandbox ${asBold(sandboxID)} wasn't found`)
process.exit(1)
}
throw err
}
}
export const resumeCommand = new commander.Command('resume')
.description('resume paused sandbox')
.argument(
'<sandboxID>',
`resume the sandbox specified by ${asBold('<sandboxID>')}`
)
.alias('rs')
.action(async (sandboxID: string) => {
try {
const apiKey = ensureAPIKey()
await resumeSandbox(sandboxID, apiKey)
} catch (err: unknown) {
console.error(err)
process.exit(1)
}
})
@@ -0,0 +1,77 @@
import { wait } from '../../utils/wait'
import { asBold } from '../../utils/format'
import { Sandbox } from 'e2b'
import { ensureAPIKey } from 'src/api'
export function formatEnum(e: { [key: string]: string }) {
return Object.values(e)
.map((level) => asBold(level))
.join(', ')
}
export enum Format {
JSON = 'json',
PRETTY = 'pretty',
}
const maxRuntime = 24 * 60 * 60 * 1000 // 24 hours in milliseconds
export function waitForSandboxEnd(sandboxID: string) {
let running = true
async function monitor() {
const startTime = new Date().getTime()
// eslint-disable-next-line no-constant-condition
while (true) {
const currentTime = new Date().getTime()
const elapsedTime = currentTime - startTime // Time elapsed in milliseconds
// Check if 24 hours (in milliseconds) have passed
if (elapsedTime >= maxRuntime) {
break
}
running = await isRunning(sandboxID)
if (!running) {
break
}
await wait(5000)
}
}
monitor()
return () => running
}
export async function isRunning(sandboxID: string) {
try {
const apiKey = ensureAPIKey()
const info = await Sandbox.getInfo(sandboxID, {
apiKey,
})
return info.state === 'running'
} catch (err) {
console.error(`Failed to check sandbox status: ${err}`)
return false
}
}
export function parseMetadata(metadataRaw?: string) {
let metadata: Record<string, string> | undefined = undefined
if (metadataRaw && metadataRaw.length > 0) {
const parsedMetadata: Record<string, string> = {}
metadataRaw.split(',').map((pair: string) => {
const [key, value] = pair.split('=')
if (key && value) {
parsedMetadata[key.trim()] = value.trim()
}
})
metadata = parsedMetadata
}
return metadata
}
@@ -0,0 +1,37 @@
import * as boxen from 'boxen'
import * as commander from 'commander'
import { asBold, asPrimary } from '../../utils/format'
export const buildCommand = new commander.Command('build')
.description('Deprecated: use `e2b template create` instead.')
.argument('[template]', 'unused')
.allowUnknownOption(true)
.alias('bd')
.action(async () => {
const deprecationMessage = `${asBold('DEPRECATION WARNING')}
This is the v1 build system which is now deprecated.
Please migrate to the new build system v2.
Migration guide: ${asPrimary('https://e2b.dev/docs/template/migration-v2')}`
const deprecationWarning = boxen.default(deprecationMessage, {
padding: {
bottom: 0,
top: 0,
left: 2,
right: 2,
},
margin: {
top: 1,
bottom: 1,
left: 0,
right: 0,
},
borderColor: 'yellow',
borderStyle: 'round',
})
console.log(deprecationWarning)
process.exit(1)
})
@@ -0,0 +1,212 @@
import * as boxen from 'boxen'
import * as commander from 'commander'
import { defaultBuildLogger, Template, TemplateClass } from 'e2b'
import { connectionConfig, ensureAPIKey } from 'src/api'
import {
defaultDockerfileName,
fallbackDockerfileName,
} from 'src/docker/constants'
import { parsePositiveInt, pathOption } from 'src/options'
import { validateTemplateName } from 'src/utils/templateName'
import { getRoot } from 'src/utils/filesystem'
import {
asFormattedSandboxTemplate,
asLocal,
asLocalRelative,
asPrimary,
asPython,
asTypescript,
withDelimiter,
} from '../../utils/format'
import { getDockerfile } from './dockerfile'
export const createCommand = new commander.Command('create')
.description(
'build Dockerfile as a Sandbox template. This command reads a Dockerfile and builds it directly.'
)
.argument(
'<template-name>',
'template name to create or rebuild. The template name must be lowercase and contain only letters, numbers, dashes and underscores.'
)
.addOption(pathOption)
.option(
'-d, --dockerfile <file>',
`specify path to Dockerfile. By default E2B tries to find ${asLocal(
defaultDockerfileName
)} or ${asLocal(fallbackDockerfileName)} in root directory.`
)
.option(
'-c, --cmd <start-command>',
'specify command that will be executed when the sandbox is started.'
)
.option(
'--ready-cmd <ready-command>',
'specify command that will need to exit 0 for the template to be ready.'
)
.option(
'--cpu-count <cpu-count>',
'specify the number of CPUs that will be used to run the sandbox. The default value is 2.',
parsePositiveInt('CPU count')
)
.option(
'--memory-mb <memory-mb>',
'specify the amount of memory in megabytes that will be used to run the sandbox. Must be an even number. The default value is 512.',
parsePositiveInt('Memory in megabytes')
)
.option('--no-cache', 'skip cache when building the template.')
.alias('ct')
.action(
async (
templateName: string,
opts: {
path?: string
dockerfile?: string
cmd?: string
readyCmd?: string
cpuCount?: number
memoryMb?: number
noCache?: boolean
}
) => {
try {
process.stdout.write('\n')
// Validate and normalize template name
try {
templateName = validateTemplateName(templateName)
} catch (err) {
console.error(
`Template name ${asLocal(templateName)} is not valid. ${
err instanceof Error ? err.message : String(err)
}`
)
process.exit(1)
}
// Validate memory
if (opts.memoryMb && opts.memoryMb % 2 !== 0) {
console.error(
`The memory in megabytes must be an even number. You provided ${asLocal(
opts.memoryMb.toFixed(0)
)}.`
)
process.exit(1)
}
const root = getRoot(opts.path)
// Use options directly
const dockerfile = opts.dockerfile
const startCmd = opts.cmd
const readyCmd = opts.readyCmd
const cpuCount = opts.cpuCount
const memoryMB = opts.memoryMb
// Get Dockerfile content
const { dockerfileContent, dockerfileRelativePath } = getDockerfile(
root,
dockerfile
)
console.log(
`Found ${asLocalRelative(
dockerfileRelativePath
)} that will be used to build the sandbox template.`
)
// Initialize template builder with file context and parse Dockerfile
const baseTemplate = Template({
fileContextPath: root,
}).fromDockerfile(dockerfileContent)
// Apply start/ready commands if provided
let finalTemplate: TemplateClass = baseTemplate
if (startCmd && readyCmd) {
finalTemplate = baseTemplate.setStartCmd(startCmd, readyCmd)
} else if (readyCmd) {
finalTemplate = baseTemplate.setReadyCmd(readyCmd)
} else if (startCmd) {
console.error('Both start and ready commands must be provided.')
process.exit(1)
}
console.log('\nBuilding sandbox template...\n')
// Prepare API credentials for SDK
const apiKey = ensureAPIKey()
const domain = connectionConfig.domain
// Build the template using SDK
try {
await Template.build(finalTemplate, {
alias: templateName,
cpuCount: cpuCount,
memoryMB: memoryMB,
skipCache: opts.noCache,
apiKey: apiKey,
domain: domain,
onBuildLogs: defaultBuildLogger(),
})
} catch (error) {
console.error('\n❌ Template build failed.')
if (error instanceof Error) {
console.error('Error:', error.message)
}
process.exit(1)
}
// Display success message with examples
const pythonExample = asPython(`from e2b import Sandbox, AsyncSandbox
# Create sync sandbox
sandbox = Sandbox.create("${templateName}")
# Create async sandbox
sandbox = await AsyncSandbox.create("${templateName}")`)
const typescriptExample = asTypescript(`import { Sandbox } from 'e2b'
// Create sandbox
const sandbox = await Sandbox.create('${templateName}')`)
const examplesMessage = `You can now use the template to create custom sandboxes.\nLearn more on ${asPrimary(
'https://e2b.dev/docs'
)}`
const exampleHeader = boxen.default(examplesMessage, {
padding: {
bottom: 1,
top: 1,
left: 2,
right: 2,
},
margin: {
top: 1,
bottom: 1,
left: 0,
right: 0,
},
fullscreen(width) {
return [width, 0]
},
float: 'left',
})
const exampleUsage = `${withDelimiter(
pythonExample,
'Python SDK'
)}\n${withDelimiter(typescriptExample, 'JS SDK', true)}`
console.log(
`\n✅ Building sandbox template ${asFormattedSandboxTemplate({
templateID: templateName,
})} finished.\n${exampleHeader}\n${exampleUsage}\n`
)
process.exit(0)
} catch (err: any) {
console.error(err)
process.exit(1)
}
}
)
@@ -0,0 +1,187 @@
import * as commander from 'commander'
import * as chalk from 'chalk'
import * as fs from 'fs'
import {
asBold,
asFormattedError,
asFormattedSandboxTemplate,
asLocal,
asLocalRelative,
} from 'src/utils/format'
import {
configOption,
pathOption,
selectMultipleOption,
teamOption,
} from 'src/options'
import {
E2BConfig,
configName,
deleteConfig,
getConfigPath,
loadConfig,
} from 'src/config'
import { getRoot } from 'src/utils/filesystem'
import { listSandboxTemplates } from './list'
import { getPromptTemplates } from 'src/utils/templatePrompt'
import { confirm } from 'src/utils/confirm'
import { client, resolveTeamId } from 'src/api'
import { handleE2BRequestError } from '../../utils/errors'
async function deleteTemplate(templateID: string) {
const res = await client.api.DELETE('/templates/{templateID}', {
params: {
path: {
templateID,
},
},
})
handleE2BRequestError(res, 'Error deleting sandbox template')
return
}
export const deleteCommand = new commander.Command('delete')
.description(`delete sandbox template and ${asLocal(configName)} config`)
.argument(
'[template]',
`specify ${asBold('[template]')} to delete it. If you dont specify ${asBold(
'[template]'
)} the command will try to delete sandbox template defined by ${asLocal(
'e2b.toml'
)}.`
)
.addOption(pathOption)
.addOption(configOption)
.addOption(selectMultipleOption)
.addOption(teamOption)
.alias('dl')
.option('-y, --yes', 'skip manual delete confirmation')
.action(
async (
template,
opts: {
path?: string
config?: string
yes?: boolean
select?: boolean
team?: string
}
) => {
try {
let teamId = opts.team
const root = getRoot(opts.path)
const templates: (Pick<E2BConfig, 'template_id'> & {
configPath?: string
})[] = []
if (template) {
templates.push({
template_id: template,
})
} else if (opts.select) {
teamId = resolveTeamId(teamId)
const allTemplates = await listSandboxTemplates({
teamID: teamId,
})
const selectedTemplates = await getPromptTemplates(
allTemplates,
'Select sandbox templates to delete'
)
templates.push(
...selectedTemplates.map((e) => ({
template_id: e.templateID,
...e,
}))
)
if (!templates || templates.length === 0) {
console.log('No sandbox templates selected')
return
}
} else {
const configPath = getConfigPath(root, opts.config)
const config = fs.existsSync(configPath)
? await loadConfig(configPath)
: undefined
if (!config) {
console.log(
`No ${asLocal(configName)} found in ${asLocalRelative(
root
)}. Specify sandbox template with ${asBold(
'[template]'
)} argument or use interactive mode with ${asBold('-s')} flag.`
)
return
}
templates.push({
...config,
configPath,
})
}
if (!templates || templates.length === 0) {
console.log(
`No sandbox templates selected. Specify sandbox template with ${asBold(
'[template]'
)} argument or use interactive mode with ${asBold('-s')} flag.`
)
return
}
console.log(
chalk.default.red(
chalk.default.underline('\nSandbox templates to delete')
)
)
templates.forEach((e) =>
console.log(
asFormattedSandboxTemplate(
{ ...e, templateID: e.template_id },
e.configPath
)
)
)
process.stdout.write('\n')
if (!opts.yes) {
const confirmed = await confirm(
`Do you really want to delete ${
templates.length === 1 ? 'this template' : 'these templates'
}?`
)
if (!confirmed) {
console.log('Canceled')
return
}
}
await Promise.all(
templates.map(async (e) => {
console.log(
`- Deleting sandbox template ${asFormattedSandboxTemplate(
{ ...e, templateID: e.template_id },
e.configPath
)}`
)
await deleteTemplate(e.template_id)
if (e.configPath) {
await deleteConfig(e.configPath)
}
})
)
process.stdout.write('\n')
} catch (err: any) {
console.error(asFormattedError(err.message))
process.exit(1)
}
}
)
@@ -0,0 +1,71 @@
import * as fs from 'fs'
import * as path from 'path'
import {
defaultDockerfileName,
fallbackDockerfileName,
} from 'src/docker/constants'
import { asBold, asLocalRelative } from '../../utils/format'
function loadFile(filePath: string) {
if (!fs.existsSync(filePath)) {
return undefined
}
return fs.readFileSync(filePath, 'utf-8')
}
export function getDockerfile(root: string, file?: string) {
if (file) {
const dockerfilePath = path.join(root, file)
const dockerfileContent = loadFile(dockerfilePath)
const dockerfileRelativePath = path.relative(root, dockerfilePath)
if (dockerfileContent === undefined) {
throw new Error(
`No ${asLocalRelative(
dockerfileRelativePath
)} found in the root directory.`
)
}
return {
dockerfilePath,
dockerfileContent,
dockerfileRelativePath,
}
}
let dockerfilePath = path.join(root, defaultDockerfileName)
let dockerfileContent = loadFile(dockerfilePath)
const defaultDockerfileRelativePath = path.relative(root, dockerfilePath)
let dockerfileRelativePath = defaultDockerfileRelativePath
if (dockerfileContent !== undefined) {
return {
dockerfilePath,
dockerfileContent,
dockerfileRelativePath,
}
}
dockerfilePath = path.join(root, fallbackDockerfileName)
dockerfileContent = loadFile(dockerfilePath)
const fallbackDockerfileRelativeName = path.relative(root, dockerfilePath)
dockerfileRelativePath = fallbackDockerfileRelativeName
if (dockerfileContent !== undefined) {
return {
dockerfilePath,
dockerfileContent,
dockerfileRelativePath,
}
}
throw new Error(
`No ${asLocalRelative(defaultDockerfileRelativePath)} or ${asLocalRelative(
fallbackDockerfileRelativeName
)} found in the root directory (${root}). You can specify a custom Dockerfile with ${asBold(
'--dockerfile <file>'
)} option.`
)
}
@@ -0,0 +1,24 @@
import * as fs from 'fs'
import * as path from 'path'
/**
* Write content to a file, creating directories if needed
*/
export async function writeFileContent(
filePath: string,
content: string
): Promise<void> {
if (fs.existsSync(filePath)) {
throw new Error(
`File ${filePath} already exists. Aborting to avoid overwrite.`
)
}
// Ensure directory exists
const dir = path.dirname(filePath)
if (!fs.existsSync(dir)) {
await fs.promises.mkdir(dir, { recursive: true })
}
await fs.promises.writeFile(filePath, content)
}
@@ -0,0 +1,233 @@
import { Template, TemplateClass } from 'e2b'
import * as fs from 'fs'
import HandlebarsLib from 'handlebars'
import * as path from 'path'
import {
GeneratedFiles,
Language,
TemplateJSON,
TemplateWithStepsJSON,
} from './types'
class Handlebars {
private handlebars: typeof HandlebarsLib
constructor() {
const handlebars = HandlebarsLib.create()
handlebars.registerHelper('eq', function (a: any, b: any, options: any) {
if (a === b) {
// @ts-ignore - this context is provided by Handlebars
return options.fn(this)
}
return ''
})
handlebars.registerHelper('escapeQuotes', function (str) {
return str ? str.replace(/'/g, "\\'") : str
})
handlebars.registerHelper('escapeDoubleQuotes', function (str) {
return str ? str.replace(/"/g, '\\"') : str
})
this.handlebars = handlebars
}
compile(template: string) {
return this.handlebars.compile(template)
}
}
interface HandlebarStep {
type: string
args?: string[]
envVars?: Record<string, string>
src?: string
dest?: string
}
/**
* Transform template data for Handlebars
*/
export async function transformTemplateData(
template: TemplateClass
): Promise<TemplateJSON & { steps: HandlebarStep[] }> {
// Extract JSON structure from parsed template
const jsonString = await Template.toJSON(template, false)
const json = JSON.parse(jsonString) as TemplateWithStepsJSON
const transformedSteps: HandlebarStep[] = []
for (const step of json.steps) {
switch (step.type) {
case 'ENV': {
// Keep all environment variables from one ENV instruction together
const envVars: Record<string, string> = {}
for (let i = 0; i < step.args.length; i += 2) {
if (i + 1 < step.args.length) {
envVars[step.args[i]] = step.args[i + 1]
}
}
transformedSteps.push({
type: 'ENV',
envVars,
})
break
}
case 'COPY': {
if (step.args.length >= 2) {
const src = step.args[0]
let dest = step.args[1]
if (!dest || dest === '') {
dest = '.'
}
transformedSteps.push({
type: 'COPY',
src,
dest,
})
}
break
}
default:
transformedSteps.push({
type: step.type,
args: step.args,
})
}
}
return {
...json,
steps: transformedSteps,
}
}
/**
* Convert the template to TypeScript code using Handlebars
*/
export async function generateTypeScriptCode(
template: TemplateClass,
name: string,
cpuCount?: number,
memoryMB?: number
): Promise<{ templateContent: string; buildContent: string }> {
const hb = new Handlebars()
const transformedData = await transformTemplateData(template)
// Load and compile templates
// In dist, templates are at dist/templates/, __dirname is dist/
const templatesDir = path.join(__dirname, 'templates')
const templateSource = fs.readFileSync(
path.join(templatesDir, 'typescript-template.hbs'),
'utf8'
)
const buildSource = fs.readFileSync(
path.join(templatesDir, 'typescript-build.hbs'),
'utf8'
)
const generateTemplateSource = hb.compile(templateSource)
const generateBuildSource = hb.compile(buildSource)
// Generate content
const templateData = {
...transformedData,
}
const templateContent = generateTemplateSource(templateData)
const buildContent = generateBuildSource({
name,
cpuCount,
memoryMB,
})
return {
templateContent: templateContent.trim(),
buildContent: buildContent.trim(),
}
}
/**
* Convert the template to Python code using Handlebars
*/
export async function generatePythonCode(
template: TemplateClass,
name: string,
cpuCount?: number,
memoryMB?: number,
isAsync: boolean = false
): Promise<{ templateContent: string; buildContent: string }> {
const hb = new Handlebars()
const transformedData = await transformTemplateData(template)
// Load and compile templates
// In dist, templates are at dist/templates/, __dirname is dist/
const templatesDir = path.join(__dirname, 'templates')
const templateSource = fs.readFileSync(
path.join(templatesDir, 'python-template.hbs'),
'utf8'
)
const buildSource = fs.readFileSync(
path.join(templatesDir, `python-build-${isAsync ? 'async' : 'sync'}.hbs`),
'utf8'
)
const generateTemplateSource = hb.compile(templateSource)
const generateBuildSource = hb.compile(buildSource)
// Generate content
const templateContent = generateTemplateSource({
...transformedData,
isAsync,
})
const buildContent = generateBuildSource({
name,
cpuCount,
memoryMB,
})
return {
templateContent: templateContent.trim(),
buildContent: buildContent.trim(),
}
}
/**
* Generate README.md content using Handlebars
*/
export async function generateReadmeContent(
name: string,
templateDir: string,
generatedFiles: GeneratedFiles
): Promise<string> {
const hb = new Handlebars()
// Load and compile README template
const templatesDir = path.join(__dirname, 'templates')
const readmeSource = fs.readFileSync(
path.join(templatesDir, 'readme.hbs'),
'utf8'
)
const generateReadmeSource = hb.compile(readmeSource)
// Prepare template data
const templateData = {
name,
templateDir,
templateFile: generatedFiles.templateFile,
buildDevFile: generatedFiles.buildDevFile,
buildProdFile: generatedFiles.buildProdFile,
isTypeScript: generatedFiles.language === Language.TypeScript,
isPython:
generatedFiles.language === Language.PythonSync ||
generatedFiles.language === Language.PythonAsync,
isPythonSync: generatedFiles.language === Language.PythonSync,
isPythonAsync: generatedFiles.language === Language.PythonAsync,
}
return generateReadmeSource(templateData).trim()
}
@@ -0,0 +1,4 @@
// Re-export all the public APIs from the lib modules
export * from './types'
export * from './template-generator'
export * from './file-utils'
@@ -0,0 +1,93 @@
import * as path from 'path'
import { asLocalRelative, asPrimary } from '../../../utils/format'
import { GeneratedFiles, Language, languageDisplay } from './types'
import { generatePythonCode, generateTypeScriptCode } from './handlebars'
import { writeFileContent } from './file-utils'
import { TemplateClass } from 'e2b'
/**
* Generate and write template files for a given language
*/
export async function generateAndWriteTemplateFiles(
root: string,
name: string,
language: Language,
template: TemplateClass,
cpuCount?: number,
memoryMB?: number
): Promise<GeneratedFiles> {
switch (language) {
case Language.TypeScript: {
const { templateContent, buildContent: buildDevContent } =
await generateTypeScriptCode(
template,
`${name}-dev`,
cpuCount,
memoryMB
)
const { buildContent: buildProdContent } = await generateTypeScriptCode(
template,
name,
cpuCount,
memoryMB
)
const templateFile = 'template.ts'
const buildDevFile = 'build.dev.ts'
const buildProdFile = 'build.prod.ts'
await writeFileContent(path.join(root, templateFile), templateContent)
await writeFileContent(path.join(root, buildDevFile), buildDevContent)
await writeFileContent(path.join(root, buildProdFile), buildProdContent)
console.log(
`\n✅ Generated ${asPrimary(
languageDisplay[Language.TypeScript]
)} template files:`
)
console.log(` ${asLocalRelative(templateFile)}`)
console.log(` ${asLocalRelative(buildDevFile)}`)
console.log(` ${asLocalRelative(buildProdFile)}`)
return { templateFile, buildDevFile, buildProdFile, language }
}
case Language.PythonSync:
case Language.PythonAsync: {
const isAsync = language === Language.PythonAsync
const { templateContent, buildContent: buildDevContent } =
await generatePythonCode(
template,
`${name}-dev`,
cpuCount,
memoryMB,
isAsync
)
const { buildContent: buildProdContent } = await generatePythonCode(
template,
name,
cpuCount,
memoryMB,
isAsync
)
const templateFile = 'template.py'
const buildDevFile = 'build_dev.py'
const buildProdFile = 'build_prod.py'
await writeFileContent(path.join(root, templateFile), templateContent)
await writeFileContent(path.join(root, buildDevFile), buildDevContent)
await writeFileContent(path.join(root, buildProdFile), buildProdContent)
console.log(
`\n✅ Generated ${asPrimary(languageDisplay[language])} template files:`
)
console.log(` ${asLocalRelative(templateFile)}`)
console.log(` ${asLocalRelative(buildDevFile)}`)
console.log(` ${asLocalRelative(buildProdFile)}`)
return { templateFile, buildDevFile, buildProdFile, language }
}
default:
throw new Error('Unsupported language')
}
}
@@ -0,0 +1,35 @@
export enum Language {
TypeScript = 'typescript',
PythonSync = 'python-sync',
PythonAsync = 'python-async',
}
export const languageDisplay = {
[Language.TypeScript]: 'TypeScript',
[Language.PythonSync]: 'Python (sync)',
[Language.PythonAsync]: 'Python (async)',
}
export interface TemplateJSON {
fromImage?: string
fromTemplate?: string
startCmd?: string
readyCmd?: string
force: boolean
}
export interface TemplateWithStepsJSON extends TemplateJSON {
steps: Array<{
type: string
args: string[]
filesHash?: string
force?: boolean
}>
}
export interface GeneratedFiles {
templateFile: string
buildDevFile: string
buildProdFile: string
language: Language
}
@@ -0,0 +1,21 @@
import * as commander from 'commander'
import { createCommand } from './create'
import { buildCommand } from './build'
import { deleteCommand } from './delete'
import { initCommand } from './init'
import { listCommand } from './list'
import { migrateCommand } from './migrate'
import { publishCommand, unPublishCommand } from './publish'
export const templateCommand = new commander.Command('template')
.description('manage sandbox templates')
.alias('tpl')
.addCommand(createCommand)
.addCommand(buildCommand, { hidden: true })
.addCommand(listCommand)
.addCommand(initCommand)
.addCommand(deleteCommand)
.addCommand(publishCommand)
.addCommand(unPublishCommand)
.addCommand(migrateCommand)
+312
View File
@@ -0,0 +1,312 @@
import { input, select } from '@inquirer/prompts'
import PackageJson from '@npmcli/package-json'
import * as commander from 'commander'
import { Template } from 'e2b'
import * as fs from 'fs'
import * as path from 'path'
import { pathOption } from 'src/options'
import { getRoot } from 'src/utils/filesystem'
import { asPrimary } from 'src/utils/format'
import { validateTemplateName } from 'src/utils/templateName'
import {
generateAndWriteTemplateFiles,
GeneratedFiles,
Language,
languageDisplay,
} from './generators'
import { generateReadmeContent } from './generators/handlebars'
const DEFAULT_TEMPLATE_NAME = 'my-template'
/**
* Generate template files using shared template generation logic
*/
async function generateTemplateFiles(
root: string,
name: string,
language: Language,
cpuCount?: number,
memoryMB?: number
): Promise<GeneratedFiles> {
const template = Template().fromBaseImage().runCmd('echo Hello World E2B!')
return generateAndWriteTemplateFiles(
root,
name,
language,
template,
cpuCount,
memoryMB
)
}
/**
* Add build scripts to Makefile if it exists or create a new one
*/
async function addMakefileScripts(
root: string,
files: GeneratedFiles,
templateDirName: string
): Promise<void> {
try {
const makefileName = 'Makefile'
const makefileExists = fs.existsSync(path.join(root, makefileName))
let cdPrefix = ''
if (makefileExists) {
cdPrefix = `cd ${templateDirName} && `
}
const makefileContent = `
.PHONY: e2b:build:dev
e2b:build:dev:
\t${cdPrefix}python ${files.buildDevFile}
.PHONY: e2b:build:prod
e2b:build:prod:
\t${cdPrefix}python ${files.buildProdFile}
`
if (makefileExists) {
const makefilePath = path.join(root, makefileName)
await fs.promises.appendFile(makefilePath, '\n' + makefileContent, 'utf8')
} else {
// Create a basic Makefile if it doesn't exist
const makefilePath = path.join(root, templateDirName, makefileName)
await fs.promises.writeFile(makefilePath, makefileContent, 'utf8')
}
console.log('\n📝 Added build scripts to Makefile:')
console.log(
` ${asPrimary('make e2b:build:dev')} - Build development template`
)
console.log(
` ${asPrimary('make e2b:build:prod')} - Build production template`
)
} catch (err) {
console.warn(
'\n⚠️ Could not add scripts to Makefile:',
err instanceof Error ? err.message : err
)
}
}
/**
* Add build scripts to package.json if it exists or create a new one
*/
async function addPackageJsonScripts(
root: string,
files: GeneratedFiles,
templateDirName: string
): Promise<void> {
try {
let cdPrefix = ''
let pkgJson: PackageJson
try {
// The library expects the directory path, not the full file path
pkgJson = await PackageJson.load(root)
cdPrefix = `cd ${templateDirName} && `
} catch (error) {
// Handle the case where package.json does not exist
const createRoot = path.join(root, templateDirName)
pkgJson = await PackageJson.create(createRoot)
}
pkgJson.update({
scripts: {
...pkgJson.content.scripts,
'e2b:build:dev': `${cdPrefix}npx tsx ${files.buildDevFile}`,
'e2b:build:prod': `${cdPrefix}npx tsx ${files.buildProdFile}`,
},
})
// Save the changes
await pkgJson.save()
console.log('\n📝 Added build scripts to package.json:')
console.log(
` ${asPrimary('npm run e2b:build:dev')} - Build development template`
)
console.log(
` ${asPrimary('npm run e2b:build:prod')} - Build production template`
)
} catch (err) {
console.warn(
'\n⚠️ Could not add scripts to package.json:',
err instanceof Error ? err.message : err
)
}
}
export const initCommand = new commander.Command('init')
.description('initialize a new sandbox template using the SDK')
.addOption(pathOption)
.option('-n, --name <name>', 'template name', (value) => {
try {
return validateTemplateName(value)
} catch (err) {
throw new commander.InvalidArgumentError(
err instanceof Error ? err.message : String(err)
)
}
})
.option(
'-l, --language <language>',
`target language: ${Object.values(Language).join(', ')}`,
(value) => {
if (!Object.values(Language).includes(value as Language)) {
throw new commander.InvalidArgumentError(
`Invalid language. Must be one of: ${Object.values(Language).join(
', '
)}`
)
}
return value as Language
}
)
.alias('it')
.action(
async (opts: { path?: string; name?: string; language?: Language }) => {
try {
process.stdout.write('\n')
const root = getRoot(opts.path)
console.log('🚀 Initializing Sandbox Template...\n')
// Step 1: Get template name (from CLI or prompt)
let templateName: string = opts.name ?? DEFAULT_TEMPLATE_NAME
if (opts.name === undefined) {
templateName = await input({
message: 'Enter template name:',
default: DEFAULT_TEMPLATE_NAME,
validate: (input: string) => {
try {
validateTemplateName(input)
} catch (err) {
return err instanceof Error ? err.message : err
}
return true
},
})
}
templateName = validateTemplateName(templateName)
console.log(`Using template name: ${templateName}`)
// Step 2: Get language (from CLI or prompt)
let language: Language
if (opts.language) {
language = opts.language
console.log(`Using language: ${languageDisplay[language]}`)
} else {
language = await select({
message: 'Select target language for template files:',
choices: [
{
name: languageDisplay[Language.TypeScript],
value: Language.TypeScript,
description:
'Generate .ts files for JavaScript/TypeScript projects',
},
{
name: languageDisplay[Language.PythonSync],
value: Language.PythonSync,
description: 'Generate synchronous Python template files',
},
{
name: languageDisplay[Language.PythonAsync],
value: Language.PythonAsync,
description: 'Generate asynchronous Python template files',
},
],
default: Language.TypeScript,
})
}
// Step 3: Create template directory - fail if it already exists
const templateDirName = templateName
const templateDir = path.join(root, templateDirName)
if (fs.existsSync(templateDir)) {
throw new Error(
`Directory '${templateDirName}' already exists. Please choose a different template name or remove the existing directory.`
)
}
await fs.promises.mkdir(templateDir, { recursive: true })
// Step 4: Generate template and build files in the template directory
const generatedFiles = await generateTemplateFiles(
templateDir,
templateName,
language
)
// Step 5: Add scripts
switch (language) {
case Language.TypeScript:
await addPackageJsonScripts(root, generatedFiles, templateDirName)
break
case Language.PythonAsync:
case Language.PythonSync:
await addMakefileScripts(root, generatedFiles, templateDirName)
break
default:
throw new Error('Unsupported language for scripts')
}
// Step 6: Create README.md
const readmeContent = await generateReadmeContent(
templateName,
templateDirName,
generatedFiles
)
const readmeFilePath = path.join(templateDir, 'README.md')
await fs.promises.writeFile(readmeFilePath, readmeContent, 'utf8')
console.log('\n🎉 Template initialized successfully!')
console.log(
`\nTemplate created in: ${asPrimary(`./${templateDirName}/`)}`
)
console.log('\n🔨 To get started with your template:')
switch (language) {
case Language.TypeScript:
console.log(
` ${asPrimary('npm install e2b')} (install e2b dependency)`
)
console.log(
` ${asPrimary('npm run e2b:build:dev')} (for development)`
)
console.log(
` ${asPrimary('npm run e2b:build:prod')} (for production)`
)
break
case Language.PythonAsync:
case Language.PythonSync:
console.log(
` ${asPrimary('pip install e2b')} (install e2b dependency)`
)
console.log(
` ${asPrimary('make e2b:build:dev')} (for development)`
)
console.log(
` ${asPrimary('make e2b:build:prod')} (for production)`
)
break
default:
throw new Error('Unsupported language for instructions')
}
console.log(
`\nLearn more about Sandbox Templates: ${asPrimary(
'https://e2b.dev/docs'
)}\n`
)
} catch (err: any) {
console.error(err)
process.exit(1)
}
}
)
+127
View File
@@ -0,0 +1,127 @@
import * as tablePrinter from 'console-table-printer'
import * as commander from 'commander'
import * as e2b from 'e2b'
import { listAliases } from '../../utils/format'
import { sortTemplatesAliases } from 'src/utils/templateSort'
import { client, ensureAPIKey, resolveTeamId } from 'src/api'
import { teamOption } from '../../options'
import { handleE2BRequestError } from '../../utils/errors'
export const listCommand = new commander.Command('list')
.description('list sandbox templates')
.alias('ls')
.addOption(teamOption)
.option('-f, --format <format>', 'output format, eg. json, pretty')
.action(async (opts: { team: string; format: string }) => {
try {
const format = opts.format || 'pretty'
ensureAPIKey()
process.stdout.write('\n')
const templates = await listSandboxTemplates({
teamID: resolveTeamId(opts.team),
})
for (const template of templates) {
sortTemplatesAliases(template.aliases)
}
if (format === 'pretty') {
renderTable(templates)
} else if (format === 'json') {
console.log(JSON.stringify(templates, null, 2))
} else {
console.error(`Unsupported output format: ${format}`)
process.exit(1)
}
} catch (err: any) {
console.error(err)
process.exit(1)
}
})
function renderTable(templates: e2b.components['schemas']['Template'][]) {
if (!templates?.length) {
console.log('No templates found.')
return
}
const table = new tablePrinter.Table({
title: 'Sandbox templates',
columns: [
{ name: 'visibility', alignment: 'left', title: 'Access' },
{ name: 'templateID', alignment: 'left', title: 'Template ID' },
{
name: 'aliases',
alignment: 'left',
title: 'Template Name',
color: 'orange',
maxLen: 20,
},
{ name: 'cpuCount', alignment: 'right', title: 'vCPUs' },
{ name: 'memoryMB', alignment: 'right', title: 'RAM MiB' },
{ name: 'createdBy', alignment: 'right', title: 'Created by' },
{ name: 'createdAt', alignment: 'right', title: 'Created at' },
{ name: 'diskSizeMB', alignment: 'right', title: 'Disk size MiB' },
{ name: 'envdVersion', alignment: 'right', title: 'Envd version' },
],
disabledColumns: [
'public',
'buildID',
'buildCount',
'lastSpawnedAt',
'spawnCount',
'updatedAt',
],
rows: templates.map((template) => ({
...template,
visibility: template.public ? 'Public' : 'Private',
aliases: listAliases(template.aliases),
createdBy: template.createdBy?.email,
createdAt: new Date(template.createdAt).toLocaleDateString(),
})),
style: {
headerTop: {
left: '',
right: '',
mid: '',
other: '',
},
headerBottom: {
left: '',
right: '',
mid: '',
other: '',
},
tableBottom: {
left: '',
right: '',
mid: '',
other: '',
},
vertical: '',
},
colorMap: {
orange: '\x1b[38;5;216m',
},
})
table.printTable()
process.stdout.write('\n')
}
export async function listSandboxTemplates({
teamID,
}: {
teamID?: string
}): Promise<e2b.components['schemas']['Template'][]> {
const templates = await client.api.GET('/templates', {
params: {
query: { teamID },
},
})
handleE2BRequestError(templates, 'Error getting templates')
return templates.data
}
@@ -0,0 +1,317 @@
import { select } from '@inquirer/prompts'
import * as commander from 'commander'
import { Template, TemplateBuilder, TemplateClass } from 'e2b'
import * as fs from 'fs'
import * as path from 'path'
import { E2BConfig, getConfigPath, loadConfig } from '../../config'
import { defaultDockerfileName } from '../../docker/constants'
import { configOption, parsePositiveInt, pathOption } from '../../options'
import { getRoot } from '../../utils/filesystem'
import { asLocal, asLocalRelative, asPrimary } from '../../utils/format'
import { getDockerfile } from './dockerfile'
import { validateTemplateName } from '../../utils/templateName'
import {
generateAndWriteTemplateFiles,
Language,
languageDisplay,
} from './generators'
/**
* Migrate Dockerfile to a specific target language using SDK
*/
async function migrateToLanguage(
root: string,
config: E2BConfig,
dockerfileContent: string,
language: Language,
nameOverride?: string
): Promise<void> {
// Initialize template with file context
const template = Template({
fileContextPath: root,
})
// Parse Dockerfile using SDK
let baseTemplate: TemplateBuilder
try {
baseTemplate = template.fromDockerfile(dockerfileContent)
} catch (error) {
console.warn(
"\n⚠️ Unfortunately, we weren't able to fully convert the template to the new SDK format."
)
console.warn(
'\nPlease build the Docker image manually, push it to a repository of your choice, and then reference it.'
)
console.warn("\nHere's an example of how to build the Docker image:")
console.warn(
` ${asPrimary(
'docker build -f e2b.Dockerfile --platform linux/amd64 -t your-image-tag .'
)}`
)
console.warn(
'\nAfter building and pushing your image to a repository of your choice, update the generated template files to use the actual image tag.'
)
if (error instanceof Error) {
console.warn('\nCause:', error.message)
}
baseTemplate = template.fromImage('my-custom-image')
}
// Apply config start/ready commands
let parsedTemplate: TemplateClass = baseTemplate
if (config.start_cmd) {
parsedTemplate = baseTemplate.setStartCmd(
config.start_cmd,
config.ready_cmd || 'sleep 20'
)
} else if (config.ready_cmd) {
parsedTemplate = baseTemplate.setReadyCmd(config.ready_cmd)
}
const name = nameOverride || config.template_name || config.template_id
if (!name) {
throw new Error('Template name or ID is required')
}
// Generate code for the target language using shared functionality
await generateAndWriteTemplateFiles(
root,
name,
language,
parsedTemplate,
config.cpu_count,
config.memory_mb
)
}
export const migrateCommand = new commander.Command('migrate')
.description(
`migrate ${asLocal('e2b.Dockerfile')} and ${asLocal(
'e2b.toml'
)} to new Template SDK format`
)
.option(
'-d, --dockerfile <file>',
`specify path to Dockerfile. Defaults to ${asLocal('e2b.Dockerfile')}`
)
.addOption(configOption)
.option(
'-n, --name <name>',
'override the template name used in the generated files. Defaults to the template name or ID from the config file.',
(value) => {
try {
return validateTemplateName(value)
} catch (err) {
throw new commander.InvalidArgumentError(
err instanceof Error ? err.message : String(err)
)
}
}
)
.option(
'-c, --cmd <start-command>',
'override the command that will be executed when the sandbox is started.'
)
.option(
'--ready-cmd <ready-command>',
'override the command that will need to exit 0 for the template to be ready.'
)
.option(
'--cpu-count <cpu-count>',
'override the number of CPUs that will be used to run the sandbox.',
parsePositiveInt('CPU count')
)
.option(
'--memory-mb <memory-mb>',
'override the amount of memory in megabytes that will be used to run the sandbox. Must be an even number.',
parsePositiveInt('Memory in megabytes')
)
.option(
'-l, --language <language>',
`specify target language: ${Object.values(Language).join(', ')}`,
(value) => {
if (!Object.values(Language).includes(value as Language)) {
throw new commander.InvalidArgumentError(
`Invalid language. Must be one of: ${Object.values(Language).join(
', '
)}`
)
}
return value as Language
}
)
.addOption(pathOption)
.action(
async (opts: {
dockerfile?: string
config?: string
path?: string
language?: Language
name?: string
cmd?: string
readyCmd?: string
cpuCount?: number
memoryMb?: number
}) => {
let success = false
try {
console.log('\n🔄 Migrating template configuration to SDK format...\n')
// Validate memory override
if (opts.memoryMb && opts.memoryMb % 2 !== 0) {
throw new Error(
`The memory in megabytes must be an even number. You provided ${asLocal(
opts.memoryMb.toFixed(0)
)}.`
)
}
const root = getRoot(opts.path)
const configPath = getConfigPath(root, opts.config)
const { dockerfileContent, dockerfilePath, dockerfileRelativePath } =
getDockerfile(root, opts.dockerfile)
let config: E2BConfig = {
template_id: 'name-your-template',
dockerfile: defaultDockerfileName,
}
// Validate config file exists
if (fs.existsSync(configPath)) {
config = await loadConfig(configPath)
} else {
console.error(
`Config file ${asLocalRelative(
path.relative(root, configPath)
)} not found. Using defaults.`
)
}
// Apply command-line overrides on top of the loaded config
if (opts.cmd !== undefined) {
config.start_cmd = opts.cmd
}
if (opts.readyCmd !== undefined) {
config.ready_cmd = opts.readyCmd
}
if (opts.cpuCount !== undefined) {
config.cpu_count = opts.cpuCount
}
if (opts.memoryMb !== undefined) {
config.memory_mb = opts.memoryMb
}
// Determine target language
let language: Language
if (opts.language) {
language = opts.language
console.log(`Using language: ${asPrimary(languageDisplay[language])}`)
} else {
// Prompt for language selection
language = await select({
message: 'Select target language for Template SDK:',
choices: [
{
name: languageDisplay[Language.TypeScript],
value: Language.TypeScript,
description:
'Generate .ts files for JavaScript/TypeScript projects',
},
{
name: languageDisplay[Language.PythonSync],
value: Language.PythonSync,
description: 'Generate synchronous Python template files',
},
{
name: languageDisplay[Language.PythonAsync],
value: Language.PythonAsync,
description: 'Generate asynchronous Python template files',
},
],
default: Language.TypeScript,
})
}
// Perform migration
await migrateToLanguage(
root,
config,
dockerfileContent,
language,
opts.name
)
// Rename old files to .old extensions
const oldFilesRenamed: { oldPath: string; newPath: string }[] = []
// Rename Dockerfile if it exists
if (fs.existsSync(dockerfilePath)) {
const oldDockerfilePath = `${dockerfilePath}.old`
fs.renameSync(dockerfilePath, oldDockerfilePath)
oldFilesRenamed.push({
oldPath: dockerfileRelativePath,
newPath: path.relative(root, oldDockerfilePath),
})
}
// Rename e2b.toml if it exists
if (fs.existsSync(configPath)) {
const oldConfigPath = `${configPath}.old`
fs.renameSync(configPath, oldConfigPath)
oldFilesRenamed.push({
oldPath: path.relative(root, configPath),
newPath: path.relative(root, oldConfigPath),
})
}
if (oldFilesRenamed.length > 0) {
console.log('\n📁 Old template files no longer needed:')
oldFilesRenamed.forEach((file) => {
console.log(
` ${asLocalRelative(file.oldPath)}${asLocalRelative(file.newPath)}`
)
})
}
console.log('\n🎉 Migration completed successfully!')
console.log('\n🔨 To get started with your template:')
if (language === Language.TypeScript) {
console.log(
` ${asPrimary('npm install e2b')} (install e2b dependency)`
)
console.log(
` ${asPrimary('npx tsx build.dev.ts')} (run development build)`
)
console.log(
` ${asPrimary('npx tsx build.prod.ts')} (run production build)`
)
} else {
console.log(
` ${asPrimary('pip install e2b')} (install e2b dependency)`
)
console.log(
` ${asPrimary('python build_dev.py')} (run development build)`
)
console.log(
` ${asPrimary('python build_prod.py')} (run production build)`
)
}
console.log(
`\nLearn more about Template SDK: ${asPrimary(
'https://e2b.dev/docs'
)}\n`
)
success = true
} catch (err: any) {
console.error(`Migration failed: ${err.message}`)
process.exit(1)
}
if (success) {
process.exit(0)
}
}
)
@@ -0,0 +1,232 @@
import * as commander from 'commander'
import * as chalk from 'chalk'
import * as fs from 'fs'
import {
asBold,
asFormattedError,
asFormattedSandboxTemplate,
asLocal,
asLocalRelative,
} from 'src/utils/format'
import {
configOption,
pathOption,
selectMultipleOption,
teamOption,
} from 'src/options'
import { configName, E2BConfig, getConfigPath, loadConfig } from 'src/config'
import { getRoot } from 'src/utils/filesystem'
import { listSandboxTemplates } from './list'
import { getPromptTemplates } from 'src/utils/templatePrompt'
import { confirm } from 'src/utils/confirm'
import { client, resolveTeamId } from 'src/api'
import { handleE2BRequestError } from '../../utils/errors'
async function publishTemplate(templateID: string, publish: boolean) {
const res = await client.api.PATCH('/v2/templates/{templateID}', {
params: {
path: {
templateID,
},
},
body: {
public: publish,
},
})
handleE2BRequestError(
res,
`Error ${publish ? 'publishing' : 'unpublishing'} sandbox template`
)
return res.data?.names ?? []
}
async function templateAction(
publish: boolean,
template: string,
opts: {
path?: string
config?: string
yes?: boolean
select?: boolean
team?: string
}
) {
try {
let teamId = opts.team
const root = getRoot(opts.path)
const templates: (Pick<E2BConfig, 'template_id'> & {
configPath?: string
})[] = []
if (template) {
templates.push({
template_id: template,
})
} else if (opts.select) {
teamId = resolveTeamId(teamId)
const allTemplates = await listSandboxTemplates({
teamID: teamId,
})
const filteredTemplates = allTemplates.filter(
(e) => !e.public === publish
)
if (filteredTemplates.length === 0) {
console.log(
`No sandbox templates available ${
publish ? 'to publish' : 'to unpublish'
} found`
)
return
}
const selectedTemplates = await getPromptTemplates(
filteredTemplates,
`Select sandbox templates to ${publish ? 'publish' : 'unpublish'}`
)
templates.push(
...selectedTemplates.map((e) => ({
template_id: e.templateID,
...e,
}))
)
if (!templates || templates.length === 0) {
console.log('No sandbox templates selected')
return
}
} else {
const configPath = getConfigPath(root, opts.config)
const config = fs.existsSync(configPath)
? await loadConfig(configPath)
: undefined
if (!config) {
console.log(
`No ${asLocal(configName)} found in ${asLocalRelative(
root
)}. Specify sandbox template with ${asBold(
'[template]'
)} argument or use interactive mode with ${asBold('-s')} flag.`
)
return
}
templates.push({
...config,
configPath,
})
}
if (!templates || templates.length === 0) {
console.log(
`No sandbox templates selected. Specify sandbox template with ${asBold(
'[template]'
)} argument or use interactive mode with ${asBold('-s')} flag.`
)
return
}
console.log(
chalk.default.underline(
`Sandbox templates to ${publish ? 'publish' : 'unpublish'}`
)
)
templates.forEach((e) =>
console.log(
asFormattedSandboxTemplate(
{ ...e, templateID: e.template_id },
e.configPath
)
)
)
process.stdout.write('\n')
if (!opts.yes) {
const confirmed = await confirm(
`Do you really want to ${publish ? 'publish' : 'unpublish'} ${
templates.length === 1 ? 'this template' : 'these templates'
}?\n⚠️ This will make the ${
templates.length === 1 ? 'template' : 'templates'
} ${
publish
? 'public to everyone outside your team'
: 'private to your team'
}`
)
if (!confirmed) {
console.log('Canceled')
return
}
}
await Promise.all(
templates.map(async (e) => {
console.log(
`- ${
publish ? 'Publishing' : 'Unpublishing'
} sandbox template ${asFormattedSandboxTemplate(
{ ...e, templateID: e.template_id },
e.configPath
)}`
)
const names = await publishTemplate(e.template_id, publish)
if (publish && names.length > 0) {
console.log(` Published as: ${asBold(names.join(', '))}`)
}
})
)
process.stdout.write('\n')
} catch (err: any) {
console.error(asFormattedError(err.message))
process.exit(1)
}
}
export const publishCommand = new commander.Command('publish')
.description('publish sandbox template')
.argument(
'[template]',
`specify ${asBold(
'[template]'
)} to publish it. If you dont specify ${asBold(
'[template]'
)} the command will try to publish sandbox template defined by ${asLocal(
'e2b.toml'
)}.`
)
.addOption(pathOption)
.addOption(configOption)
.addOption(selectMultipleOption)
.addOption(teamOption)
.alias('pb')
.option('-y, --yes', 'skip manual publish confirmation')
.action(templateAction.bind(null, true))
export const unPublishCommand = new commander.Command('unpublish')
.description('unpublish sandbox template')
.argument(
'[template]',
`specify ${asBold(
'[template]'
)} to unpublish it. If you don't specify ${asBold(
'[template]'
)} the command will try to unpublish sandbox template defined by ${asLocal(
'e2b.toml'
)}.`
)
.addOption(pathOption)
.addOption(configOption)
.addOption(selectMultipleOption)
.addOption(teamOption)
.alias('upb')
.option('-y, --yes', 'skip manual unpublish confirmation')
.action(templateAction.bind(null, false))
+129
View File
@@ -0,0 +1,129 @@
import * as yup from 'yup'
import * as toml from '@iarna/toml'
import * as fsPromise from 'fs/promises'
import * as fs from 'fs'
import * as path from 'path'
import { asFormattedSandboxTemplate, asLocalRelative } from 'src/utils/format'
export const configName = 'e2b.toml'
function getConfigHeader(config: E2BConfig) {
return `# This is a config for E2B sandbox template.
# You can use template ID (${config.template_id}) ${
config.template_name ? `or template name (${config.template_name}) ` : ''
}to create a sandbox:
# Python SDK
# from e2b import Sandbox, AsyncSandbox
# sandbox = Sandbox.create("${
config.template_name || config.template_id
}") # Sync sandbox
# sandbox = await AsyncSandbox.create("${
config.template_name || config.template_id
}") # Async sandbox
# JS SDK
# import { Sandbox } from 'e2b'
# const sandbox = await Sandbox.create('${
config.template_name || config.template_id
}')
`
}
export const configSchema = yup.object({
template_id: yup.string().required(),
template_name: yup.string().optional(),
dockerfile: yup.string().required(),
start_cmd: yup.string().optional(),
ready_cmd: yup.string().optional(),
cpu_count: yup.number().integer().min(1).optional(),
memory_mb: yup.number().integer().min(128).optional(),
team_id: yup.string().optional(),
})
export type E2BConfig = yup.InferType<typeof configSchema>
interface Migration {
from: string
to: string
}
// List of name migrations from old config format to new one.
// We need to keep this list to be able to migrate old configs to new format.
const migrations: Migration[] = [
{
from: 'id',
to: 'template_id',
},
{
from: 'name',
to: 'template_name',
},
]
function applyMigrations(config: toml.JsonMap, migrations: Migration[]) {
for (const migration of migrations) {
const from = migration.from
const to = migration.to
if (config[from]) {
config[to] = config[from]
delete config[from]
}
}
return config
}
export async function loadConfig(configPath: string) {
const tomlRaw = await fsPromise.readFile(configPath, 'utf-8')
const config = toml.parse(tomlRaw)
const migratedConfig = applyMigrations(config, migrations)
return (await configSchema.validate(migratedConfig)) as E2BConfig
}
export async function saveConfig(
configPath: string,
config: E2BConfig,
overwrite?: boolean
) {
try {
if (!overwrite) {
const configExists = fs.existsSync(configPath)
if (configExists) {
throw new Error(
`Config already exists on path ${asLocalRelative(configPath)}`
)
}
}
const validatedConfig: any = await configSchema.validate(config, {
stripUnknown: true,
})
const tomlRaw = toml.stringify(validatedConfig)
await fsPromise.writeFile(configPath, getConfigHeader(config) + tomlRaw)
} catch (err: any) {
throw new Error(
`E2B sandbox template config ${asFormattedSandboxTemplate(
{
templateID: config.template_id,
},
configPath
)} cannot be saved: ${err.message}`
)
}
}
export async function deleteConfig(configPath: string) {
await fsPromise.unlink(configPath)
}
export function getConfigPath(root: string, configPath?: string) {
if (configPath && path.isAbsolute(configPath)) return configPath
return path.join(root, configPath || configName)
}
+2
View File
@@ -0,0 +1,2 @@
export const defaultDockerfileName = 'e2b.Dockerfile'
export const fallbackDockerfileName = 'Dockerfile'
+40
View File
@@ -0,0 +1,40 @@
#!/usr/bin/env -S node --enable-source-maps
import simpleUpdateNotifier from 'simple-update-notifier'
import * as commander from 'commander'
import * as packageJSON from '../package.json'
import { program } from './commands'
import { commands2md } from './utils/commands2md'
export const pkg = packageJSON
const updateCheck = simpleUpdateNotifier({
pkg,
updateCheckInterval: 1000 * 60 * 60 * 8, // 8 hours
}).catch((e) => {
if (process.env.DEBUG) {
console.error('Update check failed:', e)
}
})
const prog = program.version(
packageJSON.version,
undefined,
'display E2B CLI version'
)
if (process.env.NODE_ENV === 'development') {
prog
.addOption(new commander.Option('-cmd2md').hideHelp())
.on('option:-cmd2md', () => {
commands2md(program.commands as any)
process.exit(0)
})
}
async function main() {
await prog.parseAsync()
await updateCheck
}
main()
+43
View File
@@ -0,0 +1,43 @@
import * as commander from 'commander'
import { asBold, asLocal } from './utils/format'
/**
* Parse a CLI option as a positive integer, rejecting non-numeric values so
* they don't silently become NaN.
*/
export function parsePositiveInt(label: string): (value: string) => number {
return (value) => {
const parsed = Number(value)
if (!Number.isInteger(parsed) || parsed < 1) {
throw new commander.InvalidArgumentError(
`${label} must be a positive integer. You provided ${asLocal(value)}.`
)
}
return parsed
}
}
export const pathOption = new commander.Option(
'-p, --path <path>',
`change root directory where command is executed to ${asBold(
'<path>'
)} directory`
)
export const configOption = new commander.Option(
'--config <e2b-toml>',
`specify path to the E2B config toml. By default E2B tries to find ${asBold(
'./e2b.toml'
)} in root directory. We recommend using the new build system (https://e2b.dev/docs/template/defining-template) that does not use config files.`
)
export const selectMultipleOption = new commander.Option(
'-s, --select',
'select sandbox template from interactive list'
)
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).'
)
@@ -0,0 +1,21 @@
import asyncio
from e2b import AsyncTemplate, default_build_logger
from template import template
async def main():
await AsyncTemplate.build(
template,
"{{name}}",
{{#if cpuCount}}
cpu_count={{cpuCount}},
{{/if}}
{{#if memoryMB}}
memory_mb={{memoryMB}},
{{/if}}
on_build_logs=default_build_logger(),
)
if __name__ == "__main__":
asyncio.run(main())
@@ -0,0 +1,16 @@
from e2b import Template, default_build_logger
from template import template
if __name__ == "__main__":
Template.build(
template,
"{{name}}",
{{#if cpuCount}}
cpu_count={{cpuCount}},
{{/if}}
{{#if memoryMB}}
memory_mb={{memoryMB}},
{{/if}}
on_build_logs=default_build_logger(),
)
@@ -0,0 +1,36 @@
from e2b import {{#if isAsync}}AsyncTemplate{{else}}Template{{/if}}
template = (
{{#if isAsync}}AsyncTemplate{{else}}Template{{/if}}()
{{#if fromImage}}
.from_image("{{{fromImage}}}")
{{/if}}
{{#each steps}}
{{#eq type "WORKDIR"}}
.set_workdir("{{{args.[0]}}}")
{{/eq}}
{{#eq type "USER"}}
.set_user("{{{args.[0]}}}")
{{/eq}}
{{#eq type "ENV"}}
.set_envs({
{{#each envVars}}
"{{{@key}}}": "{{{this}}}",
{{/each}}
})
{{/eq}}
{{#eq type "RUN"}}
.run_cmd("{{{args.[0]}}}")
{{/eq}}
{{#eq type "COPY"}}
.copy("{{{src}}}", "{{{dest}}}")
{{/eq}}
{{/each}}
{{#if startCmd}}
{{#if readyCmd}}
.set_start_cmd("sudo {{{escapeDoubleQuotes startCmd}}}", "{{{escapeDoubleQuotes readyCmd}}}")
{{/if}}
{{else if readyCmd}}
.set_ready_cmd("sudo {{{escapeDoubleQuotes readyCmd}}}")
{{/if}}
)
+99
View File
@@ -0,0 +1,99 @@
# {{name}} - E2B Sandbox Template
This is an E2B sandbox template that allows you to run code in a controlled environment.
## Prerequisites
Before you begin, make sure you have:
- An E2B account (sign up at [e2b.dev](https://e2b.dev))
- Your E2B API key (get it from your [E2B dashboard](https://e2b.dev/dashboard))
{{#if isTypeScript}}- Node.js and npm/yarn (or similar) installed{{else if isPython}}- Python installed{{/if}}
## Configuration
1. Create a `.env` file in your project root or set the environment variable:
```
E2B_API_KEY=your_api_key_here
```
## Installing Dependencies
```bash
{{#if isTypeScript}}
npm install e2b
{{else if isPython}}
pip install e2b
{{/if}}
```
## Building the Template
```bash
{{#if isTypeScript}}
# For development
npm run e2b:build:dev
# For production
npm run e2b:build:prod
{{else if isPython}}
# For development
make e2b:build:dev
# For production
make e2b:build:prod
{{/if}}
```
## Using the Template in a Sandbox
Once your template is built, you can use it in your E2B sandbox:
{{#if isTypeScript}}
```typescript
import { Sandbox } from 'e2b'
// Create a new sandbox instance
const sandbox = await Sandbox.create('{{name}}')
// Your sandbox is ready to use!
console.log('Sandbox created successfully')
```
{{else if isPythonSync}}
```python
from e2b import Sandbox
# Create a new sandbox instance
sandbox = Sandbox.create('{{name}}')
# Your sandbox is ready to use!
print('Sandbox created successfully')
```
{{else if isPythonAsync}}
```python
from e2b import AsyncSandbox
import asyncio
async def main():
# Create a new sandbox instance
sandbox = await AsyncSandbox.create('{{name}}')
# Your sandbox is ready to use!
print('Sandbox created successfully')
# Run the async function
asyncio.run(main())
```
{{/if}}
## Template Structure
- `{{templateFile}}` - Defines the sandbox template configuration
- `{{buildDevFile}}` - Builds the template for development
- `{{buildProdFile}}` - Builds the template for production
## Next Steps
1. Customize the template in `{{templateFile}}` to fit your needs
2. Build the template using one of the methods above
3. Use the template in your E2B sandbox code
4. Check out the [E2B documentation](https://e2b.dev/docs) for more advanced usage
@@ -0,0 +1,16 @@
import { Template, defaultBuildLogger } from 'e2b'
import { template } from './template'
async function main() {
await Template.build(template, '{{name}}', {
{{#if cpuCount}}
cpuCount: {{cpuCount}},
{{/if}}
{{#if memoryMB}}
memoryMB: {{memoryMB}},
{{/if}}
onBuildLogs: defaultBuildLogger(),
});
}
main().catch(console.error);
@@ -0,0 +1,34 @@
import { Template } from 'e2b'
export const template = Template()
{{#if fromImage}}
.fromImage('{{{fromImage}}}')
{{/if}}
{{#each steps}}
{{#eq type "WORKDIR"}}
.setWorkdir('{{{args.[0]}}}')
{{/eq}}
{{#eq type "USER"}}
.setUser('{{{args.[0]}}}')
{{/eq}}
{{#eq type "ENV"}}
.setEnvs({
{{#each envVars}}
'{{{@key}}}': '{{{this}}}',
{{/each}}
})
{{/eq}}
{{#eq type "RUN"}}
.runCmd('{{{args.[0]}}}')
{{/eq}}
{{#eq type "COPY"}}
.copy('{{{src}}}', '{{{dest}}}')
{{/eq}}
{{/each}}
{{#if startCmd}}
{{#if readyCmd}}
.setStartCmd('sudo {{{escapeQuotes startCmd}}}', '{{{escapeQuotes readyCmd}}}')
{{/if}}
{{else if readyCmd}}
.setReadyCmd('sudo {{{escapeQuotes readyCmd}}}')
{{/if}}
+108
View File
@@ -0,0 +1,108 @@
import * as e2b from 'e2b'
const FLUSH_INPUT_INTERVAL_MS = 10
function getStdoutSize() {
return {
cols: process.stdout.columns,
rows: process.stdout.rows,
}
}
export async function spawnConnectedTerminal(sandbox: e2b.Sandbox) {
// Clear local terminal emulator before starting terminal
// process.stdout.write('\x1b[2J\x1b[0f')
process.stdin.setRawMode(true)
process.stdout.setEncoding('utf-8')
const terminalSession = await sandbox.pty.create({
onData: (data) => {
process.stdout.write(data)
},
...getStdoutSize(),
timeoutMs: 0,
})
const inputQueue = new BatchedQueue<Buffer>(async (batch) => {
const combined = Buffer.concat(batch)
await sandbox.pty.sendInput(terminalSession.pid, combined)
}, FLUSH_INPUT_INTERVAL_MS)
const resizeListener = process.stdout.on('resize', () =>
sandbox.pty.resize(terminalSession.pid, getStdoutSize())
)
const stdinListener = process.stdin.on('data', (data) => {
inputQueue.push(data)
})
inputQueue.start()
// Wait for terminal session to finish
try {
await terminalSession.wait()
} catch (err: any) {
if (err instanceof e2b.CommandExitError) {
if (err.exitCode === -1 && err.error === 'signal: killed') {
return
}
if (err.exitCode === 130) {
console.warn('Terminal session was killed by user')
return
}
}
throw err
} finally {
// Cleanup
process.stdout.write('\n')
resizeListener.destroy()
stdinListener.destroy()
await inputQueue.stop()
process.stdin.setRawMode(false)
}
}
class BatchedQueue<T> {
private queue: T[] = []
private isFlushing = false
private intervalId?: NodeJS.Timeout
constructor(
private flushHandler: (batch: T[]) => Promise<void>,
private flushIntervalMs: number
) {}
push(item: T) {
this.queue.push(item)
}
start() {
this.intervalId = setInterval(async () => {
if (this.isFlushing) return
this.isFlushing = true
await this.flush()
this.isFlushing = false
}, this.flushIntervalMs)
}
async stop() {
if (this.intervalId) {
clearInterval(this.intervalId)
this.intervalId = undefined
}
await this.flush()
}
private async flush() {
if (this.queue.length === 0) return
const batch = this.queue.splice(0, this.queue.length)
try {
await this.flushHandler(batch)
} catch (err) {
console.error('Error sending input:', err)
}
}
}
+105
View File
@@ -0,0 +1,105 @@
import * as os from 'os'
import * as path from 'path'
import * as fs from 'fs'
/**
* User configuration stored in ~/.e2b/config.json
*/
export interface UserIdentity {
email: string
}
export interface UserOAuth {
token_endpoint: string
revoke_endpoint: string
client_id: string
}
export interface UserTokens {
access_token: string
refresh_token: string
}
export interface UserConfig {
version: 1
identity: UserIdentity
oauth: UserOAuth
tokens: UserTokens
last_refresh: string
teamName: string
teamId: string
teamApiKey: string
dockerProxySet?: boolean
}
type UnknownRecord = Record<string, unknown>
export const USER_CONFIG_PATH = path.join(os.homedir(), '.e2b', 'config.json') // TODO: Keep in Keychain
export const DEPRECATED_USER_CONFIG_MESSAGE =
'Your CLI authentication config is deprecated. You have been signed out. Please run `e2b auth login` again.'
export const DOCS_BASE =
process.env.E2B_DOCS_BASE ||
`https://${process.env.E2B_DOMAIN || 'e2b.dev'}/docs`
export const DASHBOARD_BASE =
process.env.E2B_DASHBOARD_BASE ||
`https://${process.env.E2B_DOMAIN || 'e2b.dev'}/dashboard`
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
const config = JSON.parse(fs.readFileSync(USER_CONFIG_PATH, 'utf8'))
if (!isUserConfig(config)) {
fs.unlinkSync(USER_CONFIG_PATH)
console.error(DEPRECATED_USER_CONFIG_MESSAGE)
return null
}
return config
}
function isObject(value: unknown): value is UnknownRecord {
return typeof value === 'object' && value !== null && !Array.isArray(value)
}
function isString(value: unknown): value is string {
return typeof value === 'string'
}
function isUserConfig(config: unknown): config is UserConfig {
if (!isObject(config)) return false
if (config.version !== 1) return false
return (
isObject(config.identity) &&
isString(config.identity.email) &&
isObject(config.oauth) &&
isString(config.oauth.token_endpoint) &&
isString(config.oauth.revoke_endpoint) &&
isString(config.oauth.client_id) &&
isObject(config.tokens) &&
isString(config.tokens.access_token) &&
isString(config.tokens.refresh_token)
)
}
export function getConfigRefreshTimestamp(): string {
return new Date().toISOString()
}
/**
* Write user config to disk with restrictive file permissions.
* The config directory is restricted to the owner and the config file is
* written as owner-readable/writable only because it contains credentials.
*/
export function writeUserConfig(configPath: string, config: UserConfig): void {
const dir = path.dirname(configPath)
fs.mkdirSync(dir, { recursive: true, mode: 0o700 })
fs.chmodSync(dir, 0o700)
fs.writeFileSync(configPath, JSON.stringify(config, null, 2), { mode: 0o600 })
fs.chmodSync(configPath, 0o600)
}
+85
View File
@@ -0,0 +1,85 @@
import { Command } from 'commander'
import fs from 'fs'
import json2md from 'json2md'
import path from 'path'
/**
* Converts command objects to Markdown documentation.
* This function takes an array of command objects and generates a structured
* Markdown document describing each command, its usage, options, and subcommands.
* @returns A string containing the entire markdown documentation for all commands.
*/
export function commands2md(commands: Command[]): void {
const outputDir = 'sdk_ref'
if (!fs.existsSync(outputDir)) {
fs.mkdirSync(outputDir, { recursive: true })
}
function commandToMd(
command: any,
parentName: string = ''
): [string, string] {
const commandName = command.name() as string
const fullName = parentName ? `${parentName} ${commandName}` : commandName
const mdStructure = [
{ h2: `e2b ${fullName}` },
{ p: command.description() },
{ h3: 'Usage' },
{
code: {
language: 'bash',
content: `e2b ${fullName} ${command.usage()}`,
},
},
...(command.options.length > 0
? [
{ h3: 'Options' },
{
ul: command.options.map(
(y: any) =>
`\`${y.flags}: ${y.description} ${
y.defaultValue !== undefined
? `[default: ${y.defaultValue}]`
: ''
}\``
),
},
]
: []),
]
let mdContent = json2md(mdStructure)
// Process subcommands
command.commands.forEach((subcommand: any) => {
const [, subMdContent] = commandToMd(subcommand, fullName)
mdContent += subMdContent + '\n\n'
})
// Clean the mdContent from terminal colors and escape HTML characters
mdContent = mdContent
.replace(/</g, '<')
.replace(/>/g, '>')
.replace(/\[1m/g, '')
.replace(/\[22m/g, '')
.replace(/\[34m/g, '')
.replace(/\[39m/g, '')
.replace(/\[38;2;255;183;102m/g, '')
return [fullName, mdContent]
}
commands.forEach((command: any) => {
try {
const [commandName, mdContent] = commandToMd(command)
const fileName = `${commandName}.md`
const filePath = path.join(outputDir, fileName)
fs.writeFileSync(filePath, mdContent)
console.log(`Generated documentation for ${commandName} at ${filePath}`)
} catch (error) {
console.error(`Error processing command: ${command.name()}`)
console.error(error)
}
})
}
+13
View File
@@ -0,0 +1,13 @@
export async function confirm(text: string, defaultAnswer = false) {
const inquirer = await import('inquirer')
const confirmAnswers = await inquirer.default.prompt([
{
name: 'confirm',
type: 'confirm',
default: defaultAnswer,
message: text,
},
])
return confirmAnswers['confirm'] as boolean
}
+72
View File
@@ -0,0 +1,72 @@
import status from 'statuses'
/**
* Thrown when a request to E2B API occurs.
*/
export class E2BRequestError extends Error {
constructor(message: any) {
super(message)
this.name = 'E2BRequestError'
}
}
type E2BResponseError = { code?: number; message?: string }
type E2BResponse<TData> =
| {
data: TData
error?: undefined
}
| {
data?: undefined
error: E2BResponseError
}
function throwE2BRequestError(error: E2BResponseError, errMsg?: string): never {
let message: string
const code = error.code ?? 0
switch (code) {
case 400:
message = 'bad request'
break
case 401:
message = 'unauthorized'
break
case 403:
message = 'forbidden'
break
case 404:
message = 'not found'
break
case 500:
message = 'internal server error'
break
default:
message = status.message[code] || 'unknown error'
break
}
throw new E2BRequestError(
`${errMsg && `${errMsg}: `}[${code}] ${message && `${message}: `}${
error.message ?? 'no message'
}`
)
}
export function handleE2BRequestError(
res: { error: E2BResponseError },
errMsg?: string
): never
export function handleE2BRequestError<TData>(
res: E2BResponse<TData>,
errMsg?: string
): asserts res is { data: TData; error?: undefined }
export function handleE2BRequestError(
res: E2BResponse<unknown>,
errMsg?: string
) {
if (!res.error) {
return
}
throwE2BRequestError(res.error, errMsg)
}
+12
View File
@@ -0,0 +1,12 @@
import * as path from 'path'
export function getRoot(templatePath?: string) {
const defaultPath = process.cwd()
if (!templatePath) return defaultPath
if (path.isAbsolute(templatePath)) return templatePath
return path.resolve(defaultPath, templatePath)
}
export function cwdRelative(absolutePath: string) {
return path.relative(process.cwd(), absolutePath)
}
+150
View File
@@ -0,0 +1,150 @@
import * as chalk from 'chalk'
import * as e2b from 'e2b'
import * as highlight from 'cli-highlight'
import * as boxen from 'boxen'
import { cwdRelative } from './filesystem'
import { UserConfig } from '../user'
export const primaryColor = '#FFB766'
export function asFormattedConfig(config: UserConfig) {
const email = asBold(config.identity.email)
const team = config.teamName
? asBold(config.teamName)
: asRed('Log out and log in to get team name')
const teamId = asBold(config.teamId)
return `You are logged in as ${email},\nSelected team: ${team} (${teamId})`
}
export function asFormattedTeam(
team: e2b.components['schemas']['Team'],
selected: string
) {
const name = asBold(team.name)
const id = asBold(team.teamID)
const isSelected =
team.teamID == selected ? asPrimary(' (currently selected team)') : ''
return `${name} (${id})${isSelected}`
}
export function asFormattedSandboxTemplate(
template: Pick<e2b.components['schemas']['Template'], 'templateID'> & {
aliases?: e2b.components['schemas']['Template']['aliases']
},
configLocalPath?: string
) {
const aliases = listAliases(template.aliases)
const name = aliases ? asBold(aliases) : ''
const configPath = configLocalPath
? asDim(' <-> ') + asLocalRelative(configLocalPath)
: ''
const id = `${template.templateID} `
return `${id}${name}${configPath}`.trim()
}
export function asRed(text: string) {
return chalk.default.redBright(text)
}
export function asFormattedError(text: string | undefined, err?: any) {
return chalk.default.redBright(
`${text ? `${text} \n` : ''}${err ? err.stack : ''}\n`
)
}
export function asDim(content?: string) {
return chalk.default.dim(content)
}
export function asBold(content: string) {
return chalk.default.bold(content)
}
export function asPrimary(content: string) {
return chalk.default.hex(primaryColor)(content)
}
export function asTimestamp(content: string) {
return chalk.default.blue(content)
}
export function asLocal(pathInLocal?: string) {
return chalk.default.blue(pathInLocal)
}
export function asLocalRelative(absolutePathInLocal?: string) {
if (!absolutePathInLocal) return ''
return asLocal('./' + cwdRelative(absolutePathInLocal))
}
export function asBuildLogs(content: string) {
return chalk.default.blueBright(content)
}
export function withUnderline(content: string) {
return chalk.default.underline(content)
}
export function listAliases(aliases: string[] | undefined) {
if (!aliases) return undefined
return aliases.join(', ')
}
export function asTypescript(code: string) {
return highlight.default(code, {
language: 'typescript',
ignoreIllegals: true,
})
}
export function asPython(code: string) {
return highlight.default(code, { language: 'python', ignoreIllegals: true })
}
export const borderStyle = {
topLeft: '',
topRight: '',
bottomLeft: '',
bottomRight: '',
top: '',
bottom: '',
left: '',
right: '',
} as const
const horizontalPadding = 2
const verticalPadding = 1
export function withDelimiter(
content: string,
title: string,
isLast?: boolean
) {
return boxen.default(content, {
borderStyle: {
...borderStyle,
top: '─',
bottom: isLast ? '─' : '',
},
titleAlignment: 'center',
float: 'left',
title: title ? asBold(title) : undefined,
margin: {
top: 0,
bottom: 0,
left: 1,
right: 0,
},
fullscreen: (w) => [w, 0],
padding: {
bottom: isLast ? verticalPadding : 0,
left: horizontalPadding,
right: horizontalPadding,
top: verticalPadding,
},
})
}
+29
View File
@@ -0,0 +1,29 @@
import { spawn } from 'child_process'
// Spawn the platform's URL opener ourselves so the 'error' listener is attached
// synchronously. The `open` package (v9.x) only attaches its listener after a
// microtask, by which point a `spawn` ENOENT (e.g. missing `xdg-open` on
// headless Linux) has already been emitted and crashes the process — see
// sindresorhus/open#144.
export function openUrlInBrowser(url: string, onError: () => void): void {
let command: string
let args: string[]
if (process.platform === 'darwin') {
command = 'open'
args = [url]
} else if (process.platform === 'win32') {
command = 'cmd'
args = ['/c', 'start', '""', url.replace(/&/g, '^&')]
} else {
command = 'xdg-open'
args = [url]
}
try {
const child = spawn(command, args, { stdio: 'ignore', detached: true })
child.once('error', onError)
child.unref()
} catch {
onError()
}
}
+16
View File
@@ -0,0 +1,16 @@
import * as os from 'os'
// Signals we handle - filtered to those defined by the OS.
// Note: SIGKILL and SIGSTOP cannot be caught.
const HANDLED_SIGNALS = (
['SIGINT', 'SIGTERM', 'SIGHUP', 'SIGQUIT', 'SIGABRT', 'SIGPIPE'] as const
).filter((sig) => sig in os.constants.signals)
export function setupSignalHandlers(
onSignal: NodeJS.SignalsListener
): () => void {
HANDLED_SIGNALS.forEach((sig) => process.on(sig, onSignal))
return () =>
HANDLED_SIGNALS.forEach((sig) => process.removeListener(sig, onSignal))
}
+30
View File
@@ -0,0 +1,30 @@
/**
* Allowed template name (alias) format, matching the server-side validation in
* e2b-dev/infra (`id.identifierRegex`): the name is trimmed and lowercased,
* then must contain only lowercase letters, numbers, dashes and underscores.
*/
const templateNameRegex = /^[a-z0-9-_]+$/
const MAX_TEMPLATE_NAME_LENGTH = 128
/**
* Validates a template name and returns its normalized form (trimmed and
* lowercased), matching how the server normalizes it.
*/
export function validateTemplateName(name: string): string {
const cleaned = name?.trim().toLowerCase()
if (!cleaned) {
throw new Error('Template name cannot be empty')
}
if (!templateNameRegex.test(cleaned)) {
throw new Error(
'Template name must contain only letters, numbers, dashes and underscores'
)
}
if (cleaned.length > MAX_TEMPLATE_NAME_LENGTH) {
throw new Error(
`Template name must be at most ${MAX_TEMPLATE_NAME_LENGTH} characters long`
)
}
return cleaned
}
+27
View File
@@ -0,0 +1,27 @@
import * as e2b from 'e2b'
import * as chalk from 'chalk'
import { asFormattedSandboxTemplate } from 'src/utils/format'
export async function getPromptTemplates(
templates: e2b.components['schemas']['Template'][],
text: string
) {
const inquirer = await import('inquirer')
const templatesAnswers = await inquirer.default.prompt([
{
name: 'templates',
message: chalk.default.underline(text),
type: 'checkbox',
pageSize: 50,
choices: templates.map((e) => ({
name: asFormattedSandboxTemplate(e),
value: e,
})),
},
])
return templatesAnswers[
'templates'
] as e2b.components['schemas']['Template'][]
}
+7
View File
@@ -0,0 +1,7 @@
import * as sdk from 'e2b'
export function sortTemplatesAliases<
E extends sdk.components['schemas']['Template']['aliases'],
>(aliases: E) {
aliases?.sort()
}
+114
View File
@@ -0,0 +1,114 @@
import * as fs from 'fs'
import { getUserConfig, writeUserConfig, USER_CONFIG_PATH } from 'src/user'
const REFRESH_SKEW_SECONDS = 60
function decodeJwtExp(token: string): number | undefined {
const payload = token.split('.')[1]
if (!payload) return undefined
try {
const decoded = JSON.parse(
Buffer.from(payload, 'base64url').toString('utf8')
) as { exp?: number }
return decoded.exp
} catch {
return undefined
}
}
export function isTokenExpired(accessToken: string): boolean {
const exp = decodeJwtExp(accessToken)
if (!exp) return true
return Math.floor(Date.now() / 1000) >= exp - REFRESH_SKEW_SECONDS
}
export class TokenRefreshError extends Error {
constructor(
public status: number,
public errorCode: string,
message: string
) {
super(message)
this.name = 'TokenRefreshError'
}
}
type RefreshedTokens = {
access_token: string
refresh_token: string
}
export async function refreshOAuthToken(
refreshToken: string,
clientId: string,
tokenEndpoint: string
): Promise<RefreshedTokens> {
const res = await fetch(tokenEndpoint, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({
grant_type: 'refresh_token',
refresh_token: refreshToken,
client_id: clientId,
}),
})
if (!res.ok) {
const body = await res.json().catch(() => ({}) as Record<string, unknown>)
throw new TokenRefreshError(
res.status,
(body.error as string) ?? 'unknown',
(body.error_description as string) ?? 'Token refresh failed'
)
}
const data = (await res.json()) as {
access_token: string
refresh_token?: string
expires_in: number
}
return {
access_token: data.access_token,
refresh_token: data.refresh_token ?? refreshToken,
}
}
export async function ensureValidAccessToken(): Promise<string> {
const config = getUserConfig()
if (!config) {
throw new Error('No user config found, run `e2b auth login` first.')
}
if (!isTokenExpired(config.tokens.access_token)) {
return config.tokens.access_token
}
try {
const refreshed = await refreshOAuthToken(
config.tokens.refresh_token,
config.oauth.client_id,
config.oauth.token_endpoint
)
// Token refresh updates only tokens.* — it does NOT update last_refresh.
writeUserConfig(USER_CONFIG_PATH, {
...config,
tokens: {
access_token: refreshed.access_token,
refresh_token: refreshed.refresh_token,
},
})
return refreshed.access_token
} catch (err) {
if (err instanceof TokenRefreshError && err.errorCode === 'invalid_grant') {
fs.rmSync(USER_CONFIG_PATH, { force: true })
throw new Error(
'Your session has expired. Please run `e2b auth login` again.'
)
}
throw err
}
}
+23
View File
@@ -0,0 +1,23 @@
import { SANDBOX_INSPECT_URL } from 'src/user'
import { asPrimary } from './format'
/**
* Prints a clickable URL to the E2B Dashboard for inspecting a sandbox
*
* This function creates a terminal-clickable link that allows users to
* inspect their sandbox in the E2B Dashboard. The link is formatted with
* ANSI escape sequences to make it clickable in compatible terminals.
*
* @param {string} sandboxId - The ID of the sandbox to inspect
*/
export const printDashboardSandboxInspectUrl = (sandboxId: string) => {
const url = SANDBOX_INSPECT_URL(sandboxId)
const clickable = `\u001b]8;;${url}\u0007${url}\u001b]8;;\u0007`
console.log('')
console.log(
'Use the following link to inspect this Sandbox live inside the E2B Dashboard:'
)
console.log(asPrimary(`${clickable}`))
console.log('')
}
+3
View File
@@ -0,0 +1,3 @@
export async function wait(ms: number) {
return new Promise((resolve) => setTimeout(resolve, ms))
}
@@ -0,0 +1 @@
assets-to-be-ignored
@@ -0,0 +1,2 @@
assets-to-be-ignored
*.tar.gz
@@ -0,0 +1,4 @@
FROM ubuntu:latest
LABEL authors="i"
ENTRYPOINT ["top", "-b"]
@@ -0,0 +1,4 @@
// Console log current time with date-fns
import { format } from 'date-fns'
console.log(`Current time is ${format(new Date(), 'HH:mm:ss')}`)
@@ -0,0 +1,5 @@
{
"dependencies": {
"date-fns": "^2.30.0"
}
}
@@ -0,0 +1,207 @@
import { afterAll, beforeAll, describe, expect, test } from 'vitest'
import { Sandbox } from 'e2b'
import { getUserConfig } from 'src/user'
import {
bufferToText,
isDebug,
parseEnvInt,
runCli,
runCliWithPipedStdin,
} from '../../setup'
type UserConfigWithDomain = NonNullable<ReturnType<typeof getUserConfig>> & {
domain?: string
E2B_DOMAIN?: string
}
const userConfig = safeGetUserConfig() as UserConfigWithDomain | null
const domain =
process.env.E2B_DOMAIN ||
userConfig?.E2B_DOMAIN ||
userConfig?.domain ||
'e2b.app'
const apiKey = process.env.E2B_API_KEY || userConfig?.teamApiKey
const shouldSkip = !apiKey || isDebug
const integrationTest = test.skipIf(shouldSkip)
const templateId =
process.env.E2B_CLI_BACKEND_TEMPLATE_ID ||
process.env.E2B_TEMPLATE_ID ||
'base'
const sandboxTimeoutMs = parseEnvInt(
'E2B_CLI_BACKEND_SANDBOX_TIMEOUT_MS',
20_000
)
const perTestTimeoutMs = parseEnvInt('E2B_CLI_BACKEND_TEST_TIMEOUT_MS', 30_000)
const spawnTimeoutMs = perTestTimeoutMs
const cliEnv: NodeJS.ProcessEnv = {
...process.env,
E2B_DOMAIN: domain,
E2B_API_KEY: apiKey,
}
delete cliEnv.E2B_DEBUG
const runCliInSandbox = (args: string[]) =>
runCli(args, { timeoutMs: spawnTimeoutMs, env: cliEnv })
const runCliWithPipeInSandbox = (args: string[], input: Buffer) =>
runCliWithPipedStdin(args, input, { timeoutMs: spawnTimeoutMs, env: cliEnv })
describe('sandbox cli backend integration', () => {
let sandbox: Sandbox
beforeAll(async () => {
if (shouldSkip) return
sandbox = await Sandbox.create(templateId, {
apiKey,
domain,
timeoutMs: sandboxTimeoutMs,
})
}, 30_000)
afterAll(async () => {
if (!sandbox) return
try {
await sandbox.kill()
} catch (err) {
console.warn(
`Failed to kill sandbox ${sandbox.sandboxId} in cleanup: ${String(err)}`
)
}
}, 15_000)
integrationTest(
'list shows the sandbox',
{ timeout: perTestTimeoutMs },
async () => {
const listResult = runCliInSandbox(['sandbox', 'list', '--format', 'json'])
expect(listResult.status).toBe(0)
expect(sandboxExistsInList(listResult.stdout, sandbox.sandboxId)).toBe(true)
}
)
integrationTest(
'info shows the sandbox details',
{ timeout: perTestTimeoutMs },
async () => {
const infoResult = runCliInSandbox([
'sandbox',
'info',
sandbox.sandboxId,
'--format',
'json',
])
expect(infoResult.status).toBe(0)
const info = JSON.parse(bufferToText(infoResult.stdout)) as {
sandboxId?: string
state?: string
}
expect(info.sandboxId).toBe(sandbox.sandboxId)
expect(info.state).toBe('running')
}
)
integrationTest(
'exec runs a command without piped stdin',
{ timeout: perTestTimeoutMs },
async () => {
const execResult = runCliInSandbox([
'sandbox',
'exec',
sandbox.sandboxId,
'--',
'sh',
'-lc',
'echo backend-non-pipe',
])
expect(execResult.status).toBe(0)
expect(bufferToText(execResult.stdout)).toContain('backend-non-pipe')
}
)
integrationTest(
'exec runs a command with piped stdin',
{ timeout: perTestTimeoutMs },
async () => {
const pipedExecResult = await runCliWithPipeInSandbox(
['sandbox', 'exec', sandbox.sandboxId, '--', 'sh', '-lc', 'wc -c'],
Buffer.from('hello\n', 'utf8')
)
expect(pipedExecResult.status).toBe(0)
const pipedStdout = bufferToText(pipedExecResult.stdout).trim()
if (pipedStdout !== '6') {
expect(pipedStdout).toBe('0')
}
}
)
integrationTest(
'metrics returns successfully',
{ timeout: perTestTimeoutMs },
async () => {
const metricsResult = runCliInSandbox([
'sandbox',
'metrics',
sandbox.sandboxId,
'--format',
'json',
])
expect(metricsResult.status).toBe(0)
}
)
integrationTest(
'kill removes the sandbox',
{ timeout: perTestTimeoutMs },
async () => {
const killResult = runCliInSandbox(['sandbox', 'kill', sandbox.sandboxId])
expect(killResult.status).toBe(0)
await assertSandboxNotListed(sandbox.sandboxId)
}
)
})
async function assertSandboxNotListed(sandboxId: string): Promise<void> {
const retries = 10
const delayMs = 500
for (let i = 0; i < retries; i++) {
const listResult = runCliInSandbox(['sandbox', 'list', '--format', 'json'])
if (listResult.status === 0) {
const exists = sandboxExistsInList(listResult.stdout, sandboxId)
if (!exists) {
return
}
}
await new Promise((resolve) => setTimeout(resolve, delayMs))
}
throw new Error(`Sandbox ${sandboxId} still appears in sandbox list`)
}
function sandboxExistsInList(
output: string | Buffer | null | undefined,
sandboxId: string
): boolean {
const text = bufferToText(output).trim()
if (!text) {
return false
}
const parsed = JSON.parse(text) as Array<{ sandboxId?: string }>
return parsed.some((item) => item.sandboxId === sandboxId)
}
function safeGetUserConfig(): ReturnType<typeof getUserConfig> | null {
try {
return getUserConfig()
} catch (err) {
console.warn(`Failed to read ~/.e2b/config.json: ${String(err)}`)
return null
}
}
@@ -0,0 +1,264 @@
import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest'
const mocks = vi.hoisted(() => {
const create = vi.fn()
const ensureAPIKey = vi.fn(() => 'test-api-key')
const spawnConnectedTerminal = vi.fn()
return {
create,
ensureAPIKey,
spawnConnectedTerminal,
}
})
vi.mock('e2b', () => ({
Sandbox: {
create: mocks.create,
},
}))
vi.mock('../../../src/api', () => ({
ensureAPIKey: mocks.ensureAPIKey,
}))
vi.mock('src/utils/urls', () => ({
printDashboardSandboxInspectUrl: vi.fn(),
}))
vi.mock('src/terminal', () => ({
spawnConnectedTerminal: mocks.spawnConnectedTerminal,
}))
describe('sandbox create lifecycle options', () => {
beforeEach(() => {
vi.resetModules()
vi.clearAllMocks()
mocks.create.mockResolvedValue({
sandboxId: 'sandbox-id',
setTimeout: vi.fn().mockResolvedValue(undefined),
})
mocks.spawnConnectedTerminal.mockResolvedValue(undefined)
})
afterEach(() => {
vi.useRealTimers()
vi.restoreAllMocks()
})
test('passes ontimeout and autoresume to Sandbox.create', async () => {
const exitSpy = vi
.spyOn(process, 'exit')
.mockImplementation((() => undefined) as never)
const { createCommand } = await import(
'../../../src/commands/sandbox/create'
)
await createCommand('create', 'cr', false).parseAsync(
[
'base',
'--detach',
'--lifecycle.ontimeout',
'pause',
'--lifecycle.autoresume',
],
{ from: 'user' }
)
expect(mocks.create).toHaveBeenCalledWith('base', {
apiKey: 'test-api-key',
lifecycle: {
onTimeout: 'pause',
autoResume: true,
},
})
expect(exitSpy).toHaveBeenCalledWith(0)
})
test('passes ontimeout without autoresume to Sandbox.create', async () => {
const exitSpy = vi
.spyOn(process, 'exit')
.mockImplementation((() => undefined) as never)
const { createCommand } = await import(
'../../../src/commands/sandbox/create'
)
await createCommand('create', 'cr', false).parseAsync(
['base', '--detach', '--lifecycle.ontimeout', 'kill'],
{ from: 'user' }
)
expect(mocks.create).toHaveBeenCalledWith('base', {
apiKey: 'test-api-key',
lifecycle: {
onTimeout: 'kill',
},
})
expect(exitSpy).toHaveBeenCalledWith(0)
})
test('passes timeout seconds to Sandbox.create as milliseconds', async () => {
const exitSpy = vi
.spyOn(process, 'exit')
.mockImplementation((() => undefined) as never)
const { createCommand } = await import(
'../../../src/commands/sandbox/create'
)
await createCommand('create', 'cr', false).parseAsync(
['base', '--detach', '--timeout', '120'],
{ from: 'user' }
)
expect(mocks.create).toHaveBeenCalledWith('base', {
apiKey: 'test-api-key',
timeoutMs: 120_000,
})
expect(exitSpy).toHaveBeenCalledWith(0)
})
test('preserves explicit timeout after attached terminal closes', async () => {
vi.useFakeTimers()
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', '--timeout', '120'],
{ from: 'user' }
)
expect(mocks.spawnConnectedTerminal).toHaveBeenCalledWith(sandbox)
expect(sandbox.setTimeout).toHaveBeenCalledWith(120_000)
expect(sandbox.setTimeout).not.toHaveBeenCalledWith(1_000)
expect(exitSpy).toHaveBeenCalledWith(0)
vi.useRealTimers()
})
test('rejects autoresume without pause on timeout', async () => {
const exitSpy = vi
.spyOn(process, 'exit')
.mockImplementation((() => undefined) as never)
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
const { createCommand } = await import(
'../../../src/commands/sandbox/create'
)
await createCommand('create', 'cr', false).parseAsync(
[
'base',
'--detach',
'--lifecycle.ontimeout',
'kill',
'--lifecycle.autoresume',
],
{ from: 'user' }
)
expect(mocks.create).not.toHaveBeenCalled()
expect(consoleSpy).toHaveBeenCalledWith(
expect.objectContaining({
message: '--lifecycle.autoresume requires --lifecycle.ontimeout pause',
})
)
expect(exitSpy).toHaveBeenCalledWith(1)
})
test('rejects invalid ontimeout option', async () => {
const exitSpy = vi
.spyOn(process, 'exit')
.mockImplementation((() => undefined) as never)
const { createCommand } = await import(
'../../../src/commands/sandbox/create'
)
await expect(
createCommand('create', 'cr', false).parseAsync(
['base', '--detach', '--lifecycle.ontimeout', 'hibernate'],
{ from: 'user' }
)
).rejects.toThrow('--lifecycle.ontimeout must be "pause" or "kill"')
expect(mocks.create).not.toHaveBeenCalled()
expect(exitSpy).toHaveBeenCalledWith(1)
})
test('rejects zero timeout before creating sandbox', async () => {
const exitSpy = vi
.spyOn(process, 'exit')
.mockImplementation((() => undefined) as never)
const { createCommand } = await import(
'../../../src/commands/sandbox/create'
)
await expect(
createCommand('create', 'cr', false).parseAsync(
['base', '--detach', '--timeout', '0'],
{ from: 'user' }
)
).rejects.toThrow('--timeout must be at least 30 seconds')
expect(mocks.create).not.toHaveBeenCalled()
expect(exitSpy).toHaveBeenCalledWith(1)
})
test('rejects timeout values shorter than the keep-alive interval', async () => {
const exitSpy = vi
.spyOn(process, 'exit')
.mockImplementation((() => undefined) as never)
const { createCommand } = await import(
'../../../src/commands/sandbox/create'
)
await expect(
createCommand('create', 'cr', false).parseAsync(
['base', '--detach', '--timeout', '29.999'],
{ from: 'user' }
)
).rejects.toThrow('--timeout must be at least 30 seconds')
expect(mocks.create).not.toHaveBeenCalled()
expect(exitSpy).toHaveBeenCalledWith(1)
})
test('passes lifecycle and timeout together to Sandbox.create', async () => {
const exitSpy = vi
.spyOn(process, 'exit')
.mockImplementation((() => undefined) as never)
const { createCommand } = await import(
'../../../src/commands/sandbox/create'
)
await createCommand('create', 'cr', false).parseAsync(
[
'base',
'--detach',
'--timeout',
'120',
'--lifecycle.ontimeout',
'pause',
'--lifecycle.autoresume',
],
{ from: 'user' }
)
expect(mocks.create).toHaveBeenCalledWith('base', {
apiKey: 'test-api-key',
lifecycle: {
onTimeout: 'pause',
autoResume: true,
},
timeoutMs: 120_000,
})
expect(exitSpy).toHaveBeenCalledWith(0)
})
})
@@ -0,0 +1,189 @@
import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest'
const mocks = vi.hoisted(() => {
const connect = vi.fn()
const run = vi.fn()
const wait = vi.fn()
const sendStdin = vi.fn()
const closeStdin = vi.fn()
const kill = vi.fn()
const ensureAPIKey = vi.fn(() => 'test-api-key')
const isPipedStdin = vi.fn()
const streamStdinChunks = vi.fn()
const setupSignalHandlers = vi.fn(() => () => {})
return {
connect,
run,
wait,
sendStdin,
closeStdin,
kill,
ensureAPIKey,
isPipedStdin,
streamStdinChunks,
setupSignalHandlers,
}
})
vi.mock('e2b', () => {
class CommandExitError extends Error {
exitCode: number
constructor(exitCode: number) {
super(`Command exited with ${exitCode}`)
this.exitCode = exitCode
}
}
class NotFoundError extends Error {}
return {
Sandbox: {
connect: mocks.connect,
},
CommandExitError,
NotFoundError,
}
})
vi.mock('../../../src/api', () => ({
ensureAPIKey: mocks.ensureAPIKey,
}))
vi.mock('src/utils/signal', () => ({
setupSignalHandlers: mocks.setupSignalHandlers,
}))
vi.mock(
'../../../src/commands/sandbox/exec_helpers',
async (importOriginal: <T>() => Promise<T>) => {
const actual =
await importOriginal<
typeof import('../../../src/commands/sandbox/exec_helpers')
>()
return {
...actual,
isPipedStdin: mocks.isPipedStdin,
streamStdinChunks: mocks.streamStdinChunks,
}
}
)
describe('sandbox exec closeStdin handling', () => {
beforeEach(() => {
vi.resetModules()
vi.clearAllMocks()
mocks.wait.mockResolvedValue({ exitCode: 0 })
const handle = {
pid: 1234,
error: undefined,
wait: mocks.wait,
kill: vi.fn().mockResolvedValue(undefined),
disconnect: vi.fn().mockResolvedValue(undefined),
}
mocks.run.mockResolvedValue(handle)
mocks.sendStdin.mockResolvedValue(undefined)
mocks.closeStdin.mockResolvedValue(undefined)
mocks.kill.mockResolvedValue(true)
mocks.isPipedStdin.mockReturnValue(true)
mocks.streamStdinChunks.mockImplementation(
async (
_stream: NodeJS.ReadableStream,
onChunk: (chunk: Uint8Array) => Promise<void>,
_maxBytes: number
) => {
await onChunk(Buffer.from('hello'))
}
)
mocks.connect.mockResolvedValue({
commands: {
run: mocks.run,
sendStdin: mocks.sendStdin,
closeStdin: mocks.closeStdin,
kill: mocks.kill,
supportsStdinClose: true,
},
})
})
afterEach(() => {
vi.restoreAllMocks()
})
test('fails fast and kills remote process when closeStdin throws non-NotFoundError', async () => {
mocks.closeStdin.mockRejectedValue(new Error('close failed'))
const exitSpy = vi
.spyOn(process, 'exit')
.mockImplementation((() => undefined) as never)
vi.spyOn(console, 'error').mockImplementation(() => {})
const { execCommand } = await import('../../../src/commands/sandbox/exec')
await execCommand.parseAsync(['sandbox-id', 'cat'], {
from: 'user',
})
expect(mocks.closeStdin).toHaveBeenCalledTimes(1)
expect(mocks.kill).toHaveBeenCalledWith(1234)
expect(mocks.wait).not.toHaveBeenCalled()
expect(exitSpy).toHaveBeenCalledWith(1)
})
test('keeps NotFoundError from closeStdin non-fatal', async () => {
const { NotFoundError } = await import('e2b')
mocks.closeStdin.mockRejectedValue(new NotFoundError('already exited'))
const exitSpy = vi
.spyOn(process, 'exit')
.mockImplementation((() => undefined) as never)
vi.spyOn(console, 'error').mockImplementation(() => {})
const { execCommand } = await import('../../../src/commands/sandbox/exec')
await execCommand.parseAsync(['sandbox-id', 'cat'], {
from: 'user',
})
expect(mocks.closeStdin).toHaveBeenCalledTimes(1)
expect(mocks.kill).not.toHaveBeenCalled()
expect(mocks.wait).toHaveBeenCalledTimes(1)
expect(exitSpy).toHaveBeenCalledWith(0)
})
test('stops stdin streaming after NotFoundError from sendStdin', async () => {
const { NotFoundError } = await import('e2b')
mocks.sendStdin.mockRejectedValueOnce(new NotFoundError('already exited'))
mocks.streamStdinChunks.mockImplementation(
async (
_stream: NodeJS.ReadableStream,
onChunk: (chunk: Uint8Array) => Promise<void | boolean>,
_maxBytes: number
) => {
const shouldContinue = await onChunk(Buffer.from('first'))
if (shouldContinue === false) {
return
}
await onChunk(Buffer.from('second'))
}
)
const exitSpy = vi
.spyOn(process, 'exit')
.mockImplementation((() => undefined) as never)
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
const { execCommand } = await import('../../../src/commands/sandbox/exec')
await execCommand.parseAsync(['sandbox-id', 'cat'], {
from: 'user',
})
expect(mocks.sendStdin).toHaveBeenCalledTimes(1)
expect(mocks.closeStdin).not.toHaveBeenCalled()
expect(mocks.wait).toHaveBeenCalledTimes(1)
expect(errorSpy).toHaveBeenCalledWith(
'e2b: Remote command exited before stdin could be delivered.'
)
expect(exitSpy).toHaveBeenCalledWith(0)
})
})
@@ -0,0 +1,226 @@
import { PassThrough, Readable } from 'node:stream'
import { describe, expect, test } from 'vitest'
import {
buildCommand,
chunkBytesBySize,
isPipedStdin,
readStdinIfPiped,
readStdinFrom,
streamStdinChunks,
shellQuote,
} from '../../../src/commands/sandbox/exec_helpers'
describe('exec helpers', () => {
test('shellQuote leaves safe args untouched', () => {
expect(shellQuote('python3')).toBe('python3')
expect(shellQuote('-c')).toBe('-c')
expect(shellQuote('path/to/file.txt')).toBe('path/to/file.txt')
})
test('shellQuote wraps special chars and spaces', () => {
expect(shellQuote('print(input())')).toBe("'print(input())'")
expect(shellQuote('hello world')).toBe("'hello world'")
expect(shellQuote("it's ok")).toBe("'it'\"'\"'s ok'")
expect(shellQuote('')).toBe("''")
})
test('buildCommand returns a single command as-is', () => {
const cmd = 'python3 -c "print(input())"'
expect(buildCommand([cmd])).toBe(cmd)
})
test('buildCommand quotes args that need shell escaping', () => {
expect(buildCommand(['python3', '-c', 'print(input())'])).toBe(
"python3 -c 'print(input())'"
)
expect(buildCommand(['echo', 'hello world'])).toBe("echo 'hello world'")
expect(buildCommand(['echo', "it's ok"])).toBe("echo 'it'\"'\"'s ok'")
})
test('readStdinFrom reads full input and resolves on EOF', async () => {
const stream = Readable.from(['foo', 'bar'])
await expect(readStdinFrom(stream)).resolves.toEqual(Buffer.from('foobar'))
})
test('readStdinFrom handles EOF without trailing newline', async () => {
const stream = Readable.from(['no-newline'])
await expect(readStdinFrom(stream)).resolves.toEqual(
Buffer.from('no-newline')
)
})
test('readStdinIfPiped returns undefined when stdin is not a pipe', async () => {
const fsMock = {
fstatSync: () => ({ isFIFO: () => false }),
}
const stream = new PassThrough()
stream.end('data')
await expect(readStdinIfPiped({ fsModule: fsMock, stream })).resolves.toBe(
undefined
)
})
test('readStdinIfPiped reads from provided stream when piped', async () => {
const fsMock = {
fstatSync: () => ({ isFIFO: () => true }),
}
const stream = new PassThrough()
const promise = readStdinIfPiped({ fsModule: fsMock, stream })
stream.write(Buffer.from([0xe2, 0x98]))
stream.write(Buffer.from([0x83]))
stream.end(Buffer.from([0x21]))
await expect(promise).resolves.toEqual(Buffer.from([0xe2, 0x98, 0x83, 0x21]))
})
test('isPipedStdin returns true for FIFO', () => {
const fsMock = {
fstatSync: () => ({ isFIFO: () => true, isCharacterDevice: () => false }),
}
expect(isPipedStdin(0, fsMock)).toBe(true)
})
test('isPipedStdin returns true for file redirection', () => {
const fsMock = {
fstatSync: () => ({ isFile: () => true, isCharacterDevice: () => false }),
}
expect(isPipedStdin(0, fsMock)).toBe(true)
})
test('isPipedStdin returns false for interactive terminal', () => {
const fsMock = {
fstatSync: () => ({
isCharacterDevice: () => true,
isFIFO: () => false,
isFile: () => false,
}),
}
expect(isPipedStdin(0, fsMock)).toBe(false)
})
test('isPipedStdin returns false for non-FIFO or errors', () => {
const fsMockFalse = {
fstatSync: () => ({ isFIFO: () => false, isCharacterDevice: () => false }),
}
expect(isPipedStdin(0, fsMockFalse)).toBe(false)
const fsMockThrow = {
fstatSync: () => {
throw new Error('fail')
},
}
expect(isPipedStdin(0, fsMockThrow)).toBe(false)
})
test('chunkBytesBySize splits large input into byte-sized chunks', () => {
const maxBytes = 64 * 1024
const data = Buffer.from('a'.repeat(maxBytes * 2 + 1))
const chunks = chunkBytesBySize(data, maxBytes)
expect(chunks).toHaveLength(3)
expect(chunks[0].byteLength).toBe(maxBytes)
expect(chunks[1].byteLength).toBe(maxBytes)
expect(chunks[2].byteLength).toBe(1)
expect(Buffer.concat(chunks.map((c) => Buffer.from(c)))).toEqual(data)
})
test('chunkBytesBySize keeps byte content intact', () => {
const maxBytes = 64 * 1024
const data = Buffer.from('\u{1F600}'.repeat(20000)) // 😀 (4 bytes each)
const chunks = chunkBytesBySize(data, maxBytes)
for (const chunk of chunks) {
expect(chunk.byteLength).toBeLessThanOrEqual(maxBytes)
}
expect(Buffer.concat(chunks.map((c) => Buffer.from(c)))).toEqual(data)
})
test('chunkBytesBySize returns empty array for empty input', () => {
const chunks = chunkBytesBySize(Buffer.alloc(0), 64 * 1024)
expect(chunks).toHaveLength(0)
})
test('chunkBytesBySize returns single chunk for small input', () => {
const data = Buffer.from('hello')
const chunks = chunkBytesBySize(data, 64 * 1024)
expect(chunks).toHaveLength(1)
expect(Buffer.from(chunks[0])).toEqual(data)
})
test('chunkBytesBySize throws on invalid maxBytes', () => {
expect(() => chunkBytesBySize(Buffer.from('data'), 0)).toThrow()
expect(() => chunkBytesBySize(Buffer.from('data'), -1)).toThrow()
})
test('readStdinFrom resolves with empty buffer on immediate EOF', async () => {
const stream = Readable.from([])
await expect(readStdinFrom(stream)).resolves.toEqual(Buffer.alloc(0))
})
test('streamStdinChunks delivers chunks incrementally before EOF', async () => {
const stream = new PassThrough()
const seen: Buffer[] = []
let resolveFirstChunk: (() => void) | undefined
const firstChunkSeen = new Promise<void>((resolve) => {
resolveFirstChunk = resolve
})
const done = streamStdinChunks(
stream,
async (chunk) => {
seen.push(Buffer.from(chunk))
if (resolveFirstChunk) {
resolveFirstChunk()
resolveFirstChunk = undefined
}
},
64 * 1024
)
stream.write('first')
await firstChunkSeen
expect(seen).toEqual([Buffer.from('first')])
stream.end('second')
await done
expect(seen).toEqual([Buffer.from('first'), Buffer.from('second')])
})
test('streamStdinChunks splits oversized stream chunks by max bytes', async () => {
const stream = Readable.from([Buffer.from('a'.repeat(64 * 1024 + 3))])
const chunks: Uint8Array[] = []
await streamStdinChunks(
stream,
async (chunk) => {
chunks.push(chunk)
},
64 * 1024
)
expect(chunks).toHaveLength(2)
expect(chunks[0].byteLength).toBe(64 * 1024)
expect(chunks[1].byteLength).toBe(3)
expect(Buffer.concat(chunks.map((c) => Buffer.from(c)))).toEqual(
Buffer.from('a'.repeat(64 * 1024 + 3))
)
})
test('streamStdinChunks stops early when onChunk returns false', async () => {
const stream = Readable.from([Buffer.from('first'), Buffer.from('second')])
const chunks: Uint8Array[] = []
await streamStdinChunks(
stream,
async (chunk) => {
chunks.push(chunk)
return false
},
64 * 1024
)
expect(chunks).toHaveLength(1)
expect(Buffer.from(chunks[0])).toEqual(Buffer.from('first'))
})
})
@@ -0,0 +1,188 @@
import { randomBytes } from 'node:crypto'
import { describe, expect, test } from 'vitest'
import { Sandbox } from 'e2b'
import { getUserConfig } from 'src/user'
import {
type CliRunResult,
bufferToText,
isDebug,
parseEnvInt,
runCliWithPipedStdin,
} from '../../setup'
type PipeCase = {
name: string
data: Buffer
expectedBytes: number
timeoutMs?: number
}
type UserConfigWithDomain = NonNullable<ReturnType<typeof getUserConfig>> & {
domain?: string
E2B_DOMAIN?: string
}
const userConfig = safeGetUserConfig() as UserConfigWithDomain | null
const domain =
process.env.E2B_DOMAIN ||
userConfig?.E2B_DOMAIN ||
userConfig?.domain ||
'e2b.app'
const apiKey = process.env.E2B_API_KEY || userConfig?.teamApiKey
const shouldSkip = !apiKey || isDebug
const integrationTest = test.skipIf(shouldSkip)
const templateId =
process.env.E2B_PIPE_TEMPLATE_ID ||
process.env.E2B_TEMPLATE_ID ||
'base'
const includeLargeBinary =
process.env.E2B_PIPE_INTEGRATION_STRICT === '1' ||
process.env.E2B_PIPE_INTEGRATION_BINARY === '1' ||
process.env.STRICT === '1'
const sandboxTimeoutMs = parseEnvInt('E2B_PIPE_SANDBOX_TIMEOUT_MS', 10_000)
const testTimeoutMs = parseEnvInt('E2B_PIPE_TEST_TIMEOUT_MS', 60_000)
const defaultCmdTimeoutMs = parseEnvInt(
'E2B_PIPE_CMD_TIMEOUT_MS',
Math.min(8_000, testTimeoutMs)
)
const cliEnv: NodeJS.ProcessEnv = {
...process.env,
E2B_DOMAIN: domain,
E2B_API_KEY: apiKey,
}
delete cliEnv.E2B_DEBUG
const defaultCases: PipeCase[] = [
{
name: 'empty_eof',
data: Buffer.alloc(0),
expectedBytes: 0,
},
{
name: 'ascii_newline',
data: Buffer.from('hello\n'),
expectedBytes: 6,
},
{
name: 'ascii_no_newline',
data: Buffer.from('hello'),
expectedBytes: 5,
},
{
name: 'utf8_multibyte',
data: Buffer.from([0x68, 0x69, 0x2d, 0xe2, 0x98, 0x83]), // "hi-☃"
expectedBytes: 6,
},
{
name: 'binary_nul_ff_hex',
data: Buffer.from([0x00, 0x01, 0x02, 0xff, 0x00, 0x41]),
expectedBytes: 6,
},
{
name: 'chunk_64k',
data: Buffer.from('a'.repeat(64 * 1024)),
expectedBytes: 64 * 1024,
},
{
name: 'chunk_64k_plus_1',
data: Buffer.from('a'.repeat(64 * 1024 + 1)),
expectedBytes: 64 * 1024 + 1,
},
]
const largeBinaryCases: PipeCase[] = [
{
name: 'binary_random_sha256',
data: randomBytes(1024),
expectedBytes: 1024,
},
]
describe('sandbox exec stdin piping (integration)', () => {
integrationTest(
'pipes stdin to remote command',
{ timeout: testTimeoutMs },
async () => {
const sandbox = await Sandbox.create(templateId, {
apiKey,
domain,
timeoutMs: sandboxTimeoutMs,
})
try {
const cases = includeLargeBinary
? [...defaultCases, ...largeBinaryCases]
: defaultCases
// Probe with a simple case first — some environments (notably Windows
// CI) don't expose piped stdin so the remote byte count is 0.
const probe = cases[1] // ascii_newline
const probeResult = await runExecPipe(sandbox.sandboxId, probe)
assertExecSucceeded(probe.name, probeResult)
const probeStdout = bufferToText(probeResult.stdout).trim()
if (probeStdout === '0') {
return
}
expect(probeStdout).toBe(String(probe.expectedBytes))
for (const testCase of cases) {
const result = await runExecPipe(sandbox.sandboxId, testCase)
assertExecSucceeded(testCase.name, result)
const stdout = bufferToText(result.stdout).trim()
expect(stdout, testCase.name).toBe(String(testCase.expectedBytes))
}
} finally {
try {
await sandbox.kill()
} catch (err) {
console.warn(
`Failed to kill sandbox ${sandbox.sandboxId}: ${String(err)}`
)
}
}
}
)
})
function runExecPipe(
sandboxId: string,
testCase: PipeCase
): Promise<CliRunResult> {
return runCliWithPipedStdin(
['sandbox', 'exec', sandboxId, '--', 'sh', '-lc', 'wc -c'],
testCase.data,
{
env: cliEnv,
timeoutMs: testCase.timeoutMs ?? defaultCmdTimeoutMs,
}
)
}
function assertExecSucceeded(
name: string,
result: CliRunResult
): void {
if (result.error) {
const timedOut = (result.error as NodeJS.ErrnoException).code === 'ETIMEDOUT'
throw new Error(
`${name} ${timedOut ? 'timed out' : 'failed'}: ${result.error.message}`
)
}
const stderr = bufferToText(result.stderr).trim()
if (result.status !== 0) {
throw new Error(`${name} failed with rc=${result.status} stderr=${stderr}`)
}
}
function safeGetUserConfig(): ReturnType<typeof getUserConfig> | null {
try {
return getUserConfig()
} catch (err) {
console.warn(`Failed to read ~/.e2b/config.json: ${String(err)}`)
return null
}
}
@@ -0,0 +1,158 @@
import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest'
const mocks = vi.hoisted(() => {
const connect = vi.fn()
const run = vi.fn()
const sendStdin = vi.fn()
const closeStdin = vi.fn()
const ensureAPIKey = vi.fn(() => 'test-api-key')
const isPipedStdin = vi.fn()
const streamStdinChunks = vi.fn()
const setupSignalHandlers = vi.fn(() => () => {})
return {
connect,
run,
sendStdin,
closeStdin,
ensureAPIKey,
isPipedStdin,
streamStdinChunks,
setupSignalHandlers,
}
})
vi.mock('e2b', () => {
class CommandExitError extends Error {
exitCode: number
constructor(exitCode: number) {
super(`Command exited with ${exitCode}`)
this.exitCode = exitCode
}
}
class NotFoundError extends Error {}
return {
Sandbox: {
connect: mocks.connect,
},
CommandExitError,
NotFoundError,
}
})
vi.mock('../../../src/api', () => ({
ensureAPIKey: mocks.ensureAPIKey,
}))
vi.mock('src/utils/signal', () => ({
setupSignalHandlers: mocks.setupSignalHandlers,
}))
vi.mock(
'../../../src/commands/sandbox/exec_helpers',
async (importOriginal: <T>() => Promise<T>) => {
const actual =
await importOriginal<
typeof import('../../../src/commands/sandbox/exec_helpers')
>()
return {
...actual,
isPipedStdin: mocks.isPipedStdin,
streamStdinChunks: mocks.streamStdinChunks,
}
}
)
describe('sandbox exec stdin run flag', () => {
beforeEach(() => {
vi.resetModules()
vi.clearAllMocks()
const handle = {
pid: 1234,
error: undefined,
wait: vi.fn().mockResolvedValue({ exitCode: 0 }),
kill: vi.fn().mockResolvedValue(undefined),
disconnect: vi.fn().mockResolvedValue(undefined),
}
mocks.run.mockResolvedValue(handle)
mocks.sendStdin.mockResolvedValue(undefined)
mocks.closeStdin.mockResolvedValue(undefined)
mocks.isPipedStdin.mockReturnValue(false)
mocks.streamStdinChunks.mockResolvedValue(undefined)
mocks.connect.mockResolvedValue({
commands: {
run: mocks.run,
sendStdin: mocks.sendStdin,
closeStdin: mocks.closeStdin,
supportsStdinClose: true,
},
})
})
afterEach(() => {
vi.restoreAllMocks()
})
test('does not pass stdin flag when stdin is not piped', async () => {
const exitSpy = vi
.spyOn(process, 'exit')
.mockImplementation((() => undefined) as never)
const { execCommand } = await import('../../../src/commands/sandbox/exec')
await execCommand.parseAsync(['sandbox-id', 'echo', 'hello'], {
from: 'user',
})
expect(mocks.run).toHaveBeenCalledTimes(1)
const runOpts = mocks.run.mock.calls[0][1]
expect(runOpts).not.toHaveProperty('stdin')
expect(mocks.streamStdinChunks).not.toHaveBeenCalled()
expect(exitSpy).toHaveBeenCalledWith(0)
})
test('passes stdin: true when stdin piping is active', async () => {
mocks.isPipedStdin.mockReturnValue(true)
const events: string[] = []
mocks.setupSignalHandlers.mockImplementation(() => {
events.push('setup')
return () => {
events.push('cleanup')
}
})
mocks.streamStdinChunks.mockImplementation(
async (
_stream: NodeJS.ReadableStream,
onChunk: (chunk: Uint8Array) => Promise<void | boolean>,
_maxBytes: number
) => {
events.push('stream-start')
await onChunk(Buffer.from('hello'))
events.push('stream-end')
}
)
const exitSpy = vi
.spyOn(process, 'exit')
.mockImplementation((() => undefined) as never)
const { execCommand } = await import('../../../src/commands/sandbox/exec')
await execCommand.parseAsync(['sandbox-id', 'cat'], {
from: 'user',
})
expect(mocks.run).toHaveBeenCalledTimes(1)
const runOpts = mocks.run.mock.calls[0][1]
expect(runOpts).toHaveProperty('stdin', true)
expect(mocks.streamStdinChunks).toHaveBeenCalled()
expect(mocks.sendStdin).toHaveBeenCalled()
expect(mocks.closeStdin).toHaveBeenCalled()
expect(events).toContain('setup')
expect(events).toContain('stream-start')
expect(events.indexOf('setup')).toBeLessThan(events.indexOf('stream-start'))
expect(events).toContain('cleanup')
expect(exitSpy).toHaveBeenCalledWith(0)
})
})
@@ -0,0 +1,71 @@
import { spawnSync } from 'node:child_process'
import * as fs from 'node:fs/promises'
import * as path from 'node:path'
import { afterAll, beforeAll, describe, expect, test } from 'vitest'
const apiKey = process.env.E2B_API_KEY
const domain = process.env.E2B_DOMAIN || 'e2b.app'
const cliPath = path.join(process.cwd(), 'dist', 'index.js')
const templateName = `cli-create-api-key-test-${Date.now()}`
describe('template create cli backend integration', () => {
let testDir: string
beforeAll(async () => {
if (!apiKey) {
throw new Error(
'E2B_API_KEY must be set to run template create backend tests'
)
}
testDir = await fs.mkdtemp('e2b-create-test-')
await fs.writeFile(
path.join(testDir, 'e2b.Dockerfile'),
'FROM ubuntu:latest\n'
)
})
afterAll(async () => {
if (!testDir) return
runCli(['template', 'delete', '--yes', templateName])
await fs.rm(testDir, { recursive: true, force: true })
})
test(
'template create succeeds with E2B_API_KEY alone (no E2B_ACCESS_TOKEN)',
{ timeout: 300_000 },
() => {
const result = runCli([
'template',
'create',
templateName,
'--path',
testDir,
])
const output = String(result.stdout || '') + String(result.stderr || '')
expect(result.status, output).toBe(0)
// Success marker printed by create.ts on a finished build; the failure
// path prints "❌ Template build failed." instead.
expect(output).toContain('✅ Building sandbox template')
expect(output).not.toContain('❌ Template build failed')
// Auth never fell through to the access-token error box.
expect(output).not.toMatch(/You must be logged in/)
}
)
})
function runCli(args: string[]): ReturnType<typeof spawnSync> {
// Intentionally exclude E2B_ACCESS_TOKEN from the child env so this test
// verifies the API-key-only auth path end-to-end.
return spawnSync('node', [cliPath, ...args], {
env: {
PATH: process.env.PATH,
HOME: process.env.HOME,
E2B_DOMAIN: domain,
E2B_API_KEY: apiKey,
},
encoding: 'utf8',
timeout: 300_000,
})
}
@@ -0,0 +1,68 @@
import * as fs from 'fs/promises'
import * as path from 'path'
import { execSync } from 'child_process'
import { afterEach, beforeEach, describe, expect, test } from 'vitest'
describe('Template Delete --config', () => {
let testDir: string
beforeEach(async () => {
testDir = await fs.mkdtemp('e2b-delete-config-test-')
const defaultConfig = `template_id = "default-template-id"
dockerfile = "e2b.Dockerfile"`
await fs.writeFile(path.join(testDir, 'e2b.toml'), defaultConfig)
const customConfig = `template_id = "custom-template-id"
dockerfile = "e2b.Dockerfile"`
await fs.writeFile(path.join(testDir, 'custom.toml'), customConfig)
await fs.writeFile(path.join(testDir, 'e2b.Dockerfile'), 'FROM alpine:3.18')
})
afterEach(async () => {
if (testDir) {
await fs.rm(testDir, { recursive: true, force: true })
}
})
test('uses the config file passed via --config even when e2b.toml exists', async () => {
const cliPath = path.join(process.cwd(), 'dist', 'index.js')
let output = ''
try {
output = execSync(
`node "${cliPath}" template delete --yes --path "${testDir}" --config custom.toml 2>&1`,
{ encoding: 'utf-8', stdio: 'pipe', timeout: 10_000 }
)
} catch (err: any) {
output = (err?.stdout ?? '') + (err?.stderr ?? '')
}
expect(output).toContain('Sandbox templates to delete')
expect(output).toContain('custom-template-id')
expect(output).toContain('custom.toml')
expect(output).not.toContain('default-template-id')
})
test('uses the default e2b.toml when --config is not provided', async () => {
const cliPath = path.join(process.cwd(), 'dist', 'index.js')
let output = ''
try {
output = execSync(
`node "${cliPath}" template delete --yes --path "${testDir}" 2>&1`,
{ encoding: 'utf-8', stdio: 'pipe', timeout: 10_000 }
)
} catch (err: any) {
output = (err?.stdout ?? '') + (err?.stderr ?? '')
}
expect(output).toContain('Sandbox templates to delete')
expect(output).toContain('default-template-id')
expect(output).toContain('e2b.toml')
expect(output).not.toContain('custom-template-id')
})
})
@@ -0,0 +1,10 @@
FROM python:3.11-slim
RUN apt-get update && apt-get install -y gcc g++ make libpq-dev && rm -rf /var/lib/apt/lists/*
ENV PYTHONDONTWRITEBYTECODE=1 PYTHONUNBUFFERED=1
RUN useradd -m -u 1000 appuser
WORKDIR /app
COPY requirements.txt .
RUN pip install --upgrade pip && pip install -r requirements.txt
COPY app.py .
USER appuser
CMD ["gunicorn", "--bind", "0.0.0.0:8000", "app:application"]
@@ -0,0 +1,3 @@
template_id = "complex-python"
dockerfile = "e2b.Dockerfile"
template_name = "complex-python-app"
@@ -0,0 +1,15 @@
import asyncio
from e2b import AsyncTemplate, default_build_logger
from template import template
async def main():
await AsyncTemplate.build(
template,
"complex-python-app-dev",
on_build_logs=default_build_logger(),
)
if __name__ == "__main__":
asyncio.run(main())
@@ -0,0 +1,15 @@
import asyncio
from e2b import AsyncTemplate, default_build_logger
from template import template
async def main():
await AsyncTemplate.build(
template,
"complex-python-app",
on_build_logs=default_build_logger(),
)
if __name__ == "__main__":
asyncio.run(main())
@@ -0,0 +1,20 @@
from e2b import AsyncTemplate
template = (
AsyncTemplate()
.from_image("python:3.11-slim")
.set_user("root")
.set_workdir("/")
.run_cmd("apt-get update && apt-get install -y gcc g++ make libpq-dev && rm -rf /var/lib/apt/lists/*")
.set_envs({
"PYTHONDONTWRITEBYTECODE": "1",
"PYTHONUNBUFFERED": "1",
})
.run_cmd("useradd -m -u 1000 appuser")
.set_workdir("/app")
.copy("requirements.txt", ".")
.run_cmd("pip install --upgrade pip && pip install -r requirements.txt")
.copy("app.py", ".")
.set_user("appuser")
.set_start_cmd("sudo gunicorn --bind 0.0.0.0:8000 app:application", "sleep 20")
)
@@ -0,0 +1,10 @@
from e2b import Template, default_build_logger
from template import template
if __name__ == "__main__":
Template.build(
template,
"complex-python-app-dev",
on_build_logs=default_build_logger(),
)
@@ -0,0 +1,10 @@
from e2b import Template, default_build_logger
from template import template
if __name__ == "__main__":
Template.build(
template,
"complex-python-app",
on_build_logs=default_build_logger(),
)
@@ -0,0 +1,20 @@
from e2b import Template
template = (
Template()
.from_image("python:3.11-slim")
.set_user("root")
.set_workdir("/")
.run_cmd("apt-get update && apt-get install -y gcc g++ make libpq-dev && rm -rf /var/lib/apt/lists/*")
.set_envs({
"PYTHONDONTWRITEBYTECODE": "1",
"PYTHONUNBUFFERED": "1",
})
.run_cmd("useradd -m -u 1000 appuser")
.set_workdir("/app")
.copy("requirements.txt", ".")
.run_cmd("pip install --upgrade pip && pip install -r requirements.txt")
.copy("app.py", ".")
.set_user("appuser")
.set_start_cmd("sudo gunicorn --bind 0.0.0.0:8000 app:application", "sleep 20")
)
@@ -0,0 +1,10 @@
import { Template, defaultBuildLogger } from 'e2b'
import { template } from './template'
async function main() {
await Template.build(template, 'complex-python-app-dev', {
onBuildLogs: defaultBuildLogger(),
});
}
main().catch(console.error);
@@ -0,0 +1,10 @@
import { Template, defaultBuildLogger } from 'e2b'
import { template } from './template'
async function main() {
await Template.build(template, 'complex-python-app', {
onBuildLogs: defaultBuildLogger(),
});
}
main().catch(console.error);
@@ -0,0 +1,18 @@
import { Template } from 'e2b'
export const template = Template()
.fromImage('python:3.11-slim')
.setUser('root')
.setWorkdir('/')
.runCmd('apt-get update && apt-get install -y gcc g++ make libpq-dev && rm -rf /var/lib/apt/lists/*')
.setEnvs({
'PYTHONDONTWRITEBYTECODE': '1',
'PYTHONUNBUFFERED': '1',
})
.runCmd('useradd -m -u 1000 appuser')
.setWorkdir('/app')
.copy('requirements.txt', '.')
.runCmd('pip install --upgrade pip && pip install -r requirements.txt')
.copy('app.py', '.')
.setUser('appuser')
.setStartCmd('sudo gunicorn --bind 0.0.0.0:8000 app:application', 'sleep 20')
@@ -0,0 +1,4 @@
FROM alpine:latest
COPY package.json /app/
COPY src/index.js ./src/
COPY config.json /etc/app/config.json
@@ -0,0 +1,2 @@
template_id = "copy-test"
dockerfile = "e2b.Dockerfile"
@@ -0,0 +1,15 @@
import asyncio
from e2b import AsyncTemplate, default_build_logger
from template import template
async def main():
await AsyncTemplate.build(
template,
"copy-test-dev",
on_build_logs=default_build_logger(),
)
if __name__ == "__main__":
asyncio.run(main())
@@ -0,0 +1,15 @@
import asyncio
from e2b import AsyncTemplate, default_build_logger
from template import template
async def main():
await AsyncTemplate.build(
template,
"copy-test",
on_build_logs=default_build_logger(),
)
if __name__ == "__main__":
asyncio.run(main())
@@ -0,0 +1,13 @@
from e2b import AsyncTemplate
template = (
AsyncTemplate()
.from_image("alpine:latest")
.set_user("root")
.set_workdir("/")
.copy("package.json", "/app/")
.copy("src/index.js", "./src/")
.copy("config.json", "/etc/app/config.json")
.set_user("user")
.set_workdir("/home/user")
)
@@ -0,0 +1,10 @@
from e2b import Template, default_build_logger
from template import template
if __name__ == "__main__":
Template.build(
template,
"copy-test-dev",
on_build_logs=default_build_logger(),
)
@@ -0,0 +1,10 @@
from e2b import Template, default_build_logger
from template import template
if __name__ == "__main__":
Template.build(
template,
"copy-test",
on_build_logs=default_build_logger(),
)
@@ -0,0 +1,13 @@
from e2b import Template
template = (
Template()
.from_image("alpine:latest")
.set_user("root")
.set_workdir("/")
.copy("package.json", "/app/")
.copy("src/index.js", "./src/")
.copy("config.json", "/etc/app/config.json")
.set_user("user")
.set_workdir("/home/user")
)
@@ -0,0 +1,10 @@
import { Template, defaultBuildLogger } from 'e2b'
import { template } from './template'
async function main() {
await Template.build(template, 'copy-test-dev', {
onBuildLogs: defaultBuildLogger(),
});
}
main().catch(console.error);
@@ -0,0 +1,10 @@
import { Template, defaultBuildLogger } from 'e2b'
import { template } from './template'
async function main() {
await Template.build(template, 'copy-test', {
onBuildLogs: defaultBuildLogger(),
});
}
main().catch(console.error);
@@ -0,0 +1,11 @@
import { Template } from 'e2b'
export const template = Template()
.fromImage('alpine:latest')
.setUser('root')
.setWorkdir('/')
.copy('package.json', '/app/')
.copy('src/index.js', './src/')
.copy('config.json', '/etc/app/config.json')
.setUser('user')
.setWorkdir('/home/user')

Some files were not shown because too many files have changed in this diff Show More