chore: import upstream snapshot with attribution
This commit is contained in:
@@ -0,0 +1,3 @@
|
||||
source_up
|
||||
|
||||
dotenv ../../../.env.local
|
||||
@@ -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
|
||||
@@ -0,0 +1,2 @@
|
||||
*.hbs
|
||||
tests/**/fixtures/**/*
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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`)
|
||||
})
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
@@ -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.')
|
||||
})
|
||||
@@ -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)}`
|
||||
)
|
||||
}
|
||||
@@ -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}`)}`
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
)
|
||||
@@ -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),
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
)
|
||||
@@ -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))
|
||||
@@ -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)
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export const defaultDockerfileName = 'e2b.Dockerfile'
|
||||
export const fallbackDockerfileName = 'Dockerfile'
|
||||
@@ -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()
|
||||
@@ -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}}
|
||||
)
|
||||
@@ -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}}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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,
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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'][]
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
import * as sdk from 'e2b'
|
||||
|
||||
export function sortTemplatesAliases<
|
||||
E extends sdk.components['schemas']['Template']['aliases'],
|
||||
>(aliases: E) {
|
||||
aliases?.sort()
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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('')
|
||||
}
|
||||
@@ -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"
|
||||
+15
@@ -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())
|
||||
+15
@@ -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())
|
||||
+20
@@ -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")
|
||||
)
|
||||
+10
@@ -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(),
|
||||
)
|
||||
+10
@@ -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(),
|
||||
)
|
||||
+20
@@ -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")
|
||||
)
|
||||
+10
@@ -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);
|
||||
+10
@@ -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);
|
||||
+18
@@ -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"
|
||||
+15
@@ -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())
|
||||
+15
@@ -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())
|
||||
+13
@@ -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")
|
||||
)
|
||||
+10
@@ -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(),
|
||||
)
|
||||
+10
@@ -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(),
|
||||
)
|
||||
+13
@@ -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")
|
||||
)
|
||||
+10
@@ -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);
|
||||
+10
@@ -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);
|
||||
+11
@@ -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
Reference in New Issue
Block a user