chore: import upstream snapshot with attribution
This commit is contained in:
@@ -0,0 +1,9 @@
|
||||
# Changesets
|
||||
|
||||
To add changeset run:
|
||||
|
||||
```bash
|
||||
npx changeset
|
||||
```
|
||||
|
||||
in the root of the project. This will create a new changeset in the `.changeset` folder.
|
||||
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"$schema": "https://unpkg.com/@changesets/config@2.3.1/schema.json",
|
||||
"changelog": "@changesets/cli/changelog",
|
||||
"commit": false,
|
||||
"fixed": [],
|
||||
"ignore": [],
|
||||
"linked": [],
|
||||
"access": "public",
|
||||
"baseBranch": "main",
|
||||
"updateInternalDependencies": "patch",
|
||||
"privatePackages": {
|
||||
"version": true,
|
||||
"tag": true
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"e2b": patch
|
||||
---
|
||||
|
||||
Move the Connect/Protobuf runtime dependencies off the `2.0.0-rc.3` pre-release pin to the stable line: `@connectrpc/connect` and `@connectrpc/connect-web` upgrade to `^2.1.2`, and `@bufbuild/protobuf` upgrades from `^2.6.2` to `^2.12.1`. No public API changes — the sandbox filesystem and command RPCs continue to use the same Connect transport configuration.
|
||||
@@ -0,0 +1,6 @@
|
||||
---
|
||||
"e2b": minor
|
||||
"@e2b/python-sdk": minor
|
||||
---
|
||||
|
||||
Replace the per-call `integration` connection option with a set-once, process-wide `ConnectionConfig.setIntegration()` (JS) / `ConnectionConfig.set_integration()` (Python). Integrations wrapping the SDK call it once at startup and every request is attributed via the `User-Agent` header — no more threading the option through individual SDK calls. The method is internal and hidden from docs. The `integration` option on `ConnectionConfigOpts` (JS) and the `integration` keyword argument on `ConnectionConfig` (Python) are removed; `ConnectionConfigOpts` remains as a deprecated alias of `ConnectionOpts`. In both SDKs, an explicitly provided `User-Agent` header now always takes precedence over the SDK-built one, while SDK-built values are recomputed whenever a config is rebuilt so they always reflect the current integration.
|
||||
@@ -0,0 +1,8 @@
|
||||
---
|
||||
"@e2b/cli": patch
|
||||
"e2b": patch
|
||||
---
|
||||
|
||||
Switch the build tooling from `tsup` to `tsdown`. The published artifacts are unchanged: the SDK still ships `dist/index.js` (CJS), `dist/index.mjs` (ESM) and `dist/index.d.ts`/`dist/index.d.mts`, and the CLI still ships an executable `dist/index.js` with its `dist/templates`.
|
||||
|
||||
`engines.node` for both packages is set to `>=20.18.1 <21 || >=22` (Node 20.18.1+, or 22 and above — keeping the minimum required by `undici` while excluding the end-of-life Node 21 line).
|
||||
@@ -0,0 +1,4 @@
|
||||
root = true
|
||||
|
||||
[*.py]
|
||||
indent_size = 4
|
||||
@@ -0,0 +1,4 @@
|
||||
packages/python-sdk/e2b/api/client/** linguist-generated=true
|
||||
packages/python-sdk/e2b/envd/filesystem/** linguist-generated=true
|
||||
packages/python-sdk/e2b/envd/process/** linguist-generated=true
|
||||
**/*.gen.ts linguist-generated=true
|
||||
@@ -0,0 +1,75 @@
|
||||
name: Bug report
|
||||
description: Create a report to help us improve
|
||||
title: "[Bug]: "
|
||||
labels: ["bug"]
|
||||
body:
|
||||
- type: input
|
||||
id: sandbox_id
|
||||
attributes:
|
||||
label: Sandbox ID or Build ID
|
||||
|
||||
- type: textarea
|
||||
id: environment
|
||||
attributes:
|
||||
label: Environment
|
||||
description: Environment details (used libraries version, OS, etc..)
|
||||
placeholder: e2b==1.2.3, macOS 14
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: input
|
||||
id: timestamp
|
||||
attributes:
|
||||
label: Timestamp of the issue
|
||||
description: When did the error occur? (include timezone please)
|
||||
placeholder: e.g. 2026-01-12 14:32 UTC
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: dropdown
|
||||
id: frequency
|
||||
attributes:
|
||||
label: Frequency
|
||||
options:
|
||||
- One-time occurrence
|
||||
- Happens occasionally
|
||||
- Happens every time
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: expected-behavior
|
||||
attributes:
|
||||
label: Expected behavior
|
||||
description: What did you expect to happen?
|
||||
placeholder: Describe the expected behavior...
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: actual-behavior
|
||||
attributes:
|
||||
label: Actual behavior
|
||||
description: What actually happened?
|
||||
placeholder: Describe what actually occurred...
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: reproduction
|
||||
attributes:
|
||||
label: Issue reproduction
|
||||
description: Steps to reproduce the issue
|
||||
placeholder: |
|
||||
1. Go to `...`
|
||||
2. Run `...`
|
||||
3. See error
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: additional-context
|
||||
attributes:
|
||||
label: Additional context
|
||||
description: Logs, screenshots, or any other relevant information
|
||||
placeholder: Add any additional context here...
|
||||
@@ -0,0 +1,8 @@
|
||||
blank_issues_enabled: false
|
||||
contact_links:
|
||||
- name: E2B Docs
|
||||
url: https://e2b.dev/docs
|
||||
about: Read the E2B documentation.
|
||||
- name: E2B Discord
|
||||
url: https://discord.gg/dSBY3ms2Qr
|
||||
about: Ask questions and get help from the E2B community.
|
||||
@@ -0,0 +1,19 @@
|
||||
---
|
||||
name: Feature request
|
||||
about: Suggest an idea for this project
|
||||
title: ''
|
||||
labels: 'feature'
|
||||
assignees: ''
|
||||
---
|
||||
|
||||
**Is your feature request related to a problem? Please describe.**
|
||||
A clear and concise description of what the problem is. Ex. I'm always frustrated when [...]
|
||||
|
||||
**Describe the solution you'd like**
|
||||
A clear and concise description of what you want to happen.
|
||||
|
||||
**Describe alternatives you've considered**
|
||||
A clear and concise description of any alternative solutions or features you've considered.
|
||||
|
||||
**Additional context**
|
||||
Add any other context or screenshots about the feature request here.
|
||||
@@ -0,0 +1,41 @@
|
||||
name: 'Build and install E2B CLI'
|
||||
description: >-
|
||||
Builds the e2b CLI from source in this repo and installs it globally so the
|
||||
`e2b` command is available on PATH. Assumes the repository has already been
|
||||
checked out.
|
||||
|
||||
runs:
|
||||
using: 'composite'
|
||||
steps:
|
||||
- name: Parse .tool-versions
|
||||
uses: wistia/parse-tool-versions@32f568a4ffd4bfa7720ebf93f171597d1ebc979a # v2.1.1
|
||||
with:
|
||||
filename: '.tool-versions'
|
||||
uppercase: 'true'
|
||||
prefix: 'tool_version_'
|
||||
|
||||
- name: Install pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
with:
|
||||
version: '${{ env.TOOL_VERSION_PNPM }}'
|
||||
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: '${{ env.TOOL_VERSION_NODEJS }}'
|
||||
cache: pnpm
|
||||
cache-dependency-path: pnpm-lock.yaml
|
||||
|
||||
- name: Install dependencies
|
||||
shell: bash
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: Build the CLI
|
||||
shell: bash
|
||||
run: pnpm build
|
||||
working-directory: ./packages/cli
|
||||
|
||||
- name: Install the CLI globally
|
||||
shell: bash
|
||||
run: npm install -g .
|
||||
working-directory: ./packages/cli
|
||||
Executable
+9
@@ -0,0 +1,9 @@
|
||||
#!/bin/sh
|
||||
|
||||
# This script checks if the current commit contains changesets.
|
||||
|
||||
set -eu
|
||||
|
||||
CHANGES=$(node -e "require('@changesets/read').default(process.cwd()).then(result => console.log(!!result.length))")
|
||||
|
||||
echo "${CHANGES}"
|
||||
Executable
+15
@@ -0,0 +1,15 @@
|
||||
#!/bin/sh
|
||||
|
||||
# This script checks if the specified package has changesets in the current commit.
|
||||
|
||||
set -eu
|
||||
|
||||
if [ $# -lt 1 ]; then
|
||||
echo "Error: Package name is required as the first argument." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
PACKAGE_NAME=$1
|
||||
PACKAGE_CHANGES=$(node -e "require('@changesets/read').default(process.cwd()).then(result => console.log(result.flatMap(changeset => changeset.releases.flatMap(release => release.name)).includes('${PACKAGE_NAME}')))")
|
||||
|
||||
echo "${PACKAGE_CHANGES}"
|
||||
@@ -0,0 +1,72 @@
|
||||
name: Test CLI
|
||||
|
||||
on:
|
||||
workflow_call:
|
||||
inputs:
|
||||
E2B_DOMAIN:
|
||||
required: false
|
||||
type: string
|
||||
default: ''
|
||||
secrets:
|
||||
E2B_API_KEY:
|
||||
required: true
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
test:
|
||||
defaults:
|
||||
run:
|
||||
working-directory: ./packages/cli
|
||||
shell: bash
|
||||
name: CLI - Build (${{ matrix.os }})
|
||||
strategy:
|
||||
matrix:
|
||||
os: [ubuntu-22.04, windows-latest]
|
||||
runs-on: ${{ matrix.os }}
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Parse .tool-versions
|
||||
uses: wistia/parse-tool-versions@v2.1.1
|
||||
with:
|
||||
filename: '.tool-versions'
|
||||
uppercase: 'true'
|
||||
prefix: 'tool_version_'
|
||||
|
||||
- name: Install pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
with:
|
||||
version: '${{ env.TOOL_VERSION_PNPM }}'
|
||||
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: '${{ env.TOOL_VERSION_NODEJS }}'
|
||||
registry-url: 'https://registry.npmjs.org'
|
||||
cache: pnpm
|
||||
cache-dependency-path: pnpm-lock.yaml
|
||||
|
||||
- name: Configure pnpm
|
||||
run: |
|
||||
pnpm config set auto-install-peers true
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: Build the SDK (pre-requisite for the tests)
|
||||
run: pnpm build
|
||||
working-directory: ./packages/js-sdk
|
||||
|
||||
- name: Build the CLI
|
||||
run: pnpm build
|
||||
working-directory: ./packages/cli
|
||||
|
||||
- name: Run tests
|
||||
run: pnpm test
|
||||
working-directory: ./packages/cli
|
||||
env:
|
||||
E2B_API_KEY: ${{ secrets.E2B_API_KEY }}
|
||||
E2B_DOMAIN: ${{ inputs.E2B_DOMAIN }}
|
||||
@@ -0,0 +1,99 @@
|
||||
name: Generated files
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
changes:
|
||||
name: Detect changes
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: read
|
||||
outputs:
|
||||
generated: ${{ steps.filter.outputs.generated }}
|
||||
steps:
|
||||
- name: Filter changed paths
|
||||
uses: dorny/paths-filter@v3
|
||||
id: filter
|
||||
with:
|
||||
# `**/!(*.md)` excludes Markdown so docs-only changes don't trigger codegen checks.
|
||||
filters: |
|
||||
generated:
|
||||
- 'spec/**/!(*.md)'
|
||||
- 'codegen.Dockerfile'
|
||||
- 'Makefile'
|
||||
- 'packages/**/!(*.md)'
|
||||
- 'pnpm-lock.yaml'
|
||||
- 'pnpm-workspace.yaml'
|
||||
- 'package.json'
|
||||
- '.tool-versions'
|
||||
- '.github/workflows/generated_files.yml'
|
||||
|
||||
check-generated:
|
||||
name: Generated files
|
||||
needs: changes
|
||||
if: ${{ needs.changes.outputs.generated == 'true' }}
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Parse .tool-versions
|
||||
uses: wistia/parse-tool-versions@v2.1.1
|
||||
with:
|
||||
filename: '.tool-versions'
|
||||
uppercase: 'true'
|
||||
prefix: 'tool_version_'
|
||||
|
||||
- name: Install pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
id: pnpm-install
|
||||
with:
|
||||
version: '${{ env.TOOL_VERSION_PNPM }}'
|
||||
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: '${{ env.TOOL_VERSION_NODEJS }}'
|
||||
registry-url: 'https://registry.npmjs.org'
|
||||
cache: pnpm
|
||||
cache-dependency-path: pnpm-lock.yaml
|
||||
|
||||
- name: Configure pnpm
|
||||
run: |
|
||||
pnpm config set auto-install-peers true
|
||||
pnpm config set exclude-links-from-lockfile true
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Build codegen image with caching
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: .
|
||||
file: codegen.Dockerfile
|
||||
tags: codegen-env:latest
|
||||
load: true # makes the image available for `docker run`
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
|
||||
- name: Run codegen
|
||||
run: CODEGEN_IMAGE=codegen-env:latest make codegen
|
||||
|
||||
- name: Check for uncommitted changes
|
||||
run: |
|
||||
if [[ -n $(git status --porcelain) ]]; then
|
||||
echo "❌ Generated files are not up to date:"
|
||||
git status --short
|
||||
git diff
|
||||
exit 1
|
||||
else
|
||||
echo "✅ No changes detected."
|
||||
fi
|
||||
@@ -0,0 +1,100 @@
|
||||
name: Test JS SDK
|
||||
|
||||
on:
|
||||
workflow_call:
|
||||
inputs:
|
||||
E2B_DOMAIN:
|
||||
required: false
|
||||
type: string
|
||||
default: ''
|
||||
secrets:
|
||||
E2B_API_KEY:
|
||||
required: true
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
test:
|
||||
defaults:
|
||||
run:
|
||||
working-directory: ./packages/js-sdk
|
||||
shell: bash
|
||||
name: JS SDK - Build and test (${{ matrix.os }})
|
||||
strategy:
|
||||
matrix:
|
||||
os: [ubuntu-22.04, windows-latest]
|
||||
fail-fast: false
|
||||
runs-on: ${{ matrix.os }}
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Parse .tool-versions
|
||||
uses: wistia/parse-tool-versions@v2.1.1
|
||||
with:
|
||||
filename: '.tool-versions'
|
||||
uppercase: 'true'
|
||||
prefix: 'tool_version_'
|
||||
|
||||
- name: Install pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
id: pnpm-install
|
||||
with:
|
||||
version: '${{ env.TOOL_VERSION_PNPM }}'
|
||||
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: '${{ env.TOOL_VERSION_NODEJS }}'
|
||||
registry-url: 'https://registry.npmjs.org'
|
||||
cache: pnpm
|
||||
cache-dependency-path: pnpm-lock.yaml
|
||||
|
||||
- name: Configure pnpm
|
||||
run: |
|
||||
pnpm config set auto-install-peers true
|
||||
pnpm config set exclude-links-from-lockfile true
|
||||
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
pnpm install --frozen-lockfile
|
||||
|
||||
- name: Get Playwright version
|
||||
id: playwright-version
|
||||
run: echo "version=$(node -p "require('playwright/package.json').version")" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Cache Playwright browsers
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: ${{ matrix.os == 'windows-latest' && '~/AppData/Local/ms-playwright' || '~/.cache/ms-playwright' }}
|
||||
key: playwright-${{ runner.os }}-${{ steps.playwright-version.outputs.version }}
|
||||
|
||||
- name: Test build
|
||||
run: pnpm build
|
||||
|
||||
- name: Run Node tests
|
||||
run: pnpm test
|
||||
env:
|
||||
E2B_API_KEY: ${{ secrets.E2B_API_KEY }}
|
||||
E2B_DOMAIN: ${{ inputs.E2B_DOMAIN }}
|
||||
|
||||
- name: Install Bun
|
||||
uses: oven-sh/setup-bun@v2
|
||||
|
||||
- name: Run Bun tests
|
||||
run: pnpm test:bun
|
||||
env:
|
||||
E2B_API_KEY: ${{ secrets.E2B_API_KEY }}
|
||||
E2B_DOMAIN: ${{ inputs.E2B_DOMAIN }}
|
||||
|
||||
- name: Install Deno
|
||||
uses: denoland/setup-deno@v1
|
||||
with:
|
||||
deno-version: v${{ env.TOOL_VERSION_DENO }}
|
||||
|
||||
- name: Run Deno tests
|
||||
run: pnpm test:deno
|
||||
env:
|
||||
E2B_API_KEY: ${{ secrets.E2B_API_KEY }}
|
||||
E2B_DOMAIN: ${{ inputs.E2B_DOMAIN }}
|
||||
@@ -0,0 +1,101 @@
|
||||
name: Lint
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
|
||||
jobs:
|
||||
changes:
|
||||
name: Detect changes
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: read
|
||||
outputs:
|
||||
code: ${{ steps.filter.outputs.code }}
|
||||
steps:
|
||||
- name: Filter changed paths
|
||||
uses: dorny/paths-filter@v3
|
||||
id: filter
|
||||
with:
|
||||
# `**/!(*.md)` excludes Markdown so docs-only changes don't trigger linting.
|
||||
filters: |
|
||||
code:
|
||||
- 'packages/**/!(*.md)'
|
||||
- 'spec/**/!(*.md)'
|
||||
- '.oxlintrc.json'
|
||||
- '.prettierrc'
|
||||
- '.prettierignore'
|
||||
- '.editorconfig'
|
||||
- 'pnpm-lock.yaml'
|
||||
- 'pnpm-workspace.yaml'
|
||||
- 'package.json'
|
||||
- '.tool-versions'
|
||||
- '.github/workflows/lint.yml'
|
||||
|
||||
lint:
|
||||
name: Lint
|
||||
needs: changes
|
||||
if: ${{ needs.changes.outputs.code == 'true' }}
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout Repo
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Parse .tool-versions
|
||||
uses: wistia/parse-tool-versions@v2.1.1
|
||||
with:
|
||||
filename: '.tool-versions'
|
||||
uppercase: 'true'
|
||||
prefix: 'tool_version_'
|
||||
|
||||
- uses: pnpm/action-setup@v4
|
||||
with:
|
||||
version: '${{ env.TOOL_VERSION_PNPM }}'
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: '${{ env.TOOL_VERSION_NODEJS }}'
|
||||
cache: pnpm
|
||||
|
||||
- name: Configure pnpm
|
||||
run: |
|
||||
pnpm config set auto-install-peers true
|
||||
pnpm config set exclude-links-from-lockfile true
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: Install uv
|
||||
uses: astral-sh/setup-uv@v6
|
||||
with:
|
||||
version: '${{ env.TOOL_VERSION_UV }}'
|
||||
python-version: '${{ env.TOOL_VERSION_PYTHON }}'
|
||||
enable-cache: true
|
||||
|
||||
- name: Install Python dependencies
|
||||
working-directory: packages/python-sdk
|
||||
run: |
|
||||
uv sync --locked
|
||||
|
||||
- name: Run linting
|
||||
run: |
|
||||
pnpm run lint
|
||||
|
||||
- name: Run formatting
|
||||
run: |
|
||||
pnpm run format
|
||||
|
||||
- name: Check for uncommitted changes
|
||||
run: |
|
||||
if [[ -n $(git status --porcelain) ]]; then
|
||||
echo "❌ Files are not formatted properly:"
|
||||
git status --short
|
||||
git diff
|
||||
exit 1
|
||||
else
|
||||
echo "✅ No changes detected."
|
||||
fi
|
||||
@@ -0,0 +1,142 @@
|
||||
name: Package Artifacts
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
types: [opened, synchronize, reopened]
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: write
|
||||
|
||||
jobs:
|
||||
build:
|
||||
name: Build Packages
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Parse .tool-versions
|
||||
uses: wistia/parse-tool-versions@v2.1.1
|
||||
with:
|
||||
filename: '.tool-versions'
|
||||
uppercase: 'true'
|
||||
prefix: 'tool_version_'
|
||||
|
||||
- uses: pnpm/action-setup@v4
|
||||
with:
|
||||
version: '${{ env.TOOL_VERSION_PNPM }}'
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: '${{ env.TOOL_VERSION_NODEJS }}'
|
||||
cache: pnpm
|
||||
|
||||
- name: Configure pnpm
|
||||
run: |
|
||||
pnpm config set auto-install-peers true
|
||||
pnpm config set exclude-links-from-lockfile true
|
||||
|
||||
- name: Sanitize branch name
|
||||
env:
|
||||
BRANCH: ${{ github.head_ref }}
|
||||
run: |
|
||||
echo "BRANCH_ID=$(echo "$BRANCH" | sed 's/[^0-9A-Za-z-]/-/g')" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: Build JS SDK
|
||||
working-directory: packages/js-sdk
|
||||
run: pnpm run build
|
||||
|
||||
- name: Pack JS SDK
|
||||
working-directory: packages/js-sdk
|
||||
run: |
|
||||
npm version prerelease --preid=${{ env.BRANCH_ID }} --no-git-tag-version
|
||||
npm pack
|
||||
|
||||
- name: Upload JS SDK artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: e2b-js-sdk
|
||||
path: packages/js-sdk/*.tgz
|
||||
|
||||
- name: Build CLI
|
||||
working-directory: packages/cli
|
||||
run: pnpm run build
|
||||
|
||||
- name: Pack CLI
|
||||
working-directory: packages/cli
|
||||
run: |
|
||||
npm version prerelease --preid=${{ env.BRANCH_ID }} --no-git-tag-version
|
||||
npm pack
|
||||
|
||||
- name: Upload CLI artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: e2b-cli
|
||||
path: packages/cli/*.tgz
|
||||
|
||||
- name: Install uv
|
||||
uses: astral-sh/setup-uv@v6
|
||||
with:
|
||||
version: '${{ env.TOOL_VERSION_UV }}'
|
||||
python-version: '${{ env.TOOL_VERSION_PYTHON }}'
|
||||
enable-cache: true
|
||||
|
||||
- name: Build Python SDK
|
||||
working-directory: packages/python-sdk
|
||||
run: |
|
||||
BASE_VERSION=$(uv version --short)
|
||||
uv version "${BASE_VERSION}+${BRANCH_ID}"
|
||||
uv build
|
||||
|
||||
- name: Upload Python SDK artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: e2b-python-sdk
|
||||
path: packages/python-sdk/dist/*
|
||||
|
||||
- name: Comment on PR
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
PR_NUMBER: ${{ github.event.pull_request.number }}
|
||||
RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
|
||||
run: |
|
||||
JS_VERSION=$(node -p "require('./packages/js-sdk/package.json').version")
|
||||
CLI_VERSION=$(node -p "require('./packages/cli/package.json').version")
|
||||
JS_TGZ=$(ls packages/js-sdk/*.tgz | xargs -n1 basename)
|
||||
CLI_TGZ=$(ls packages/cli/*.tgz | xargs -n1 basename)
|
||||
PY_VERSION=$(grep '^version' packages/python-sdk/pyproject.toml | head -1 | sed 's/.*"\(.*\)"/\1/')
|
||||
PY_WHL=$(ls packages/python-sdk/dist/*.whl | xargs -n1 basename)
|
||||
|
||||
BODY="<!-- e2b-pkg-artifacts -->"$'\n'
|
||||
BODY+="### Package Artifacts"$'\n\n'
|
||||
BODY+="Built from ${GITHUB_SHA::7}. Download artifacts from [this workflow run](${RUN_URL})."$'\n\n'
|
||||
BODY+="**JS SDK** (\`e2b@${JS_VERSION}\`):"$'\n'
|
||||
BODY+='```sh'$'\n'
|
||||
BODY+="npm install ./${JS_TGZ}"$'\n'
|
||||
BODY+='```'$'\n\n'
|
||||
BODY+="**CLI** (\`@e2b/cli@${CLI_VERSION}\`):"$'\n'
|
||||
BODY+='```sh'$'\n'
|
||||
BODY+="npm install ./${CLI_TGZ}"$'\n'
|
||||
BODY+='```'$'\n\n'
|
||||
BODY+="**Python SDK** (\`e2b==${PY_VERSION}\`):"$'\n'
|
||||
BODY+='```sh'$'\n'
|
||||
BODY+="pip install ./${PY_WHL}"$'\n'
|
||||
BODY+='```'$'\n'
|
||||
|
||||
COMMENT_ID=$(gh api "repos/${{ github.repository }}/issues/${PR_NUMBER}/comments" \
|
||||
--paginate \
|
||||
--jq '.[] | select(.body | contains("<!-- e2b-pkg-artifacts -->")) | .id' \
|
||||
| tail -1)
|
||||
|
||||
if [ -n "$COMMENT_ID" ]; then
|
||||
gh api "repos/${{ github.repository }}/issues/comments/${COMMENT_ID}" \
|
||||
-X PATCH -f body="$BODY"
|
||||
else
|
||||
gh pr comment "$PR_NUMBER" --body "$BODY"
|
||||
fi
|
||||
@@ -0,0 +1,114 @@
|
||||
name: Publish Candidates
|
||||
|
||||
on:
|
||||
workflow_call:
|
||||
inputs:
|
||||
js-sdk:
|
||||
required: false
|
||||
type: boolean
|
||||
default: false
|
||||
python-sdk:
|
||||
required: false
|
||||
type: boolean
|
||||
default: false
|
||||
cli:
|
||||
required: false
|
||||
type: boolean
|
||||
default: false
|
||||
tag:
|
||||
required: true
|
||||
type: string
|
||||
preid:
|
||||
required: true
|
||||
type: string
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
id-token: write
|
||||
|
||||
jobs:
|
||||
publish:
|
||||
name: Publish Release Candidates
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout Repo
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Parse .tool-versions
|
||||
uses: wistia/parse-tool-versions@v2.1.1
|
||||
with:
|
||||
filename: '.tool-versions'
|
||||
uppercase: 'true'
|
||||
prefix: 'tool_version_'
|
||||
|
||||
- uses: pnpm/action-setup@v4
|
||||
if: ${{ inputs.js-sdk || inputs.cli }}
|
||||
with:
|
||||
version: '${{ env.TOOL_VERSION_PNPM }}'
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v6
|
||||
if: ${{ inputs.js-sdk || inputs.cli }}
|
||||
with:
|
||||
node-version: '${{ env.TOOL_VERSION_NODEJS }}'
|
||||
registry-url: https://registry.npmjs.org
|
||||
cache: pnpm
|
||||
|
||||
- name: Configure pnpm
|
||||
if: ${{ inputs.js-sdk || inputs.cli }}
|
||||
run: |
|
||||
pnpm config set auto-install-peers true
|
||||
pnpm config set exclude-links-from-lockfile true
|
||||
|
||||
- name: Update npm
|
||||
if: ${{ inputs.js-sdk || inputs.cli }}
|
||||
run: |
|
||||
npm install -g npm@^11.6
|
||||
npm --version
|
||||
|
||||
- name: Install uv
|
||||
uses: astral-sh/setup-uv@v6
|
||||
if: ${{ inputs.python-sdk }}
|
||||
with:
|
||||
version: '${{ env.TOOL_VERSION_UV }}'
|
||||
python-version: '${{ env.TOOL_VERSION_PYTHON }}'
|
||||
enable-cache: true
|
||||
|
||||
- name: Publish Python RC
|
||||
if: ${{ inputs.python-sdk }}
|
||||
working-directory: packages/python-sdk
|
||||
run: |
|
||||
BASE_VERSION=$(uv version --short)
|
||||
uv version "${BASE_VERSION}rc${{ github.run_id }}"
|
||||
uv build
|
||||
uv publish --token ${PYPI_TOKEN} --check-url https://pypi.org/simple/
|
||||
env:
|
||||
PYPI_TOKEN: ${{ secrets.PYPI_TOKEN }}
|
||||
|
||||
- name: Install JS dependencies
|
||||
if: ${{ inputs.js-sdk || inputs.cli }}
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: Publish JS RC
|
||||
if: ${{ inputs.js-sdk }}
|
||||
working-directory: packages/js-sdk
|
||||
env:
|
||||
RC_PREID: ${{ inputs.preid }}
|
||||
RC_TAG: ${{ inputs.tag }}
|
||||
run: |
|
||||
npm version prerelease --preid="${RC_PREID}.${{ github.run_id }}" --no-git-tag-version
|
||||
npm publish --tag "$RC_TAG" --provenance
|
||||
|
||||
- name: Reinstall dependencies
|
||||
if: ${{ inputs.js-sdk || inputs.cli }}
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: Publish CLI RC
|
||||
if: ${{ inputs.cli }}
|
||||
working-directory: packages/cli
|
||||
env:
|
||||
RC_PREID: ${{ inputs.preid }}
|
||||
RC_TAG: ${{ inputs.tag }}
|
||||
run: |
|
||||
npm version prerelease --preid="${RC_PREID}.${{ github.run_id }}" --no-git-tag-version
|
||||
npm publish --tag "$RC_TAG" --provenance
|
||||
@@ -0,0 +1,111 @@
|
||||
name: Publish Packages
|
||||
|
||||
on:
|
||||
workflow_call:
|
||||
secrets:
|
||||
E2B_API_KEY:
|
||||
required: true
|
||||
PYPI_TOKEN:
|
||||
required: true
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
id-token: write
|
||||
|
||||
jobs:
|
||||
test:
|
||||
name: Build and test SDK
|
||||
runs-on: ubuntu-22.04
|
||||
steps:
|
||||
- uses: actions/create-github-app-token@v1
|
||||
id: app-token
|
||||
with:
|
||||
app-id: ${{ vars.VERSION_BUMPER_APPID }}
|
||||
private-key: ${{ secrets.VERSION_BUMPER_SECRET }}
|
||||
|
||||
- name: Checkout Repo
|
||||
uses: actions/checkout@v3
|
||||
with:
|
||||
token: ${{ steps.app-token.outputs.token }}
|
||||
|
||||
- name: Parse .tool-versions
|
||||
uses: wistia/parse-tool-versions@v2.1.1
|
||||
with:
|
||||
filename: '.tool-versions'
|
||||
uppercase: 'true'
|
||||
prefix: 'tool_version_'
|
||||
|
||||
- name: Install uv
|
||||
uses: astral-sh/setup-uv@v6
|
||||
with:
|
||||
version: '${{ env.TOOL_VERSION_UV }}'
|
||||
python-version: '${{ env.TOOL_VERSION_PYTHON }}'
|
||||
enable-cache: true
|
||||
|
||||
- uses: pnpm/action-setup@v4
|
||||
with:
|
||||
version: '${{ env.TOOL_VERSION_PNPM }}'
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: '${{ env.TOOL_VERSION_NODEJS }}'
|
||||
registry-url: 'https://registry.npmjs.org'
|
||||
cache: pnpm
|
||||
|
||||
- name: Configure pnpm
|
||||
run: |
|
||||
pnpm config set auto-install-peers true
|
||||
pnpm config set exclude-links-from-lockfile true
|
||||
|
||||
- name: Update npm
|
||||
run: |
|
||||
npm install -g npm@^11.6
|
||||
npm --version
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: Create new versions
|
||||
run: pnpm run version
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Release new versions
|
||||
uses: changesets/action@v1
|
||||
with:
|
||||
publish: pnpm run publish
|
||||
createGithubReleases: true
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
NPM_TOKEN: "" # See https://github.com/changesets/changesets/issues/1152#issuecomment-3190884868
|
||||
PYPI_TOKEN: ${{ secrets.PYPI_TOKEN }}
|
||||
|
||||
- name: Update lock file
|
||||
run: pnpm i --no-link --no-frozen-lockfile
|
||||
|
||||
- name: Commit new versions
|
||||
run: |
|
||||
git config user.name "github-actions[bot]"
|
||||
git config user.email "github-actions[bot]@users.noreply.github.com"
|
||||
git commit -am "[skip ci] Release new versions" || exit 0
|
||||
|
||||
# A PR merging mid-release makes this push a non-fast-forward. The
|
||||
# artifacts were already published from the checked-out tree, so we
|
||||
# only rebase the version bump over incoming changes that don't touch
|
||||
# packages/. Otherwise the source would claim the published version
|
||||
# contains code that isn't in the artifacts, so we fail loudly.
|
||||
if git push; then
|
||||
exit 0
|
||||
fi
|
||||
|
||||
git fetch origin "${GITHUB_REF_NAME}"
|
||||
if git diff --name-only "HEAD~1" FETCH_HEAD -- packages/ | grep -q .; then
|
||||
echo "::error::'${GITHUB_REF_NAME}' advanced with changes under packages/ during the release. Refusing to rebase the version bump onto unpublished package code (the source would diverge from the published artifacts). Reconcile manually."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
git pull --rebase origin "${GITHUB_REF_NAME}"
|
||||
git push
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
@@ -0,0 +1,57 @@
|
||||
name: Test Python SDK
|
||||
|
||||
on:
|
||||
workflow_call:
|
||||
inputs:
|
||||
E2B_DOMAIN:
|
||||
required: false
|
||||
type: string
|
||||
default: ''
|
||||
secrets:
|
||||
E2B_API_KEY:
|
||||
required: true
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
test:
|
||||
defaults:
|
||||
run:
|
||||
working-directory: ./packages/python-sdk
|
||||
shell: bash
|
||||
name: Python SDK - Build and test (${{ matrix.os }})
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
os: [ubuntu-22.04, windows-latest]
|
||||
runs-on: ${{ matrix.os }}
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Parse .tool-versions
|
||||
uses: wistia/parse-tool-versions@v2.1.1
|
||||
with:
|
||||
filename: '.tool-versions'
|
||||
uppercase: 'true'
|
||||
prefix: 'tool_version_'
|
||||
|
||||
- name: Install uv
|
||||
uses: astral-sh/setup-uv@v6
|
||||
with:
|
||||
version: '${{ env.TOOL_VERSION_UV }}'
|
||||
python-version: '${{ env.TOOL_VERSION_PYTHON }}'
|
||||
enable-cache: true
|
||||
|
||||
- name: Install dependencies
|
||||
run: uv sync --locked
|
||||
|
||||
- name: Test build
|
||||
run: uv build
|
||||
|
||||
- name: Run tests
|
||||
run: uv run pytest --verbose --numprocesses=4
|
||||
env:
|
||||
E2B_API_KEY: ${{ secrets.E2B_API_KEY }}
|
||||
E2B_DOMAIN: ${{ inputs.E2B_DOMAIN }}
|
||||
@@ -0,0 +1,106 @@
|
||||
name: Release candidate
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
js-sdk:
|
||||
description: 'Release JS SDK'
|
||||
required: false
|
||||
default: false
|
||||
type: boolean
|
||||
python-sdk:
|
||||
description: 'Release Python SDK'
|
||||
required: false
|
||||
default: false
|
||||
type: boolean
|
||||
cli:
|
||||
description: 'Release CLI'
|
||||
required: false
|
||||
default: false
|
||||
type: boolean
|
||||
tag:
|
||||
description: 'Dist-tag (e.g. rc, beta, snapshot)'
|
||||
required: false
|
||||
default: 'rc'
|
||||
type: string
|
||||
preid:
|
||||
description: 'Prerelease identifier (defaults to branch name)'
|
||||
required: false
|
||||
default: ''
|
||||
type: string
|
||||
skip-tests:
|
||||
description: 'Skip tests'
|
||||
required: false
|
||||
default: false
|
||||
type: boolean
|
||||
|
||||
# Shared literal group so production and candidate releases on the same ref serialize.
|
||||
concurrency: release-${{ github.ref }}
|
||||
|
||||
permissions:
|
||||
id-token: write
|
||||
contents: write
|
||||
|
||||
jobs:
|
||||
rc-validate:
|
||||
name: Validate RC inputs
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
preid: ${{ steps.preid.outputs.preid }}
|
||||
tag: ${{ steps.tag.outputs.tag }}
|
||||
steps:
|
||||
- name: Sanitize tag
|
||||
id: tag
|
||||
env:
|
||||
RAW_TAG: ${{ github.event.inputs.tag }}
|
||||
run: |
|
||||
SAFE_TAG="$(echo "$RAW_TAG" | sed 's/[^0-9A-Za-z-]/-/g')"
|
||||
echo "tag=$SAFE_TAG" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Block production tags
|
||||
run: |
|
||||
if [ "${{ steps.tag.outputs.tag }}" = "latest" ]; then
|
||||
echo "::error::Publishing with the 'latest' tag is not allowed for candidates. Use the 'Release' workflow instead."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Sanitize preid
|
||||
id: preid
|
||||
env:
|
||||
RAW_PREID: ${{ github.event.inputs.preid || github.ref_name }}
|
||||
run: |
|
||||
echo "preid=$(echo "$RAW_PREID" | sed 's/[^0-9A-Za-z-]/-/g')" >> "$GITHUB_OUTPUT"
|
||||
|
||||
rc-python-tests:
|
||||
name: RC Python Tests
|
||||
needs: [rc-validate]
|
||||
if: github.event.inputs.python-sdk == 'true' && github.event.inputs.skip-tests != 'true'
|
||||
uses: ./.github/workflows/python_sdk_tests.yml
|
||||
secrets: inherit
|
||||
|
||||
rc-js-tests:
|
||||
name: RC JS Tests
|
||||
needs: [rc-validate]
|
||||
if: github.event.inputs.js-sdk == 'true' && github.event.inputs.skip-tests != 'true'
|
||||
uses: ./.github/workflows/js_sdk_tests.yml
|
||||
secrets: inherit
|
||||
|
||||
rc-cli-tests:
|
||||
name: RC CLI Tests
|
||||
needs: [rc-validate]
|
||||
if: github.event.inputs.cli == 'true' && github.event.inputs.skip-tests != 'true'
|
||||
uses: ./.github/workflows/cli_tests.yml
|
||||
secrets: inherit
|
||||
|
||||
rc-publish:
|
||||
name: Publish RC
|
||||
needs: [rc-validate, rc-python-tests, rc-js-tests, rc-cli-tests]
|
||||
if: (!cancelled()) && !contains(needs.*.result, 'failure') && needs.rc-validate.result == 'success'
|
||||
uses: ./.github/workflows/publish_candidates.yml
|
||||
with:
|
||||
js-sdk: ${{ github.event.inputs.js-sdk == 'true' }}
|
||||
python-sdk: ${{ github.event.inputs.python-sdk == 'true' }}
|
||||
cli: ${{ github.event.inputs.cli == 'true' }}
|
||||
tag: ${{ needs.rc-validate.outputs.tag }}
|
||||
preid: ${{ needs.rc-validate.outputs.preid }}
|
||||
secrets: inherit
|
||||
@@ -0,0 +1,182 @@
|
||||
name: Release
|
||||
|
||||
on:
|
||||
workflow_dispatch: {}
|
||||
|
||||
# Shared literal group so production and candidate releases on the same ref serialize.
|
||||
concurrency: release-${{ github.ref }}
|
||||
|
||||
permissions:
|
||||
id-token: write
|
||||
contents: write
|
||||
|
||||
jobs:
|
||||
preflight:
|
||||
name: Release preflight
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
release: ${{ steps.version.outputs.release }}
|
||||
js-sdk: ${{ steps.js.outputs.release }}
|
||||
python-sdk: ${{ steps.python.outputs.release }}
|
||||
cli: ${{ steps.cli.outputs.release }}
|
||||
itinerary: ${{ steps.itinerary.outputs.itinerary }}
|
||||
steps:
|
||||
- name: Checkout Repo
|
||||
uses: actions/checkout@v3
|
||||
|
||||
- name: Parse .tool-versions
|
||||
uses: wistia/parse-tool-versions@v2.1.1
|
||||
with:
|
||||
filename: '.tool-versions'
|
||||
uppercase: 'true'
|
||||
prefix: 'tool_version_'
|
||||
|
||||
- name: Install pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
id: pnpm-install
|
||||
with:
|
||||
version: '${{ env.TOOL_VERSION_PNPM }}'
|
||||
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: '${{ env.TOOL_VERSION_NODEJS }}'
|
||||
registry-url: 'https://registry.npmjs.org'
|
||||
cache: pnpm
|
||||
cache-dependency-path: pnpm-lock.yaml
|
||||
|
||||
- name: Configure pnpm
|
||||
run: |
|
||||
pnpm config set auto-install-peers true
|
||||
pnpm config set exclude-links-from-lockfile true
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: Check if new version
|
||||
id: version
|
||||
run: |
|
||||
IS_RELEASE=$(./.github/scripts/is_release.sh)
|
||||
echo "release=$IS_RELEASE" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Check JavaScript SDK Release
|
||||
id: js
|
||||
if: steps.version.outputs.release == 'true'
|
||||
run: |
|
||||
IS_RELEASE=$(./.github/scripts/is_release_for_package.sh "e2b")
|
||||
echo "release=$IS_RELEASE" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Check Python SDK Release
|
||||
id: python
|
||||
if: steps.version.outputs.release == 'true'
|
||||
run: |
|
||||
IS_RELEASE=$(./.github/scripts/is_release_for_package.sh "@e2b/python-sdk")
|
||||
echo "release=$IS_RELEASE" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Check CLI Release
|
||||
id: cli
|
||||
if: steps.version.outputs.release == 'true'
|
||||
run: |
|
||||
IS_RELEASE=$(./.github/scripts/is_release_for_package.sh "@e2b/cli")
|
||||
echo "release=$IS_RELEASE" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Build release itinerary
|
||||
id: itinerary
|
||||
if: steps.version.outputs.release == 'true'
|
||||
run: |
|
||||
pnpm changeset status --output=.cs-status.json
|
||||
ITINERARY=$(node -e '
|
||||
const data = require("./.cs-status.json");
|
||||
const labels = { "e2b": "JS SDK (e2b)", "@e2b/python-sdk": "Python SDK (e2b)", "@e2b/cli": "CLI (@e2b/cli)" };
|
||||
const order = ["e2b", "@e2b/python-sdk", "@e2b/cli"];
|
||||
const byName = Object.fromEntries(data.releases.map(r => [r.name, r]));
|
||||
const lines = order.filter(n => byName[n]).map(n => `• ${labels[n]} v${byName[n].newVersion}`);
|
||||
process.stdout.write(lines.join("\n") || "• No packages were published");
|
||||
')
|
||||
rm -f .cs-status.json
|
||||
{
|
||||
echo "itinerary<<EOF"
|
||||
echo "$ITINERARY"
|
||||
echo "EOF"
|
||||
} >> "$GITHUB_OUTPUT"
|
||||
|
||||
python-tests:
|
||||
name: Python SDK Tests
|
||||
needs: [preflight]
|
||||
if: needs.preflight.outputs.python-sdk == 'true'
|
||||
uses: ./.github/workflows/python_sdk_tests.yml
|
||||
secrets: inherit
|
||||
|
||||
js-tests:
|
||||
name: JS SDK Tests
|
||||
needs: [preflight]
|
||||
if: needs.preflight.outputs.js-sdk == 'true'
|
||||
uses: ./.github/workflows/js_sdk_tests.yml
|
||||
secrets: inherit
|
||||
|
||||
cli-tests:
|
||||
name: CLI Tests
|
||||
needs: [preflight]
|
||||
if: needs.preflight.outputs.cli == 'true'
|
||||
uses: ./.github/workflows/cli_tests.yml
|
||||
secrets: inherit
|
||||
|
||||
publish:
|
||||
name: Publish
|
||||
needs: [preflight, python-tests, js-tests, cli-tests]
|
||||
if: (!cancelled()) && !contains(needs.*.result, 'failure') && needs.preflight.outputs.release == 'true'
|
||||
uses: ./.github/workflows/publish_packages.yml
|
||||
secrets: inherit
|
||||
|
||||
report-start:
|
||||
needs: [preflight]
|
||||
if: needs.preflight.outputs.release == 'true'
|
||||
name: Release Started - Slack Notification
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Release Started - Slack Notification
|
||||
uses: rtCamp/action-slack-notify@33ca3be66c6f378fe1610fd1d5258632dbed5e58 # v2.4.0
|
||||
env:
|
||||
SLACK_COLOR: '#3aa3e3'
|
||||
SLACK_MESSAGE: |
|
||||
:rocket: A new release has been triggered :hourglass_flowing_sand:
|
||||
|
||||
*Releasing:*
|
||||
${{ needs.preflight.outputs.itinerary }}
|
||||
SLACK_TITLE: Release Started
|
||||
SLACK_WEBHOOK: ${{ secrets.SLACK_WEBHOOK }}
|
||||
SLACK_CHANNEL: 'monitoring-releases'
|
||||
|
||||
report-failure:
|
||||
needs: [python-tests, js-tests, cli-tests, publish]
|
||||
if: failure()
|
||||
name: Release Failed - Slack Notification
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Release Failed - Slack Notification
|
||||
uses: rtCamp/action-slack-notify@33ca3be66c6f378fe1610fd1d5258632dbed5e58 # v2.4.0
|
||||
env:
|
||||
SLACK_COLOR: '#ff0000'
|
||||
SLACK_MESSAGE: ':here-we-go-again: :bob-the-destroyer: We need :fix-parrot: ASAP :pray:'
|
||||
SLACK_TITLE: Release Failed
|
||||
SLACK_WEBHOOK: ${{ secrets.SLACK_WEBHOOK }}
|
||||
SLACK_CHANNEL: 'monitoring-releases'
|
||||
|
||||
report-success:
|
||||
needs: [preflight, publish]
|
||||
if: needs.publish.result == 'success'
|
||||
name: Release Succeeded - Slack Notification
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Release Succeeded - Slack Notification
|
||||
uses: rtCamp/action-slack-notify@33ca3be66c6f378fe1610fd1d5258632dbed5e58 # v2.4.0
|
||||
env:
|
||||
SLACK_COLOR: '#36a64f'
|
||||
SLACK_MESSAGE: |
|
||||
:tada: A new version has been released successfully! :ship-it-parrot:
|
||||
|
||||
*Released:*
|
||||
${{ needs.preflight.outputs.itinerary }}
|
||||
SLACK_TITLE: Release Succeeded
|
||||
SLACK_WEBHOOK: ${{ secrets.SLACK_WEBHOOK }}
|
||||
SLACK_CHANNEL: 'monitoring-releases'
|
||||
@@ -0,0 +1,133 @@
|
||||
name: SDK Tests
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
branches:
|
||||
- main
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
changes:
|
||||
name: Detect changes
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: read
|
||||
outputs:
|
||||
js: ${{ steps.filter.outputs.js }}
|
||||
python: ${{ steps.filter.outputs.python }}
|
||||
cli: ${{ steps.filter.outputs.cli }}
|
||||
steps:
|
||||
- name: Filter changed paths
|
||||
# On workflow_dispatch there is no PR diff and paths-filter would fall
|
||||
# back to git (failing without a checkout); the job-level `if`s below
|
||||
# force a full run for that event instead, so we only filter on pull_request.
|
||||
if: github.event_name == 'pull_request'
|
||||
uses: dorny/paths-filter@v3
|
||||
id: filter
|
||||
with:
|
||||
# `**/!(*.md)` excludes Markdown so docs-only changes don't trigger the suites.
|
||||
filters: |
|
||||
shared: &shared
|
||||
- '.github/workflows/sdk_tests.yml'
|
||||
- 'spec/**/!(*.md)'
|
||||
- 'pnpm-lock.yaml'
|
||||
- 'pnpm-workspace.yaml'
|
||||
- 'package.json'
|
||||
- '.tool-versions'
|
||||
js:
|
||||
- *shared
|
||||
- '.github/workflows/js_sdk_tests.yml'
|
||||
- 'packages/js-sdk/**/!(*.md)'
|
||||
python:
|
||||
- *shared
|
||||
- '.github/workflows/python_sdk_tests.yml'
|
||||
- 'packages/python-sdk/**/!(*.md)'
|
||||
- 'packages/connect-python/**/!(*.md)'
|
||||
cli:
|
||||
- *shared
|
||||
- '.github/workflows/cli_tests.yml'
|
||||
- 'packages/cli/**/!(*.md)'
|
||||
- 'packages/js-sdk/**/!(*.md)'
|
||||
|
||||
js-tests:
|
||||
name: Production / JS SDK Tests
|
||||
needs: changes
|
||||
if: ${{ needs.changes.outputs.js == 'true' || github.event_name == 'workflow_dispatch' }}
|
||||
uses: ./.github/workflows/js_sdk_tests.yml
|
||||
secrets:
|
||||
E2B_API_KEY: ${{ secrets.E2B_API_KEY }}
|
||||
|
||||
python-tests:
|
||||
name: Production / Python SDK Tests
|
||||
needs: changes
|
||||
if: ${{ needs.changes.outputs.python == 'true' || github.event_name == 'workflow_dispatch' }}
|
||||
uses: ./.github/workflows/python_sdk_tests.yml
|
||||
secrets:
|
||||
E2B_API_KEY: ${{ secrets.E2B_API_KEY }}
|
||||
|
||||
cli-tests:
|
||||
name: Production / CLI Tests
|
||||
needs: changes
|
||||
if: ${{ needs.changes.outputs.cli == 'true' || github.event_name == 'workflow_dispatch' }}
|
||||
uses: ./.github/workflows/cli_tests.yml
|
||||
secrets:
|
||||
E2B_API_KEY: ${{ secrets.E2B_API_KEY }}
|
||||
|
||||
js-tests-staging:
|
||||
name: Staging / JS SDK Tests
|
||||
needs: changes
|
||||
if: ${{ needs.changes.outputs.js == 'true' || github.event_name == 'workflow_dispatch' }}
|
||||
uses: ./.github/workflows/js_sdk_tests.yml
|
||||
with:
|
||||
E2B_DOMAIN: ${{ vars.E2B_DOMAIN_STAGING }}
|
||||
secrets:
|
||||
E2B_API_KEY: ${{ secrets.E2B_API_KEY_STAGING }}
|
||||
|
||||
python-tests-staging:
|
||||
name: Staging / Python SDK Tests
|
||||
needs: changes
|
||||
if: ${{ needs.changes.outputs.python == 'true' || github.event_name == 'workflow_dispatch' }}
|
||||
uses: ./.github/workflows/python_sdk_tests.yml
|
||||
with:
|
||||
E2B_DOMAIN: ${{ vars.E2B_DOMAIN_STAGING }}
|
||||
secrets:
|
||||
E2B_API_KEY: ${{ secrets.E2B_API_KEY_STAGING }}
|
||||
|
||||
cli-tests-staging:
|
||||
name: Staging / CLI Tests
|
||||
needs: changes
|
||||
if: ${{ needs.changes.outputs.cli == 'true' || github.event_name == 'workflow_dispatch' }}
|
||||
uses: ./.github/workflows/cli_tests.yml
|
||||
with:
|
||||
E2B_DOMAIN: ${{ vars.E2B_DOMAIN_STAGING }}
|
||||
secrets:
|
||||
E2B_API_KEY: ${{ secrets.E2B_API_KEY_STAGING }}
|
||||
|
||||
status:
|
||||
name: SDK Tests Status
|
||||
# `changes` is included so a failure in change-detection (which would skip the
|
||||
# suites) surfaces as a failed required check instead of a false green.
|
||||
needs:
|
||||
- changes
|
||||
- js-tests
|
||||
- python-tests
|
||||
- cli-tests
|
||||
if: ${{ always() }}
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Report aggregate result
|
||||
run: |
|
||||
echo "Job results: ${{ join(needs.*.result, ', ') }}"
|
||||
if ${{ contains(needs.*.result, 'failure') }}; then
|
||||
echo "::error::Change detection or a Production SDK test suite failed."
|
||||
exit 1
|
||||
fi
|
||||
if ${{ contains(needs.*.result, 'cancelled') }}; then
|
||||
echo "::error::Change detection or a Production SDK test suite was cancelled; tests were not verified — re-run before merging."
|
||||
exit 1
|
||||
fi
|
||||
echo "All Production SDK test suites passed or were skipped."
|
||||
@@ -0,0 +1,52 @@
|
||||
name: Build and push prepared templates
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
buildAndPushImage:
|
||||
defaults:
|
||||
run:
|
||||
working-directory: ./templates/base
|
||||
|
||||
name: Build and Push Image to DockerHub
|
||||
runs-on: ubuntu-22.04
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v3
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Log in to DockerHub
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
|
||||
- name: Build and push
|
||||
run: |
|
||||
docker buildx build \
|
||||
--file e2b.Dockerfile \
|
||||
--platform linux/amd64,linux/arm64 \
|
||||
--push \
|
||||
--tag ${{ secrets.DOCKERHUB_USERNAME }}/base:latest .
|
||||
|
||||
buildTemplate:
|
||||
name: Build and Publish E2B Template
|
||||
runs-on: ubuntu-22.04
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Build and install E2B CLI
|
||||
uses: ./.github/actions/build-cli
|
||||
|
||||
- name: Build and publish base template
|
||||
working-directory: ./templates/base
|
||||
env:
|
||||
E2B_API_KEY: ${{ secrets.E2B_API_KEY }}
|
||||
run: e2b template create base --memory-mb 512
|
||||
@@ -0,0 +1,82 @@
|
||||
name: Typecheck
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
|
||||
jobs:
|
||||
changes:
|
||||
name: Detect changes
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: read
|
||||
outputs:
|
||||
code: ${{ steps.filter.outputs.code }}
|
||||
steps:
|
||||
- name: Filter changed paths
|
||||
uses: dorny/paths-filter@v3
|
||||
id: filter
|
||||
with:
|
||||
# `**/!(*.md)` excludes Markdown so docs-only changes don't trigger typecheck.
|
||||
filters: |
|
||||
code:
|
||||
- 'packages/**/!(*.md)'
|
||||
- 'spec/**/!(*.md)'
|
||||
- 'pnpm-lock.yaml'
|
||||
- 'pnpm-workspace.yaml'
|
||||
- 'package.json'
|
||||
- '.tool-versions'
|
||||
- '.github/workflows/typecheck.yml'
|
||||
|
||||
typecheck:
|
||||
name: Typecheck
|
||||
needs: changes
|
||||
if: ${{ needs.changes.outputs.code == 'true' }}
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
steps:
|
||||
- name: Checkout Repo
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Parse .tool-versions
|
||||
uses: wistia/parse-tool-versions@v2.1.1
|
||||
with:
|
||||
filename: '.tool-versions'
|
||||
uppercase: 'true'
|
||||
prefix: 'tool_version_'
|
||||
|
||||
- uses: pnpm/action-setup@v4
|
||||
with:
|
||||
version: '${{ env.TOOL_VERSION_PNPM }}'
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: '${{ env.TOOL_VERSION_NODEJS }}'
|
||||
cache: pnpm
|
||||
|
||||
- name: Configure pnpm
|
||||
run: |
|
||||
pnpm config set auto-install-peers true
|
||||
pnpm config set exclude-links-from-lockfile true
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: Install uv
|
||||
uses: astral-sh/setup-uv@v6
|
||||
with:
|
||||
version: '${{ env.TOOL_VERSION_UV }}'
|
||||
python-version: '${{ env.TOOL_VERSION_PYTHON }}'
|
||||
enable-cache: true
|
||||
|
||||
- name: Install Python dependencies
|
||||
working-directory: packages/python-sdk
|
||||
run: |
|
||||
uv sync --locked
|
||||
|
||||
- name: Run typecheck
|
||||
run: |
|
||||
pnpm run typecheck
|
||||
+297
@@ -0,0 +1,297 @@
|
||||
vale-styles
|
||||
.vercel
|
||||
.vscode
|
||||
.DS_Store
|
||||
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
lerna-debug.log*
|
||||
.pnpm-debug.log*
|
||||
|
||||
# Diagnostic reports (https://nodejs.org/api/report.html)
|
||||
report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json
|
||||
|
||||
# Runtime data
|
||||
pids
|
||||
*.pid
|
||||
*.seed
|
||||
*.pid.lock
|
||||
|
||||
# Directory for instrumented libs generated by jscoverage/JSCover
|
||||
lib-cov
|
||||
|
||||
# Coverage directory used by tools like istanbul
|
||||
coverage
|
||||
*.lcov
|
||||
|
||||
# nyc test coverage
|
||||
.nyc_output
|
||||
|
||||
# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files)
|
||||
.grunt
|
||||
|
||||
# Bower dependency directory (https://bower.io/)
|
||||
bower_components
|
||||
|
||||
# node-waf configuration
|
||||
.lock-wscript
|
||||
|
||||
# Compiled binary addons (https://nodejs.org/api/addons.html)
|
||||
build/Release
|
||||
|
||||
# Dependency directories
|
||||
node_modules/
|
||||
jspm_packages/
|
||||
|
||||
# Snowpack dependency directory (https://snowpack.dev/)
|
||||
web_modules/
|
||||
|
||||
# TypeScript cache
|
||||
*.tsbuildinfo
|
||||
|
||||
# Optional npm cache directory
|
||||
.npm
|
||||
|
||||
# Optional eslint cache
|
||||
.eslintcache
|
||||
|
||||
# Optional stylelint cache
|
||||
.stylelintcache
|
||||
|
||||
# Microbundle cache
|
||||
.rpt2_cache/
|
||||
.rts2_cache_cjs/
|
||||
.rts2_cache_es/
|
||||
.rts2_cache_umd/
|
||||
|
||||
# Optional REPL history
|
||||
.node_repl_history
|
||||
|
||||
# Output of 'npm pack'
|
||||
*.tgz
|
||||
|
||||
# Yarn Integrity file
|
||||
.yarn-integrity
|
||||
|
||||
# dotenv environment variable files
|
||||
.env
|
||||
.env.*
|
||||
!.env.template
|
||||
!.env.example
|
||||
# parcel-bundler cache (https://parceljs.org/)
|
||||
.cache
|
||||
.parcel-cache
|
||||
|
||||
# Next.js build output
|
||||
.next
|
||||
out
|
||||
|
||||
# Nuxt.js build / generate output
|
||||
.nuxt
|
||||
dist
|
||||
|
||||
# Gatsby files
|
||||
.cache/
|
||||
# Comment in the public line in if your project uses Gatsby and not Next.js
|
||||
# https://nextjs.org/blog/next-9-1#public-directory-support
|
||||
# public
|
||||
|
||||
# vuepress build output
|
||||
.vuepress/dist
|
||||
|
||||
# vuepress v2.x temp and cache directory
|
||||
.temp
|
||||
.cache
|
||||
|
||||
# Docusaurus cache and generated files
|
||||
.docusaurus
|
||||
|
||||
# Serverless directories
|
||||
.serverless/
|
||||
|
||||
# FuseBox cache
|
||||
.fusebox/
|
||||
|
||||
# DynamoDB Local files
|
||||
.dynamodb/
|
||||
|
||||
# TernJS port file
|
||||
.tern-port
|
||||
|
||||
# Stores VSCode versions used for testing VSCode extensions
|
||||
.vscode-test
|
||||
|
||||
# yarn v2
|
||||
.yarn/cache
|
||||
.yarn/unplugged
|
||||
.yarn/build-state.yml
|
||||
.yarn/install-state.gz
|
||||
.pnp.*
|
||||
|
||||
# Byte-compiled / optimized / DLL files
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
*$py.class
|
||||
|
||||
# C extensions
|
||||
*.so
|
||||
|
||||
# Distribution / packaging
|
||||
.Python
|
||||
build/
|
||||
develop-eggs/
|
||||
dist/
|
||||
downloads/
|
||||
eggs/
|
||||
.eggs/
|
||||
lib64/
|
||||
parts/
|
||||
sdist/
|
||||
var/
|
||||
wheels/
|
||||
share/python-wheels/
|
||||
*.egg-info/
|
||||
.installed.cfg
|
||||
*.egg
|
||||
MANIFEST
|
||||
|
||||
# PyInstaller
|
||||
# Usually these files are written by a python script from a template
|
||||
# before PyInstaller builds the exe, so as to inject date/other infos into it.
|
||||
*.manifest
|
||||
*.spec
|
||||
|
||||
# Installer logs
|
||||
pip-log.txt
|
||||
pip-delete-this-directory.txt
|
||||
|
||||
# Unit test / coverage reports
|
||||
htmlcov/
|
||||
.tox/
|
||||
.nox/
|
||||
.coverage
|
||||
.coverage.*
|
||||
.cache
|
||||
nosetests.xml
|
||||
coverage.xml
|
||||
*.cover
|
||||
*.py,cover
|
||||
.hypothesis/
|
||||
.pytest_cache/
|
||||
cover/
|
||||
|
||||
# Translations
|
||||
*.mo
|
||||
*.pot
|
||||
|
||||
# Django stuff:
|
||||
*.log
|
||||
local_settings.py
|
||||
db.sqlite3
|
||||
db.sqlite3-journal
|
||||
|
||||
# Flask stuff:
|
||||
instance/
|
||||
.webassets-cache
|
||||
|
||||
# Scrapy stuff:
|
||||
.scrapy
|
||||
|
||||
# Sphinx documentation
|
||||
docs/_build/
|
||||
|
||||
# PyBuilder
|
||||
.pybuilder/
|
||||
target/
|
||||
|
||||
# Jupyter Notebook
|
||||
.ipynb_checkpoints
|
||||
|
||||
# IPython
|
||||
profile_default/
|
||||
ipython_config.py
|
||||
|
||||
# pyenv
|
||||
# For a library or package, you might want to ignore these files since the code is
|
||||
# intended to run in multiple environments; otherwise, check them in:
|
||||
# .python-version
|
||||
|
||||
# pipenv
|
||||
# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
|
||||
# However, in case of collaboration, if having platform-specific dependencies or dependencies
|
||||
# having no cross-platform support, pipenv may install dependencies that don't work, or not
|
||||
# install all needed dependencies.
|
||||
#Pipfile.lock
|
||||
|
||||
# poetry
|
||||
# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control.
|
||||
# This is especially recommended for binary packages to ensure reproducibility, and is more
|
||||
# commonly ignored for libraries.
|
||||
# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control
|
||||
#poetry.lock
|
||||
|
||||
# pdm
|
||||
# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control.
|
||||
#pdm.lock
|
||||
# pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it
|
||||
# in version control.
|
||||
# https://pdm.fming.dev/#use-with-ide
|
||||
.pdm.toml
|
||||
|
||||
# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm
|
||||
__pypackages__/
|
||||
|
||||
# Celery stuff
|
||||
celerybeat-schedule
|
||||
celerybeat.pid
|
||||
|
||||
# SageMath parsed files
|
||||
*.sage.py
|
||||
|
||||
# Environments
|
||||
.env
|
||||
.venv
|
||||
env.bak/
|
||||
venv.bak/
|
||||
|
||||
# Spyder project settings
|
||||
.spyderproject
|
||||
.spyproject
|
||||
|
||||
# Rope project settings
|
||||
.ropeproject
|
||||
|
||||
# mkdocs documentation
|
||||
/site
|
||||
|
||||
# mypy
|
||||
.mypy_cache/
|
||||
.dmypy.json
|
||||
dmypy.json
|
||||
|
||||
# Pyre type checker
|
||||
.pyre/
|
||||
|
||||
# pytype static type analyzer
|
||||
.pytype/
|
||||
|
||||
# Cython debug symbols
|
||||
cython_debug/
|
||||
|
||||
# PyCharm
|
||||
# JetBrains specific template is maintained in a separate JetBrains.gitignore that can
|
||||
# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore
|
||||
# and can be added to the global gitignore or merged into this file. For a more nuclear
|
||||
# option (not recommended) you can uncomment the following to ignore the entire idea folder.
|
||||
.idea/
|
||||
|
||||
# SDK reference artifacts
|
||||
sdk_ref/
|
||||
sdkRefRoutes.json
|
||||
|
||||
# SDK Client generated spec
|
||||
spec/openapi_generated.yml
|
||||
@@ -0,0 +1,6 @@
|
||||
enable-pre-post-scripts=true
|
||||
auto-install-peers=true
|
||||
exclude-links-from-lockfile=true
|
||||
prefer-workspace-packages=false
|
||||
link-workspace-packages=false
|
||||
engine-strict=true
|
||||
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"$schema": "https://raw.githubusercontent.com/oxc-project/oxc/main/npm/oxlint/configuration_schema.json",
|
||||
"ignorePatterns": ["**/dist/**", "**/node_modules/**", "**/*.gen.ts"],
|
||||
"rules": {
|
||||
"no-unused-vars": ["error", { "caughtErrors": "none" }],
|
||||
"typescript/no-explicit-any": "off",
|
||||
"typescript/ban-ts-comment": "off",
|
||||
"unicorn/no-useless-fallback-in-spread": "off"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
/.next/
|
||||
|
||||
# imporatant to keep // $HighlightLine comments at the end of the line
|
||||
apps/web/src/code/
|
||||
|
||||
|
||||
**/*.mdx
|
||||
**/code/**/*
|
||||
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"singleQuote": true,
|
||||
"semi": false,
|
||||
"trailingComma": "es5"
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
deno 1.46.3
|
||||
nodejs 22.18.0
|
||||
pnpm 9.15.5
|
||||
python 3.10
|
||||
uv 0.10.0
|
||||
@@ -0,0 +1,8 @@
|
||||
StylesPath = vale-styles
|
||||
|
||||
MinAlertLevel = suggestion
|
||||
|
||||
Packages = Microsoft, proselint, write-good, alex, Readability, Joblint, Google
|
||||
|
||||
[*.{md,ts,py}]
|
||||
BasedOnStyles = Vale, Microsoft, proselint, write-good, alex, Readability, Joblint, Google
|
||||
@@ -0,0 +1,9 @@
|
||||
Use pnpm for node and uv for python to install and update dependencies.
|
||||
Run `pnpm run format`, `pnpm run lint` and `pnpm run typecheck` before committing changes.
|
||||
When modifying the SDK packages, ensure equivalent changes are applied to both JS as well as sync and async Python implementations.
|
||||
To re-generate the API client run `make codegen` in the repository root when modifying spec/.
|
||||
Create or update tests covering affected codepaths and run them using `pnpm run test`.
|
||||
Generate a changeset when updating packages/cli, packages/js-sdk, packages/python-sdk with `pnpm changeset` in the repository root.
|
||||
When creating a pull request, add usage examples for user-facing changes to the PR description.
|
||||
Keep PR descriptions up-to-date with changes.
|
||||
Default credentials are stored in .env.local in the repository root or inside ~/.e2b/config.json.
|
||||
@@ -0,0 +1,7 @@
|
||||
# These owners will be the default owners for everything in
|
||||
# the repo. Unless a later match takes precedence.
|
||||
* @jakubno @ValentaTomas
|
||||
|
||||
# SDKs
|
||||
packages @jakubno @ValentaTomas @mishushakov
|
||||
packages/connect-python @ValentaTomas
|
||||
@@ -0,0 +1,2 @@
|
||||
# Contributing
|
||||
If you want to contribute, open a PR, issue, or start a discussion on our [Discord](https://discord.gg/dSBY3ms2Qr).
|
||||
@@ -0,0 +1,3 @@
|
||||
# Releasing e2b cli
|
||||
|
||||
to create a changeset run `pnpm run changeset`
|
||||
@@ -0,0 +1,201 @@
|
||||
Apache License
|
||||
Version 2.0, January 2004
|
||||
http://www.apache.org/licenses/
|
||||
|
||||
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
|
||||
|
||||
1. Definitions.
|
||||
|
||||
"License" shall mean the terms and conditions for use, reproduction,
|
||||
and distribution as defined by Sections 1 through 9 of this document.
|
||||
|
||||
"Licensor" shall mean the copyright owner or entity authorized by
|
||||
the copyright owner that is granting the License.
|
||||
|
||||
"Legal Entity" shall mean the union of the acting entity and all
|
||||
other entities that control, are controlled by, or are under common
|
||||
control with that entity. For the purposes of this definition,
|
||||
"control" means (i) the power, direct or indirect, to cause the
|
||||
direction or management of such entity, whether by contract or
|
||||
otherwise, or (ii) ownership of fifty percent (50%) or more of the
|
||||
outstanding shares, or (iii) beneficial ownership of such entity.
|
||||
|
||||
"You" (or "Your") shall mean an individual or Legal Entity
|
||||
exercising permissions granted by this License.
|
||||
|
||||
"Source" form shall mean the preferred form for making modifications,
|
||||
including but not limited to software source code, documentation
|
||||
source, and configuration files.
|
||||
|
||||
"Object" form shall mean any form resulting from mechanical
|
||||
transformation or translation of a Source form, including but
|
||||
not limited to compiled object code, generated documentation,
|
||||
and conversions to other media types.
|
||||
|
||||
"Work" shall mean the work of authorship, whether in Source or
|
||||
Object form, made available under the License, as indicated by a
|
||||
copyright notice that is included in or attached to the work
|
||||
(an example is provided in the Appendix below).
|
||||
|
||||
"Derivative Works" shall mean any work, whether in Source or Object
|
||||
form, that is based on (or derived from) the Work and for which the
|
||||
editorial revisions, annotations, elaborations, or other modifications
|
||||
represent, as a whole, an original work of authorship. For the purposes
|
||||
of this License, Derivative Works shall not include works that remain
|
||||
separable from, or merely link (or bind by name) to the interfaces of,
|
||||
the Work and Derivative Works thereof.
|
||||
|
||||
"Contribution" shall mean any work of authorship, including
|
||||
the original version of the Work and any modifications or additions
|
||||
to that Work or Derivative Works thereof, that is intentionally
|
||||
submitted to Licensor for inclusion in the Work by the copyright owner
|
||||
or by an individual or Legal Entity authorized to submit on behalf of
|
||||
the copyright owner. For the purposes of this definition, "submitted"
|
||||
means any form of electronic, verbal, or written communication sent
|
||||
to the Licensor or its representatives, including but not limited to
|
||||
communication on electronic mailing lists, source code control systems,
|
||||
and issue tracking systems that are managed by, or on behalf of, the
|
||||
Licensor for the purpose of discussing and improving the Work, but
|
||||
excluding communication that is conspicuously marked or otherwise
|
||||
designated in writing by the copyright owner as "Not a Contribution."
|
||||
|
||||
"Contributor" shall mean Licensor and any individual or Legal Entity
|
||||
on behalf of whom a Contribution has been received by Licensor and
|
||||
subsequently incorporated within the Work.
|
||||
|
||||
2. Grant of Copyright License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
copyright license to reproduce, prepare Derivative Works of,
|
||||
publicly display, publicly perform, sublicense, and distribute the
|
||||
Work and such Derivative Works in Source or Object form.
|
||||
|
||||
3. Grant of Patent License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
(except as stated in this section) patent license to make, have made,
|
||||
use, offer to sell, sell, import, and otherwise transfer the Work,
|
||||
where such license applies only to those patent claims licensable
|
||||
by such Contributor that are necessarily infringed by their
|
||||
Contribution(s) alone or by combination of their Contribution(s)
|
||||
with the Work to which such Contribution(s) was submitted. If You
|
||||
institute patent litigation against any entity (including a
|
||||
cross-claim or counterclaim in a lawsuit) alleging that the Work
|
||||
or a Contribution incorporated within the Work constitutes direct
|
||||
or contributory patent infringement, then any patent licenses
|
||||
granted to You under this License for that Work shall terminate
|
||||
as of the date such litigation is filed.
|
||||
|
||||
4. Redistribution. You may reproduce and distribute copies of the
|
||||
Work or Derivative Works thereof in any medium, with or without
|
||||
modifications, and in Source or Object form, provided that You
|
||||
meet the following conditions:
|
||||
|
||||
(a) You must give any other recipients of the Work or
|
||||
Derivative Works a copy of this License; and
|
||||
|
||||
(b) You must cause any modified files to carry prominent notices
|
||||
stating that You changed the files; and
|
||||
|
||||
(c) You must retain, in the Source form of any Derivative Works
|
||||
that You distribute, all copyright, patent, trademark, and
|
||||
attribution notices from the Source form of the Work,
|
||||
excluding those notices that do not pertain to any part of
|
||||
the Derivative Works; and
|
||||
|
||||
(d) If the Work includes a "NOTICE" text file as part of its
|
||||
distribution, then any Derivative Works that You distribute must
|
||||
include a readable copy of the attribution notices contained
|
||||
within such NOTICE file, excluding those notices that do not
|
||||
pertain to any part of the Derivative Works, in at least one
|
||||
of the following places: within a NOTICE text file distributed
|
||||
as part of the Derivative Works; within the Source form or
|
||||
documentation, if provided along with the Derivative Works; or,
|
||||
within a display generated by the Derivative Works, if and
|
||||
wherever such third-party notices normally appear. The contents
|
||||
of the NOTICE file are for informational purposes only and
|
||||
do not modify the License. You may add Your own attribution
|
||||
notices within Derivative Works that You distribute, alongside
|
||||
or as an addendum to the NOTICE text from the Work, provided
|
||||
that such additional attribution notices cannot be construed
|
||||
as modifying the License.
|
||||
|
||||
You may add Your own copyright statement to Your modifications and
|
||||
may provide additional or different license terms and conditions
|
||||
for use, reproduction, or distribution of Your modifications, or
|
||||
for any such Derivative Works as a whole, provided Your use,
|
||||
reproduction, and distribution of the Work otherwise complies with
|
||||
the conditions stated in this License.
|
||||
|
||||
5. Submission of Contributions. Unless You explicitly state otherwise,
|
||||
any Contribution intentionally submitted for inclusion in the Work
|
||||
by You to the Licensor shall be under the terms and conditions of
|
||||
this License, without any additional terms or conditions.
|
||||
Notwithstanding the above, nothing herein shall supersede or modify
|
||||
the terms of any separate license agreement you may have executed
|
||||
with Licensor regarding such Contributions.
|
||||
|
||||
6. Trademarks. This License does not grant permission to use the trade
|
||||
names, trademarks, service marks, or product names of the Licensor,
|
||||
except as required for reasonable and customary use in describing the
|
||||
origin of the Work and reproducing the content of the NOTICE file.
|
||||
|
||||
7. Disclaimer of Warranty. Unless required by applicable law or
|
||||
agreed to in writing, Licensor provides the Work (and each
|
||||
Contributor provides its Contributions) on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
|
||||
implied, including, without limitation, any warranties or conditions
|
||||
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
|
||||
PARTICULAR PURPOSE. You are solely responsible for determining the
|
||||
appropriateness of using or redistributing the Work and assume any
|
||||
risks associated with Your exercise of permissions under this License.
|
||||
|
||||
8. Limitation of Liability. In no event and under no legal theory,
|
||||
whether in tort (including negligence), contract, or otherwise,
|
||||
unless required by applicable law (such as deliberate and grossly
|
||||
negligent acts) or agreed to in writing, shall any Contributor be
|
||||
liable to You for damages, including any direct, indirect, special,
|
||||
incidental, or consequential damages of any character arising as a
|
||||
result of this License or out of the use or inability to use the
|
||||
Work (including but not limited to damages for loss of goodwill,
|
||||
work stoppage, computer failure or malfunction, or any and all
|
||||
other commercial damages or losses), even if such Contributor
|
||||
has been advised of the possibility of such damages.
|
||||
|
||||
9. Accepting Warranty or Additional Liability. While redistributing
|
||||
the Work or Derivative Works thereof, You may choose to offer,
|
||||
and charge a fee for, acceptance of support, warranty, indemnity,
|
||||
or other liability obligations and/or rights consistent with this
|
||||
License. However, in accepting such obligations, You may act only
|
||||
on Your own behalf and on Your sole responsibility, not on behalf
|
||||
of any other Contributor, and only if You agree to indemnify,
|
||||
defend, and hold each Contributor harmless for any liability
|
||||
incurred by, or claims asserted against, such Contributor by reason
|
||||
of your accepting any such warranty or additional liability.
|
||||
|
||||
END OF TERMS AND CONDITIONS
|
||||
|
||||
APPENDIX: How to apply the Apache License to your work.
|
||||
|
||||
To apply the Apache License to your work, attach the following
|
||||
boilerplate notice, with the fields enclosed by brackets "[]"
|
||||
replaced with your own identifying information. (Don't include
|
||||
the brackets!) The text should be enclosed in the appropriate
|
||||
comment syntax for the file format. We also recommend that a
|
||||
file or class name and description of purpose be included on the
|
||||
same "printed page" as the copyright notice for easier
|
||||
identification within third-party archives.
|
||||
|
||||
Copyright 2023 FoundryLabs, Inc.
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
@@ -0,0 +1,19 @@
|
||||
.PHONY: codegen
|
||||
codegen:
|
||||
@echo "Building codegen image"
|
||||
docker build -q -t codegen-env . -f codegen.Dockerfile
|
||||
@echo "Generating code"
|
||||
docker run -v $$PWD:/workspace codegen-env make generate
|
||||
|
||||
generate: generate-js generate-python
|
||||
|
||||
generate-js:
|
||||
cd packages/js-sdk && pnpm generate
|
||||
|
||||
generate-python:
|
||||
cd packages/python-sdk && make generate
|
||||
|
||||
.PHONY: init-styles
|
||||
init-styles:
|
||||
vale sync
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
<p align="center">
|
||||
<img src="/readme-assets/logo-black.png#gh-light-mode-only" alt="E2B Logo" width="200">
|
||||
<img src="/readme-assets/logo-white.png#gh-dark-mode-only" alt="E2B Logo" width="200">
|
||||
</p>
|
||||
|
||||
<h4 align="center">
|
||||
<a href="https://pypi.org/project/e2b/">
|
||||
<img alt="Last 1 month downloads for the Python SDK" loading="lazy" decoding="async" style="color:transparent;width:170px;height:18px" src="https://static.pepy.tech/personalized-badge/e2b?period=monthly&units=INTERNATIONAL_SYSTEM&left_color=BLACK&right_color=GREEN&left_text=PyPi%20Monthly%20Downloads">
|
||||
</a>
|
||||
<a href="https://www.npmjs.com/package/e2b">
|
||||
<img alt="Last 1 month downloads for the JavaScript SDK" loading="lazy" width="200" height="30" decoding="async" data-nimg="1"
|
||||
style="color:transparent;width:170px;height:18px" src="https://img.shields.io/npm/dm/e2b?label=NPM%20Monthly%20Downloads">
|
||||
</a>
|
||||
</h4>
|
||||
|
||||
<!---
|
||||
<img width="100%" src="/readme-assets/preview.png" alt="Cover image">
|
||||
--->
|
||||
## What is E2B?
|
||||
[E2B](https://e2b.dev/?utm_source=github&utm_medium=referral&utm_campaign=readme&utm_content=E2B) is an open-source infrastructure that allows you to run AI-generated code in secure isolated sandboxes in the cloud. To start and control sandboxes, use our [JavaScript SDK](https://www.npmjs.com/package/e2b) or [Python SDK](https://pypi.org/project/e2b).
|
||||
|
||||
## Run your first Sandbox
|
||||
|
||||
### 1. Install SDK
|
||||
|
||||
JavaScript / TypeScript
|
||||
```
|
||||
npm i e2b
|
||||
```
|
||||
|
||||
Python
|
||||
```
|
||||
pip install e2b
|
||||
```
|
||||
|
||||
### 2. Get your E2B API key
|
||||
1. Sign up to E2B [here](https://e2b.dev/?utm_source=github&utm_medium=referral&utm_campaign=readme&utm_content=E2B).
|
||||
2. Get your API key [here](https://e2b.dev/dashboard?tab=keys&utm_source=github&utm_medium=referral&utm_campaign=readme&utm_content=E2B).
|
||||
3. Set environment variable with your API key
|
||||
```
|
||||
E2B_API_KEY=e2b_***
|
||||
```
|
||||
|
||||
### 3. Start a sandbox and run commands
|
||||
|
||||
JavaScript / TypeScript
|
||||
```ts
|
||||
import Sandbox from 'e2b'
|
||||
|
||||
const sandbox = await Sandbox.create()
|
||||
const result = await sandbox.commands.run('echo "Hello from E2B!"')
|
||||
console.log(result.stdout) // Hello from E2B!
|
||||
```
|
||||
|
||||
Python
|
||||
```py
|
||||
from e2b import Sandbox
|
||||
|
||||
with Sandbox.create() as sandbox:
|
||||
result = sandbox.commands.run('echo "Hello from E2B!"')
|
||||
print(result.stdout) # Hello from E2B!
|
||||
```
|
||||
|
||||
### 4. Code execution with Code Interpreter
|
||||
|
||||
If you need to execute code with [`runCode()`](https://e2b.dev/docs/code-interpreting?utm_source=github&utm_medium=referral&utm_campaign=readme&utm_content=E2B)/[`run_code()`](https://e2b.dev/docs/code-interpreting?utm_source=github&utm_medium=referral&utm_campaign=readme&utm_content=E2B), install the [Code Interpreter SDK](https://github.com/e2b-dev/code-interpreter):
|
||||
|
||||
```
|
||||
npm i @e2b/code-interpreter # JavaScript/TypeScript
|
||||
pip install e2b-code-interpreter # Python
|
||||
```
|
||||
|
||||
```ts
|
||||
import { Sandbox } from '@e2b/code-interpreter'
|
||||
|
||||
const sandbox = await Sandbox.create()
|
||||
const execution = await sandbox.runCode('x = 1; x += 1; x')
|
||||
console.log(execution.text) // outputs 2
|
||||
```
|
||||
|
||||
### 5. Check docs
|
||||
Visit [E2B documentation](https://e2b.dev/docs?utm_source=github&utm_medium=referral&utm_campaign=readme&utm_content=E2B).
|
||||
|
||||
### 6. E2B cookbook
|
||||
Visit our [Cookbook](https://github.com/e2b-dev/e2b-cookbook/tree/main) to get inspired by examples with different LLMs and AI frameworks.
|
||||
|
||||
## Self-hosting
|
||||
|
||||
Read the [self-hosting guide](https://github.com/e2b-dev/infra/blob/main/self-host.md) to learn how to set up the [E2B infrastructure](https://github.com/e2b-dev/infra) on your own. The infrastructure is deployed using Terraform.
|
||||
|
||||
Supported cloud providers:
|
||||
- 🟢 AWS
|
||||
- 🟢 Google Cloud (GCP)
|
||||
- [ ] Azure
|
||||
- [ ] General Linux machine
|
||||
@@ -0,0 +1,7 @@
|
||||
# WeHub 来源说明
|
||||
|
||||
- 原始项目:`e2b-dev/E2B`
|
||||
- 原始仓库:https://github.com/e2b-dev/E2B
|
||||
- 导入方式:上游默认分支的最新快照
|
||||
- 原作者、版权和许可证信息以原始仓库及本仓库 LICENSE 为准
|
||||
- 本文件仅用于记录来源,不代表 WeHub 是原项目作者
|
||||
@@ -0,0 +1,56 @@
|
||||
FROM golang:1.23
|
||||
|
||||
# Install Golang deps
|
||||
RUN go install github.com/bufbuild/buf/cmd/buf@v1.50.1 && \
|
||||
go install google.golang.org/protobuf/cmd/protoc-gen-go@v1.28.1 && \
|
||||
go install connectrpc.com/connect/cmd/protoc-gen-connect-go@v1.18.1
|
||||
|
||||
# Install our custom protoc plugin, connect-python
|
||||
COPY ./packages/connect-python /packages/connect-python
|
||||
RUN cd /packages/connect-python && make bin/protoc-gen-connect-python
|
||||
|
||||
|
||||
FROM python:3.10
|
||||
|
||||
# Set working directory
|
||||
WORKDIR /workspace
|
||||
|
||||
ENV PROTOC_VERSION=26.1
|
||||
RUN ARCH=$(uname -m) && \
|
||||
case "$ARCH" in \
|
||||
x86_64) PROTOC_ARCH="x86_64" ;; \
|
||||
arm64|aarch64) PROTOC_ARCH="aarch_64" ;; \
|
||||
*) echo "Unsupported architecture: $ARCH" && exit 1 ;; \
|
||||
esac && \
|
||||
curl -LO https://github.com/protocolbuffers/protobuf/releases/download/v${PROTOC_VERSION}/protoc-${PROTOC_VERSION}-linux-${PROTOC_ARCH}.zip && \
|
||||
unzip -o protoc-${PROTOC_VERSION}-linux-${PROTOC_ARCH}.zip -d /usr/local && \
|
||||
rm protoc-${PROTOC_VERSION}-linux-${PROTOC_ARCH}.zip
|
||||
|
||||
# Copy installed Go deps from previous build step
|
||||
COPY --from=0 /go /go
|
||||
|
||||
# Add Go binary to PATH
|
||||
ENV PATH="/go/bin:${PATH}"
|
||||
|
||||
# Install Python deps (e2b-openapi-python-client is patched version to fix issue with explode)
|
||||
# https://github.com/openapi-generators/openapi-python-client/pull/1296
|
||||
RUN pip install black==26.3.1 pyyaml==6.0.2 e2b-openapi-python-client==0.26.2 datamodel-code-generator==0.34.0
|
||||
|
||||
# Install Node.js (pinned to match .tool-versions)
|
||||
ENV NODE_VERSION=22.18.0
|
||||
RUN ARCH=$(uname -m) && \
|
||||
case "$ARCH" in \
|
||||
x86_64) NODE_ARCH="x64" ;; \
|
||||
arm64|aarch64) NODE_ARCH="arm64" ;; \
|
||||
*) echo "Unsupported architecture: $ARCH" && exit 1 ;; \
|
||||
esac && \
|
||||
curl -fsSL https://nodejs.org/dist/v${NODE_VERSION}/node-v${NODE_VERSION}-linux-${NODE_ARCH}.tar.xz | tar -xJ -C /usr/local --strip-components=1
|
||||
|
||||
# Install Node.js deps
|
||||
ENV PNPM_VERSION=9.15.5
|
||||
RUN npm install -g \
|
||||
pnpm@${PNPM_VERSION} \
|
||||
@connectrpc/protoc-gen-connect-es@1.6.1 \
|
||||
@bufbuild/protoc-gen-es@2.6.2
|
||||
|
||||
CMD ["make", "generate"]
|
||||
@@ -0,0 +1,48 @@
|
||||
{
|
||||
"name": "e2b",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"version": "pnpm changeset version && pnpm run -r postVersion",
|
||||
"publish": "pnpm changeset publish && pnpm run -r postPublish",
|
||||
"test": "pnpm test --recursive --if-present",
|
||||
"rm-node-modules": "find . -name 'node_modules' -type d -prune -exec rm -rf '{}' +",
|
||||
"pnpm-install-hack": "cd packages/js-sdk && sed -i '' 's/\"version\": \".*\"/\"version\": \"9.9.9\"/g' package.json && cd ../.. && pnpm i && git checkout -- packages/js-sdk/package.json",
|
||||
"lint": "pnpm --if-present --recursive run lint",
|
||||
"typecheck": "pnpm --if-present --recursive run typecheck",
|
||||
"format": "pnpm --if-present --recursive run format",
|
||||
"changeset": "pnpm dlx @changesets/cli"
|
||||
},
|
||||
"packageManager": "pnpm@9.15.5",
|
||||
"dependencies": {
|
||||
"@changesets/read": "^0.6.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"changeset": "^0.2.6",
|
||||
"oxlint": "^1.72.0"
|
||||
},
|
||||
"engines": {
|
||||
"pnpm": ">=9.0.0 <10"
|
||||
},
|
||||
"pnpm": {
|
||||
"overrides": {
|
||||
"@next/eslint-plugin-next>glob@*": "10.5.0",
|
||||
"rollup@>=4": ">=4.59.0",
|
||||
"postcss@<8.5.10": "^8.5.10",
|
||||
"vite@>=6.0.0 <6.4.2": "^6.4.2",
|
||||
"lodash@<4.18.0": "^4.18.0",
|
||||
"brace-expansion@>=2.0.0 <2.0.3": "^2.0.3",
|
||||
"picomatch@<2.3.2": "^2.3.2",
|
||||
"picomatch@>=4.0.0 <4.0.4": "^4.0.4",
|
||||
"yaml@>=2.0.0 <2.8.3": "^2.8.3",
|
||||
"@tootallnate/once@<3.0.1": "^3.0.1",
|
||||
"smol-toml@<1.6.1": "^1.6.1",
|
||||
"flatted@<3.4.2": "^3.4.2",
|
||||
"minimatch@<3.1.3": "^3.1.3",
|
||||
"minimatch@>=5.0.0 <5.1.8": "^5.1.8",
|
||||
"minimatch@>=9.0.0 <9.0.7": "^9.0.7",
|
||||
"minimatch@>=10.0.0 <10.2.3": "^10.2.3",
|
||||
"ws@>=8.0.0 <8.20.1": "^8.20.1",
|
||||
"shell-quote@<1.8.4": "^1.8.4"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user