bottleship 0.0.1 → 0.1.0-prototype.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +15 -0
- package/LICENSE +21 -0
- package/README.md +187 -2
- package/bin/bottleship +2 -0
- package/dist/presets/providers/command.d.ts +3 -0
- package/dist/presets/providers/command.js +39 -0
- package/dist/presets/providers/daytona.d.ts +3 -0
- package/dist/presets/providers/daytona.js +173 -0
- package/dist/presets/providers/docker.d.ts +3 -0
- package/dist/presets/providers/docker.js +31 -0
- package/dist/presets/providers/e2b.d.ts +3 -0
- package/dist/presets/providers/e2b.js +124 -0
- package/dist/presets/providers/exe.d.ts +3 -0
- package/dist/presets/providers/exe.js +28 -0
- package/dist/presets/providers/http.example.d.ts +3 -0
- package/dist/presets/providers/http.example.js +47 -0
- package/dist/presets/providers/local.d.ts +3 -0
- package/dist/presets/providers/local.js +52 -0
- package/dist/presets/providers/modal.d.ts +3 -0
- package/dist/presets/providers/modal.js +117 -0
- package/dist/presets/providers/ssh.d.ts +3 -0
- package/dist/presets/providers/ssh.js +49 -0
- package/dist/presets/providers/util.d.ts +13 -0
- package/dist/presets/providers/util.js +102 -0
- package/dist/src/boxes.d.ts +64 -0
- package/dist/src/boxes.js +265 -0
- package/dist/src/cli.d.ts +1 -0
- package/dist/src/cli.js +202 -0
- package/dist/src/config.d.ts +27 -0
- package/dist/src/config.js +151 -0
- package/dist/src/init.d.ts +13 -0
- package/dist/src/init.js +396 -0
- package/dist/src/paths.d.ts +4 -0
- package/dist/src/paths.js +17 -0
- package/dist/src/secrets.d.ts +6 -0
- package/dist/src/secrets.js +68 -0
- package/dist/src/types.d.ts +120 -0
- package/dist/src/types.js +1 -0
- package/dist/src/ui.d.ts +31 -0
- package/dist/src/ui.js +127 -0
- package/package.json +50 -4
- package/presets/agents/claude.json +12 -0
- package/presets/agents/codex.json +15 -0
- package/presets/agents/cursor.json +14 -0
- package/presets/agents/opencode.json +19 -0
- package/presets/providers/command.ts +43 -0
- package/presets/providers/daytona.ts +181 -0
- package/presets/providers/docker.ts +38 -0
- package/presets/providers/e2b.ts +130 -0
- package/presets/providers/exe.ts +35 -0
- package/presets/providers/http.example.ts +64 -0
- package/presets/providers/local.ts +56 -0
- package/presets/providers/modal.ts +121 -0
- package/presets/providers/ssh.ts +54 -0
- package/presets/providers/util.ts +112 -0
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
Add a section on top with a new version to release it: pushing it to main publishes that version to npm.
|
|
4
|
+
Prerelease versions like `0.1.0-prototype.1` go to the dist-tag named after them (`prototype`), plain versions to `latest`.
|
|
5
|
+
|
|
6
|
+
## 0.1.0-prototype.1
|
|
7
|
+
|
|
8
|
+
- The last step of `bottleship init` opens your local agent (Claude Code, Codex, opencode or Cursor) to add bottleship to its global instructions, instead of showing text to copy.
|
|
9
|
+
- `bottleship init --reset` starts over: sets the config aside as `config.json.bak` and asks everything again, and on a terminal offers to forget saved logins.
|
|
10
|
+
- Fixed: a status check could read a box's state while it was being written and report a waiting login as running, or a finished agent as stopped.
|
|
11
|
+
- Fixed: removing a box that was still waiting for its device login left the login process running.
|
|
12
|
+
|
|
13
|
+
## 0.1.0-prototype.0
|
|
14
|
+
|
|
15
|
+
First prototype. `bottleship init` sets up a provider and a coding agent once, then your local agent runs other agents in remote boxes with `new`, `status`, `ask`, `exec` and `rm`.
|
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Joshua Knauber
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,188 @@
|
|
|
1
|
-
# bottleship
|
|
1
|
+
# bottleship (prototype)
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
A small CLI your local coding agent uses to run other coding agents in remote sandboxes ("boxes"), like subagents. Your agent writes the prompt, starts a box, checks on it, follows up, and cleans up. The agent in the box clones the repo, works, commits, pushes, and opens a PR.
|
|
4
|
+
|
|
5
|
+
bottleship is stateless: no daemon, no local task list. The box id is the handle, and everything about a run lives in the box.
|
|
6
|
+
|
|
7
|
+
This README is written for agents. Humans: run `bottleship init` once, then tell your agent to read this file.
|
|
8
|
+
|
|
9
|
+
## Install
|
|
10
|
+
|
|
11
|
+
```sh
|
|
12
|
+
npm install -g <path or git URL of this package> # will be `npm i -g bottleship` once published
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
Needs Node >= 22.18 on this machine. Installing from a git URL or a checkout builds the TypeScript sources (`npm install` runs `npm run build`).
|
|
16
|
+
|
|
17
|
+
## Onboarding
|
|
18
|
+
|
|
19
|
+
Once per machine, in a terminal:
|
|
20
|
+
|
|
21
|
+
```sh
|
|
22
|
+
bottleship init
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
It asks where boxes run (provider), which agent runs in them (`claude`, `codex`, `opencode`, `cursor`), how that agent logs in, and the git name and email for their commits, then collects the secrets boxes need and runs a live check: create a box, run a command, set up the agent, get one reply, destroy the box. Re-running it re-checks everything and only asks for what is missing.
|
|
26
|
+
|
|
27
|
+
An agent can run it non-interactively; it never prompts and prints JSON:
|
|
28
|
+
|
|
29
|
+
```sh
|
|
30
|
+
bottleship init --yes --provider docker --agent claude --git-name "Ada Lovelace" --git-email ada@example.com
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
`--provider ssh` also needs `--host <host>`; `--provider custom --name <name>` sets up a custom provider (see below). `local` and `ssh` run agents with no isolation, so init needs the user's explicit OK: a confirm in the terminal, or `--i-understand-no-isolation`. Only pass that flag when the user said so.
|
|
34
|
+
|
|
35
|
+
For an agent on the device login route (codex by default), the live check stops at the login with `login_needs_human`, since someone has to approve it in a browser. Ask the user to run `bottleship init` in their terminal.
|
|
36
|
+
|
|
37
|
+
At the end, init opens the user's own agent on this machine (Claude Code, Codex, opencode or Cursor, whichever is installed) with a prompt to add the instructions below to its global instructions (for Cursor, a user rule file `~/.cursor/rules/bottleship.mdc`; Cursor documents that folder, not whether its CLI reads it), so the user only approves the change. With `--yes`, the JSON's `next` asks the calling agent to add `instructions` to its own global instructions file and show the user the change.
|
|
38
|
+
|
|
39
|
+
Rerunning `bottleship init` only asks what is missing. `bottleship init --reset` starts over: it sets `~/.bottleship/config.json` aside as `config.json.bak` and asks everything again; on a terminal it also offers to forget the saved logins. With `--yes` it never deletes saved logins.
|
|
40
|
+
|
|
41
|
+
If a secret is missing it fails with `secret_missing`. Then ask the user to run `bottleship init` in their own terminal, where it asks with a hidden prompt. Never ask the user to paste a secret to you.
|
|
42
|
+
|
|
43
|
+
### Secrets
|
|
44
|
+
|
|
45
|
+
Box env vars are configured under `env` in `~/.bottleship/config.json` (and per agent). A value is a plain string or a secret source:
|
|
46
|
+
|
|
47
|
+
| Source | Meaning |
|
|
48
|
+
|---|---|
|
|
49
|
+
| `{ "store": "claude" }` | bottleship's store: macOS Keychain, or a 0600 file under `~/.bottleship/secrets` |
|
|
50
|
+
| `{ "command": "gh auth token" }` | the output of a command on this machine |
|
|
51
|
+
| `{ "env": "CORP_KEY" }` | an environment variable of the bottleship process |
|
|
52
|
+
|
|
53
|
+
Defaults: `GH_TOKEN` (git pushes and `gh` both use it) from the store, plus the agent's login (below). Init asks for a [fine-grained personal access token](https://github.com/settings/personal-access-tokens/new) limited to the repos boxes work on, with Contents and Pull requests read and write (Metadata read comes with them), so a box cannot touch the rest of the account. `gh auth token` (every repo the user can reach) is used only if the user picks it in init and confirms a warning. Either way, turn on branch protection for `main` so a box can only reach it through a reviewed PR.
|
|
54
|
+
|
|
55
|
+
### Agent logins
|
|
56
|
+
|
|
57
|
+
Each agent preset lists the login routes it offers (`login.routes`) and the one boxes use (`login.use`). Init asks on a first pick; pick another for one agent in `~/.bottleship/config.json`, e.g. `"agents": { "codex": { "login": { "use": "token" } } }`.
|
|
58
|
+
|
|
59
|
+
- `token`: a long-lived token or API key in bottleship's store, set as an env var in every box.
|
|
60
|
+
- `device`: the agent logs in inside each box. `new` (and `ask`, if the box's login expired) starts the login and returns its URL and code for the user to approve in a browser; the task starts once they do. Nothing is stored on this machine.
|
|
61
|
+
- `none`: no login.
|
|
62
|
+
|
|
63
|
+
| Agent | Routes (default first) | Why |
|
|
64
|
+
|---|---|---|
|
|
65
|
+
| `claude` | token: `CLAUDE_CODE_OAUTH_TOKEN` from `claude setup-token` | Anthropic's documented way to use a Claude subscription headless ([docs](https://code.claude.com/docs/en/authentication)). Boxes never get `ANTHROPIC_API_KEY` unless you configure it, as it would win over the token. |
|
|
66
|
+
| `codex` | device: `codex login --device-auth` per box; token: `CODEX_API_KEY` (an OpenAI API key) | One ChatGPT login (`auth.json`) must not be shared across machines or parallel runs ([docs](https://learn.chatgpt.com/docs/auth/ci-cd-auth)), so each box logs in itself. Once, first: in ChatGPT, Settings, Security, turn on device code login for Codex. |
|
|
67
|
+
| `cursor` (no reply tested yet) | token: `CURSOR_API_KEY` from https://cursor.com/dashboard/api (bills to your Cursor plan); device: `cursor-agent login` per box | API keys are Cursor's documented headless login ([docs](https://cursor.com/docs/cli/reference/authentication)). |
|
|
68
|
+
| `opencode` | none: free models; device: a ChatGPT plan via `opencode auth login openai --method chatgpt-headless` per box; token: `OPENCODE_API_KEY` (OpenCode Zen), `ANTHROPIC_API_KEY` or `OPENAI_API_KEY` | Per-box logins and provider API keys ([docs](https://opencode.ai/docs/)). Never a Claude subscription: Anthropic allows those only in Claude Code. |
|
|
69
|
+
|
|
70
|
+
On `new`, the `.env*` files at the root of the current repo (not `*.example`, `*.sample`, `*.template`) are copied into the box workspace at the same paths.
|
|
71
|
+
|
|
72
|
+
Real tokens are in the box, so the agent there can do anything those tokens allow: keep them narrow (a fine-grained `GH_TOKEN`, branch protection on `main`). A credential proxy that keeps them out of boxes is not built yet.
|
|
73
|
+
|
|
74
|
+
## Providers
|
|
75
|
+
|
|
76
|
+
Where boxes run. Pick one with `bottleship init` (or `--provider <name>`, and `new --provider <name>` per box):
|
|
77
|
+
|
|
78
|
+
- `docker`: containers on this machine, from the box template (see Templates); without one, from `image` (default exeuntu).
|
|
79
|
+
- `exe`: [exe.dev](https://exe.dev) VMs over ssh; needs `ssh exe.dev` working. No box template: exe.dev boots VMs only from registry images, so boxes install the agent at start.
|
|
80
|
+
- `e2b` (untested): [e2b.dev](https://e2b.dev) cloud sandboxes; API key from https://e2b.dev/dashboard.
|
|
81
|
+
- `daytona` (untested): [daytona.io](https://daytona.io) cloud sandboxes; API key from https://app.daytona.io/dashboard/keys.
|
|
82
|
+
- `modal` (untested): [modal.com](https://modal.com) cloud sandboxes; token id and secret from `modal token new` or https://modal.com/settings/tokens.
|
|
83
|
+
- `ssh` (untested on a real host): any machine you can `ssh` into without a password prompt; each box is a directory `~/bottleship-boxes/<id>` there, used as its `$HOME`. No isolation: agents run as that user with permission prompts off. Options: `host` (required, anything ssh accepts, e.g. a `~/.ssh/config` alias; `init --host`), `root`.
|
|
84
|
+
- `local`: git worktrees on this machine. The box workspace is a `git worktree` of your local repo on branch `bottleship/<id>` (from your `HEAD` commit, without uncommitted changes); `rm` removes the worktree and keeps the branch. No isolation: agents run as you, with your files and logins, with permission prompts off. Boxes stop when the laptop sleeps.
|
|
85
|
+
- `custom`: your company's sandboxes, see below.
|
|
86
|
+
|
|
87
|
+
`local` and `ssh` need the user's OK in `bottleship init` (stored as `acceptNoIsolation` on the provider); without it `new` refuses them. `rm` there kills the box's processes: those marked with the box (`BOTTLESHIP_BOX`), and on macOS those in the box's process groups whose working directory is in the box.
|
|
88
|
+
|
|
89
|
+
Provider API keys are read on this machine only and never reach a box; `bottleship init` asks for them.
|
|
90
|
+
|
|
91
|
+
"Untested" means built from the provider's docs and SDK and type-checked, with live tests in `test/live/` that run when the provider's keys are set, but never run against the real service yet.
|
|
92
|
+
|
|
93
|
+
## Custom providers
|
|
94
|
+
|
|
95
|
+
For a company sandbox system with only an API or a CLI. A human runs `bottleship init` and picks `custom` (an agent: `bottleship init --yes --provider custom --name <name>`). That copies [presets/providers/http.example.ts](presets/providers/http.example.ts) to `~/.bottleship/providers/<name>.ts` and adds `"<name>": { "module": "providers/<name>.ts" }` under `providers` in `~/.bottleship/config.json`. Then, as the agent:
|
|
96
|
+
|
|
97
|
+
1. Replace every TODO in `~/.bottleship/providers/<name>.ts` with the real API calls, or, if the system has a CLI, run its commands with `kit.run`. Find out how the system creates a sandbox, runs a command in it, and deletes it (its docs, its CLI's `--help`, or ask the user).
|
|
98
|
+
2. Put the module's options in its config entry, with secrets as secret sources, never as plain values:
|
|
99
|
+
|
|
100
|
+
```json
|
|
101
|
+
"providers": {
|
|
102
|
+
"corp": { "module": "providers/corp.ts", "baseUrl": "https://sandboxes.corp.example/api", "apiKey": { "store": "corp-sandbox" } }
|
|
103
|
+
}
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
3. Ask the user to run `bottleship init` in their terminal. It asks for missing secrets with a hidden prompt, then checks the provider end to end: create a box, run a command, set up the agent, get one reply, destroy the box. Fix what fails and run it again.
|
|
107
|
+
|
|
108
|
+
The contract is `Provider` in [src/types.ts](src/types.ts): `create` (resolves once the box can run commands), `exec` (one-shot `bash -c`, with stdin), `destroy`, and optionally `status`, `openStream` (a binary-clean long-lived exec, for `exec -i`) and `template` (build or find a box template from a `TemplateSpec`, see Templates; `create` then gets its reference). Boxes must stay up until `rm`, since the agent runs in the background between calls. Node runs the `.ts` module directly, no build step. For a system whose CLI fits in shell templates, `"module": "builtin:command"` needs no code, see [presets/providers/command.ts](presets/providers/command.ts).
|
|
109
|
+
|
|
110
|
+
## Usage
|
|
111
|
+
|
|
112
|
+
Every command prints JSON; errors are `{ "error", "message", "fix" }` with exit code 1.
|
|
113
|
+
|
|
114
|
+
```sh
|
|
115
|
+
bottleship new "<task>" [--repo owner/name | --no-repo] [--provider P] [--agent A]
|
|
116
|
+
# { "id": "docker:bs-fix-login-bug-3fa2", "provider": "docker", "agent": "claude", "repo": "acme/web", "next": "..." }
|
|
117
|
+
# device login route: { "id", "provider", "agent", "repo", "login": { "url", "code"?, "agent" }, "next": "show the user this URL and code; ..." }
|
|
118
|
+
|
|
119
|
+
bottleship status <id> [--wait 600] [--lines 40]
|
|
120
|
+
# { "id", "box": "running", "agent": "running" | "exited" | "stopped", "exitCode"?, "log": "<last lines>", "next" }
|
|
121
|
+
# { "id", "box": "running", "agent": "waiting_for_login", "login": { "url", "code"?, "agent" }, "log", "next" }
|
|
122
|
+
# { "id", "box": "running", "agent": "login_failed", "exitCode", "log": "<the login's output>", "next" }
|
|
123
|
+
# { "id", "box": "gone", "next" }
|
|
124
|
+
|
|
125
|
+
bottleship ask <id> "<follow-up>" # continues the same conversation; refused while the agent runs
|
|
126
|
+
# { "id", "agent": "running", "next" }, or with an expired device login { "id", "agent": "waiting_for_login", "login", "next" }
|
|
127
|
+
bottleship exec <id> -- git log --oneline -5
|
|
128
|
+
bottleship exec <id> -- "npm test 2>&1 | tail -20" # one argument is a script, several are argv
|
|
129
|
+
bottleship exec -i <id> -- <cmd> # streams stdin/stdout, e.g. for an IDE attaching over ACP
|
|
130
|
+
bottleship rm <id>
|
|
131
|
+
```
|
|
132
|
+
|
|
133
|
+
`new` returns as soon as the agent has started, or, on the device route, once the login shows its URL: show the user the URL and code, and the task starts after they approve. A login nobody approves within 15 minutes fails (`login_failed`); `ask` then logs in again and, if the task never ran, runs the message as the task. The repo defaults to the current directory's `origin` (with the `local` provider, a worktree of this checkout). The box gets the env, the agent's login, the git identity, a git credential helper that uses `$GH_TOKEN`, and the agent's install commands; then the agent runs detached in `~/workspace` with your prompt.
|
|
134
|
+
|
|
135
|
+
Agents are configured as shell templates (`{prompt}` is filled in shell-quoted):
|
|
136
|
+
|
|
137
|
+
```json
|
|
138
|
+
"agents": {
|
|
139
|
+
"claude": {
|
|
140
|
+
"install": ["command -v claude >/dev/null || npm install -g --silent @anthropic-ai/claude-code"],
|
|
141
|
+
"run": "claude -p {prompt} --dangerously-skip-permissions",
|
|
142
|
+
"resume": "claude -p {prompt} --continue --dangerously-skip-permissions",
|
|
143
|
+
"env": { "IS_SANDBOX": "1" },
|
|
144
|
+
"unset": ["ANTHROPIC_API_KEY", "ANTHROPIC_AUTH_TOKEN"],
|
|
145
|
+
"login": { "routes": ["token"], "use": "token", "token": { "env": "CLAUDE_CODE_OAUTH_TOKEN", "store": "claude", "command": "claude setup-token" } }
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
```
|
|
149
|
+
|
|
150
|
+
`unset` drops env vars from the box unless you configure them. A device login is `"device": { "command", "status", "setup"?, "timeout"? }`: `command` prints a URL (and code) and exits 0 once approved, `status` exits 0 when the box is logged in. An entry for a builtin agent only needs the fields it changes. The presets are in [presets/agents](presets/agents).
|
|
151
|
+
|
|
152
|
+
If a box has no Node, bottleship installs it into `~/.bottleship/node` without sudo.
|
|
153
|
+
|
|
154
|
+
## Templates
|
|
155
|
+
|
|
156
|
+
Boxes start from a prebuilt template when the provider supports one (`docker` builds a local image, `e2b` a template, `daytona` a snapshot, `modal` a named image; `exe`, `local`, `ssh` and custom ones have none). `bottleship init` builds it in the live check ("build the box template", minutes the first time) and records it under `templates.<provider>` in `~/.bottleship/config.json` as `{ "name", "ref" }`.
|
|
157
|
+
|
|
158
|
+
The template is one Dockerfile: `node:22-bookworm-slim` with git, curl, ca-certificates, gh and ripgrep, a non-root user `box` (uid 1000, home `/home/box`) whose npm globals go to `~/.bottleship/npm`, plus the configured agent's `install` commands run as `box`. Its name is `bottleship-<hash of the Dockerfile>`, so changing the agent, its install commands or bottleship's Dockerfile makes a new one, built on the next `init`; a rerun with no changes finds the existing one. Templates never hold secrets or logins; those go into each box at start.
|
|
159
|
+
|
|
160
|
+
Only the configured agent is baked in. `new --agent <other>` still uses the template and installs the other agent at start. When the recorded template does not match the current config, `new` starts the box without one (setup installs what it lacks, only slower) and says so in `warning`; run `bottleship init` to rebuild. Providers without `template()` (`local`, `ssh`, custom ones) set boxes up at start as before.
|
|
161
|
+
|
|
162
|
+
## Instructions for your agent
|
|
163
|
+
|
|
164
|
+
`bottleship init` prints this block; add it to your agent's global instructions (e.g. `~/.claude/CLAUDE.md`, `~/.codex/AGENTS.md`).
|
|
165
|
+
|
|
166
|
+
```md
|
|
167
|
+
## Remote agents with bottleship
|
|
168
|
+
|
|
169
|
+
You can run other coding agents in remote sandboxes ("boxes") with the `bottleship` CLI, like subagents. Every command prints JSON; errors carry a `fix`.
|
|
170
|
+
|
|
171
|
+
- Start remote work with `bottleship new "<prompt>"` from inside the repo (or `--repo owner/name`). Write a complete prompt: the task, the context it needs, and what done means, including "work on branch <name>, commit and push as you go, and open a PR when done". The box agent sees nothing of this conversation.
|
|
172
|
+
- Treat each box as a subagent you manage. `new` returns an id; keep track of your ids yourself, bottleship keeps no list.
|
|
173
|
+
- If `new` or `ask` returns a `login`, show the user its URL and code; the box agent starts once they approve it in their browser.
|
|
174
|
+
- `bottleship status <id>` shows whether the agent is still running and the tail of its output; `--wait <seconds>` blocks until it finishes.
|
|
175
|
+
- `bottleship ask <id> "<follow-up>"` continues the same conversation once the agent has finished.
|
|
176
|
+
- `bottleship exec <id> -- <cmd>` inspects the box (git log, test output, files).
|
|
177
|
+
- `bottleship rm <id>` when the work is merged or abandoned. If a box is gone, start a new one on the pushed branch.
|
|
178
|
+
- If the user wants to watch or steer a box from their IDE, find out how their IDE attaches to an agent over a command (ACP) and give it `bottleship exec -i <id> -- <the agent's ACP command>`.
|
|
179
|
+
- Secrets: never ask the user to paste a secret to you. If bottleship reports one missing, ask them to run `bottleship init` in their terminal.
|
|
180
|
+
```
|
|
181
|
+
|
|
182
|
+
## Development
|
|
183
|
+
|
|
184
|
+
```sh
|
|
185
|
+
npm test # build, then all tests
|
|
186
|
+
npx vitest run test/cli.test.ts # one file (needs a build first)
|
|
187
|
+
grep -rn 'ponytail:' src presets # deliberate shortcuts and their upgrade paths
|
|
188
|
+
```
|
package/bin/bottleship
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
// A provider driven by shell command templates, for sandboxes that have a CLI.
|
|
2
|
+
// Values substituted into a template are shell-quoted: {script}, {id}.
|
|
3
|
+
// {name!} inserts the raw value, for URLs and the like.
|
|
4
|
+
//
|
|
5
|
+
// options:
|
|
6
|
+
// create: creates box {id}; may print JSON {"id": ...} or a bare id (else {id} is used)
|
|
7
|
+
// exec: runs {script} with bash in box {id}; must pass stdin/stdout through
|
|
8
|
+
// stream: (optional) like exec but kept open, binary-clean (no TTY); defaults to exec
|
|
9
|
+
// destroy: removes box {id}
|
|
10
|
+
// status: (optional) exits 0 if box {id} is running
|
|
11
|
+
//
|
|
12
|
+
// example:
|
|
13
|
+
// "corp": { "module": "builtin:command",
|
|
14
|
+
// "create": "corpbox create --name {id}",
|
|
15
|
+
// "exec": "corpbox ssh {id} -- bash -c {script}",
|
|
16
|
+
// "destroy": "corpbox rm {id}" }
|
|
17
|
+
import { fill, run, spawnStream } from "./util.js";
|
|
18
|
+
const command = ({ options }) => {
|
|
19
|
+
for (const k of ["create", "exec", "destroy"])
|
|
20
|
+
if (!options[k])
|
|
21
|
+
throw new Error(`command provider: missing "${k}" template`);
|
|
22
|
+
const sh = (tpl, values, opts) => run("sh", ["-c", fill(tpl, values)], opts);
|
|
23
|
+
return {
|
|
24
|
+
async create({ id }) {
|
|
25
|
+
const out = (await sh(options.create, { id })).stdout.trim();
|
|
26
|
+
try {
|
|
27
|
+
return { id: JSON.parse(out).id ?? id };
|
|
28
|
+
}
|
|
29
|
+
catch {
|
|
30
|
+
return { id: out && !/\s/.test(out) ? out : id };
|
|
31
|
+
}
|
|
32
|
+
},
|
|
33
|
+
exec: (id, script, { stdin } = {}) => sh(options.exec, { id, script }, { stdin, check: false }),
|
|
34
|
+
openStream: (id, script) => spawnStream("sh", ["-c", fill(options.stream ?? options.exec, { id, script })]),
|
|
35
|
+
...(options.status && { status: async (id) => ((await sh(options.status, { id }, { check: false })).code === 0 ? "running" : "gone") }),
|
|
36
|
+
destroy: (id) => sh(options.destroy, { id }, { check: false }),
|
|
37
|
+
};
|
|
38
|
+
};
|
|
39
|
+
export default command;
|
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
// Daytona sandboxes through @daytonaio/sdk. A box is a sandbox; its id is the sandbox id.
|
|
2
|
+
//
|
|
3
|
+
// options: {
|
|
4
|
+
// apiKey (secret source, e.g. { "store": "daytona" }), apiUrl?, target? (region),
|
|
5
|
+
// snapshot? (default: Daytona's default snapshot, user "daytona" with bash, git, curl and a writable $HOME),
|
|
6
|
+
// image? (any registry image instead of a snapshot; it needs bash, git, curl, base64 and a writable $HOME),
|
|
7
|
+
// autoStopMinutes? (default 0 = never)
|
|
8
|
+
// }
|
|
9
|
+
//
|
|
10
|
+
// template() builds a Daytona snapshot from the Dockerfile, in the organization's default region or `target`; COPY from a local context does not work since spec Dockerfiles have none.
|
|
11
|
+
import { randomBytes } from "node:crypto";
|
|
12
|
+
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
|
13
|
+
import { tmpdir } from "node:os";
|
|
14
|
+
import { join } from "node:path";
|
|
15
|
+
import { PassThrough, Writable } from "node:stream";
|
|
16
|
+
import { shq } from "./util.js";
|
|
17
|
+
const GONE = new Set(["destroyed", "destroying", "error", "build_failed"]);
|
|
18
|
+
const RESTARTABLE = new Set(["stopped", "archived", "paused"]);
|
|
19
|
+
const hex = () => randomBytes(6).toString("hex");
|
|
20
|
+
const SNAPSHOT_BUSY = new Set(["pending", "building", "pulling", "snapshotting", "removing"]);
|
|
21
|
+
const daytona = async ({ options, secret, log }) => {
|
|
22
|
+
if (!options.apiKey)
|
|
23
|
+
throw new Error('daytona needs an apiKey option, e.g. "apiKey": { "store": "daytona" }');
|
|
24
|
+
// loaded here so other providers never pay for the SDK
|
|
25
|
+
const { Daytona, DaytonaNotFoundError, Image } = await import("@daytonaio/sdk");
|
|
26
|
+
const client = new Daytona({ apiKey: secret(options.apiKey), apiUrl: options.apiUrl, target: options.target });
|
|
27
|
+
// ponytail: never auto-stops, because Daytona counts a running agent as idle and would stop it mid-task; idle boxes bill until `bottleship rm`. Set autoStopMinutes to cap that, a stopped box restarts on the next exec but its agent is gone
|
|
28
|
+
const autoStopInterval = options.autoStopMinutes ?? 0;
|
|
29
|
+
const cache = new Map();
|
|
30
|
+
const lookup = async (id) => {
|
|
31
|
+
try {
|
|
32
|
+
const sb = await client.get(id);
|
|
33
|
+
cache.set(id, sb);
|
|
34
|
+
return sb;
|
|
35
|
+
}
|
|
36
|
+
catch (e) {
|
|
37
|
+
if (e instanceof DaytonaNotFoundError)
|
|
38
|
+
return undefined;
|
|
39
|
+
throw e;
|
|
40
|
+
}
|
|
41
|
+
};
|
|
42
|
+
// a sandbox that can run commands, started again if it was stopped or archived
|
|
43
|
+
async function started(id) {
|
|
44
|
+
const cached = cache.get(id);
|
|
45
|
+
if (cached?.state === "started")
|
|
46
|
+
return cached;
|
|
47
|
+
const sb = await lookup(id);
|
|
48
|
+
if (!sb || GONE.has(sb.state ?? ""))
|
|
49
|
+
throw new Error(`daytona sandbox ${id} is gone`);
|
|
50
|
+
if (RESTARTABLE.has(sb.state ?? ""))
|
|
51
|
+
await sb.start(600);
|
|
52
|
+
else
|
|
53
|
+
await sb.waitUntilStarted(600);
|
|
54
|
+
return sb;
|
|
55
|
+
}
|
|
56
|
+
// drops the cached sandbox on failure, so the next call looks its state up again
|
|
57
|
+
async function withBox(id, fn) {
|
|
58
|
+
try {
|
|
59
|
+
return await fn(await started(id));
|
|
60
|
+
}
|
|
61
|
+
catch (e) {
|
|
62
|
+
cache.delete(id);
|
|
63
|
+
throw e;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
// the snapshot's state, or undefined once there is none by that name
|
|
67
|
+
const snapshotState = (name) => client.snapshot.get(name).then((s) => s.state, (e) => {
|
|
68
|
+
if (e instanceof DaytonaNotFoundError)
|
|
69
|
+
return undefined;
|
|
70
|
+
throw e;
|
|
71
|
+
});
|
|
72
|
+
// ponytail: a snapshot another init is still building is polled for at most 30 minutes; raise it if agent installs get that slow
|
|
73
|
+
async function settled(name) {
|
|
74
|
+
for (let i = 0; i < 900; i++) {
|
|
75
|
+
const state = await snapshotState(name);
|
|
76
|
+
if (!state || !SNAPSHOT_BUSY.has(state))
|
|
77
|
+
return state;
|
|
78
|
+
await new Promise((res) => setTimeout(res, 2000));
|
|
79
|
+
}
|
|
80
|
+
throw new Error(`daytona snapshot ${name} is still not active after 30 minutes`);
|
|
81
|
+
}
|
|
82
|
+
return {
|
|
83
|
+
// snapshots belong to the organization, so a name cannot resolve to another organization's snapshot
|
|
84
|
+
async template({ name, dockerfile }) {
|
|
85
|
+
let state = await settled(name);
|
|
86
|
+
// Daytona deactivates a snapshot after 2 weeks without use
|
|
87
|
+
if (state === "inactive") {
|
|
88
|
+
await client.snapshot.activate(name);
|
|
89
|
+
state = await settled(name);
|
|
90
|
+
}
|
|
91
|
+
if (state === "active")
|
|
92
|
+
return name;
|
|
93
|
+
// a failed build keeps its name taken, so it goes before the rebuild
|
|
94
|
+
if (state) {
|
|
95
|
+
await client.snapshot.delete(name);
|
|
96
|
+
await settled(name);
|
|
97
|
+
}
|
|
98
|
+
// the SDK reads a Dockerfile only from disk
|
|
99
|
+
const dir = mkdtempSync(join(tmpdir(), "bottleship-daytona-"));
|
|
100
|
+
try {
|
|
101
|
+
writeFileSync(join(dir, "Dockerfile"), dockerfile);
|
|
102
|
+
await client.snapshot.create({ name, image: Image.fromDockerfile(join(dir, "Dockerfile")) }, { onLogs: (l) => log(l) });
|
|
103
|
+
}
|
|
104
|
+
finally {
|
|
105
|
+
rmSync(dir, { recursive: true, force: true });
|
|
106
|
+
}
|
|
107
|
+
return name;
|
|
108
|
+
},
|
|
109
|
+
async create({ id, template }) {
|
|
110
|
+
const base = { name: id, labels: { bottleship: "1" }, autoStopInterval };
|
|
111
|
+
const sb = options.image && !template
|
|
112
|
+
? await client.create({ ...base, image: options.image }, { timeout: 600 })
|
|
113
|
+
: await client.create({ ...base, snapshot: template ?? options.snapshot }, { timeout: 300 });
|
|
114
|
+
cache.set(sb.id, sb);
|
|
115
|
+
return { id: sb.id };
|
|
116
|
+
},
|
|
117
|
+
// the one-shot API merges stdout and stderr and takes no stdin, so stdin goes up as a file and both outputs come back base64'd
|
|
118
|
+
exec: (id, script, { stdin } = {}) => withBox(id, async (sb) => {
|
|
119
|
+
// a fresh 0700 dir, since stdin carries secrets (env.sh) and output may too
|
|
120
|
+
const d = `/tmp/bottleship-${hex()}`;
|
|
121
|
+
if (stdin !== undefined) {
|
|
122
|
+
await sb.fs.createFolder(d, "700");
|
|
123
|
+
await sb.fs.uploadFile(Buffer.from(stdin), `${d}/in`);
|
|
124
|
+
}
|
|
125
|
+
const r = await sb.process.executeCommand(`${stdin !== undefined ? "" : `mkdir -m 700 ${d} || exit 1; `}bash -c ${shq(script)} < ${stdin !== undefined ? `${d}/in` : "/dev/null"} > ${d}/out 2> ${d}/err; echo $?; base64 < ${d}/out | tr -d '\\n'; echo; base64 < ${d}/err | tr -d '\\n'; echo; rm -rf ${d}`);
|
|
126
|
+
const [code, out, err] = r.result.split("\n");
|
|
127
|
+
if (r.exitCode !== 0 || !/^\d+$/.test(code ?? ""))
|
|
128
|
+
throw new Error(`daytona exec in ${id} failed (exit ${r.exitCode}): ${r.result.slice(-500)}`);
|
|
129
|
+
return { code: Number(code), stdout: Buffer.from(out ?? "", "base64").toString(), stderr: Buffer.from(err ?? "", "base64").toString() };
|
|
130
|
+
}),
|
|
131
|
+
// a session command: stdin goes as base64 lines decoded in the box, so it stays binary-clean and "." ends it
|
|
132
|
+
// ponytail: Daytona relays output line by line as UTF-8 text, so a partial line shows once it ends and binary output is mangled; a PTY or a raw socket would fix that
|
|
133
|
+
openStream: (id, script) => withBox(id, async (sb) => {
|
|
134
|
+
const session = `bottleship-${hex()}`;
|
|
135
|
+
await sb.process.createSession(session);
|
|
136
|
+
const wrapper = `exec 3< <(while IFS= read -r l; do [ "$l" = . ] && break; printf %s "$l" | base64 -d; done)
|
|
137
|
+
bash -c ${shq(script)} <&3; c=$?; kill $! 2>/dev/null; exit $c`;
|
|
138
|
+
const { cmdId } = await sb.process.executeSessionCommand(session, { command: `bash -c ${shq(wrapper)}`, runAsync: true, suppressInputEcho: true });
|
|
139
|
+
const send = (line) => sb.process.sendSessionCommandInput(session, cmdId, line).catch(() => { });
|
|
140
|
+
const stdout = new PassThrough();
|
|
141
|
+
const stderr = new PassThrough();
|
|
142
|
+
const closers = [];
|
|
143
|
+
const end = () => sb.process.deleteSession(session).catch(() => { });
|
|
144
|
+
const stdin = new Writable({
|
|
145
|
+
write: (chunk, _enc, cb) => void send(chunk.toString("base64") + "\n").then(() => cb()),
|
|
146
|
+
final: (cb) => void send(".\n").then(() => cb()),
|
|
147
|
+
});
|
|
148
|
+
sb.process
|
|
149
|
+
.getSessionCommandLogs(session, cmdId, (s) => stdout.write(s), (s) => stderr.write(s))
|
|
150
|
+
.catch(() => { })
|
|
151
|
+
.then(() => sb.process.getSessionCommand(session, cmdId).then((c) => c.exitCode ?? null, () => null))
|
|
152
|
+
.then((code) => {
|
|
153
|
+
stdout.end();
|
|
154
|
+
stderr.end();
|
|
155
|
+
closers.forEach((cb) => cb(code));
|
|
156
|
+
void end();
|
|
157
|
+
});
|
|
158
|
+
return { stdin, stdout, stderr, close: () => void end(), onClose: (cb) => closers.push(cb) };
|
|
159
|
+
}),
|
|
160
|
+
// a stopped or archived sandbox still counts as running, since exec starts it again
|
|
161
|
+
async status(id) {
|
|
162
|
+
const sb = await lookup(id);
|
|
163
|
+
return sb && !GONE.has(sb.state ?? "") ? "running" : "gone";
|
|
164
|
+
},
|
|
165
|
+
async destroy(id) {
|
|
166
|
+
const sb = await lookup(id);
|
|
167
|
+
cache.delete(id);
|
|
168
|
+
if (sb && !GONE.has(sb.state ?? ""))
|
|
169
|
+
await sb.delete(120);
|
|
170
|
+
},
|
|
171
|
+
};
|
|
172
|
+
};
|
|
173
|
+
export default daytona;
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
// Local Docker as a provider. Boxes are containers that live until `bottleship rm`.
|
|
2
|
+
// The template is a local image tagged with its spec name, running as its own non-root user box.
|
|
3
|
+
//
|
|
4
|
+
// options: { image? (used without a template; default exeuntu, a dev image with git, curl and a non-root user), user? }
|
|
5
|
+
import { run, spawnStream } from "./util.js";
|
|
6
|
+
const docker = ({ options }) => {
|
|
7
|
+
const image = options.image ?? "ghcr.io/boldsoftware/exeuntu:latest";
|
|
8
|
+
const user = options.user ?? (image.includes("exeuntu") ? "exedev" : undefined);
|
|
9
|
+
// execs run as the container's user, set at create
|
|
10
|
+
const execArgs = (id, script) => ["exec", "-i", id, "bash", "-c", script];
|
|
11
|
+
return {
|
|
12
|
+
async template({ name, dockerfile }) {
|
|
13
|
+
if ((await run("docker", ["image", "inspect", name], { check: false })).code !== 0)
|
|
14
|
+
await run("docker", ["build", "-q", "-t", name, "-"], { stdin: dockerfile });
|
|
15
|
+
return name;
|
|
16
|
+
},
|
|
17
|
+
async create({ id, template }) {
|
|
18
|
+
const from = template ? [template] : [...(user ? ["--user", user] : []), image];
|
|
19
|
+
await run("docker", ["run", "-d", "--name", id, "--label", "bottleship=1", "--entrypoint", "sleep", ...from, "infinity"]);
|
|
20
|
+
return { id };
|
|
21
|
+
},
|
|
22
|
+
exec: (id, script, { stdin } = {}) => run("docker", execArgs(id, script), { stdin, check: false }),
|
|
23
|
+
openStream: (id, script) => spawnStream("docker", execArgs(id, script)),
|
|
24
|
+
async status(id) {
|
|
25
|
+
const r = await run("docker", ["inspect", "-f", "{{.State.Running}}", id], { check: false });
|
|
26
|
+
return r.code === 0 && r.stdout.trim() === "true" ? "running" : "gone";
|
|
27
|
+
},
|
|
28
|
+
destroy: (id) => run("docker", ["rm", "-f", id], { check: false }),
|
|
29
|
+
};
|
|
30
|
+
};
|
|
31
|
+
export default docker;
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
// E2B: Firecracker sandboxes over the e2b SDK. The default "base" template is Debian with bash, git, curl,
|
|
2
|
+
// node and gh, run as a user with a writable $HOME.
|
|
3
|
+
//
|
|
4
|
+
// template() builds an E2B template from the Dockerfile; the E2B builder ignores SHELL, and COPY or ADD from a local context does not work since spec Dockerfiles have none.
|
|
5
|
+
//
|
|
6
|
+
// options: { apiKey? (secret source, else E2B_API_KEY), template? (default "base"), timeoutMinutes? (default 60) }
|
|
7
|
+
import { PassThrough, Writable } from "node:stream";
|
|
8
|
+
const e2b = async ({ options, secret, log }) => {
|
|
9
|
+
const { Sandbox, Template, ApiClient, ConnectionConfig, CommandExitError, NotFoundError } = await import("e2b");
|
|
10
|
+
const apiKey = options.apiKey ? secret(options.apiKey) : undefined;
|
|
11
|
+
// ponytail: a box dies this long after bottleship last touched it, and E2B caps a box's whole life at 1h (Hobby) or 24h (Pro); pause/resume lifecycle if agents run longer
|
|
12
|
+
const timeoutMs = (options.timeoutMinutes ?? 60) * 60_000;
|
|
13
|
+
// connecting also extends the box's timeout, so every exec and status keeps a box someone checks on alive
|
|
14
|
+
const boxes = new Map();
|
|
15
|
+
async function box(id) {
|
|
16
|
+
const hit = boxes.get(id);
|
|
17
|
+
if (hit && Date.now() - hit.at < 60_000)
|
|
18
|
+
return hit.sbx;
|
|
19
|
+
const sbx = await Sandbox.connect(id, { apiKey, timeoutMs });
|
|
20
|
+
boxes.set(id, { sbx, at: Date.now() });
|
|
21
|
+
return sbx;
|
|
22
|
+
}
|
|
23
|
+
const result = (r) => ({ code: r.exitCode, stdout: r.stdout, stderr: r.stderr });
|
|
24
|
+
// a non-zero exit is a result, not an error
|
|
25
|
+
const settle = (p) => p.then(result, (e) => {
|
|
26
|
+
if (e instanceof CommandExitError)
|
|
27
|
+
return result(e);
|
|
28
|
+
throw e;
|
|
29
|
+
});
|
|
30
|
+
// the id of this team's template named name with a finished build; a bare name can also resolve to another team's public template, so names are never handed to create
|
|
31
|
+
async function ownTemplate(name) {
|
|
32
|
+
const { api } = new ApiClient(new ConnectionConfig({ apiKey }));
|
|
33
|
+
let nextToken;
|
|
34
|
+
do {
|
|
35
|
+
const res = await api.GET("/v2/templates", { params: { query: { limit: 100, nextToken } } });
|
|
36
|
+
if (!res.data)
|
|
37
|
+
throw new Error(`e2b could not list templates: ${res.response.status} ${JSON.stringify(res.error)}`);
|
|
38
|
+
const hit = res.data.find((t) => !t.isDefault && t.buildStatus === "ready" && t.names.some((n) => n === name || n.endsWith(`/${name}`)));
|
|
39
|
+
if (hit)
|
|
40
|
+
return hit.templateID;
|
|
41
|
+
nextToken = res.response.headers.get("X-Next-Token") || undefined;
|
|
42
|
+
} while (nextToken);
|
|
43
|
+
}
|
|
44
|
+
return {
|
|
45
|
+
// ponytail: two inits racing on one name both build, E2B keeps the later build; fine since both come from the same Dockerfile
|
|
46
|
+
async template({ name, dockerfile }) {
|
|
47
|
+
const found = await ownTemplate(name);
|
|
48
|
+
if (found)
|
|
49
|
+
return found;
|
|
50
|
+
const built = await Template.build(Template().fromDockerfile(dockerfile), name, { apiKey, onBuildLogs: (e) => log(String(e)) });
|
|
51
|
+
return built.templateId;
|
|
52
|
+
},
|
|
53
|
+
// the bottleship id goes into metadata; E2B picks the sandbox id
|
|
54
|
+
async create({ id, template }) {
|
|
55
|
+
const sbx = await Sandbox.create(template ?? options.template ?? "base", { apiKey, timeoutMs, metadata: { bottleship: id } });
|
|
56
|
+
await sbx.commands.run("true");
|
|
57
|
+
boxes.set(sbx.sandboxId, { sbx, at: Date.now() });
|
|
58
|
+
return { id: sbx.sandboxId };
|
|
59
|
+
},
|
|
60
|
+
// the SDK runs `bash -l -c <script>`; timeoutMs 0 lifts its 60 second command limit
|
|
61
|
+
async exec(id, script, { stdin } = {}) {
|
|
62
|
+
const sbx = await box(id);
|
|
63
|
+
if (stdin === undefined)
|
|
64
|
+
return settle(sbx.commands.run(script, { timeoutMs: 0 }));
|
|
65
|
+
const h = await sbx.commands.run(script, { background: true, stdin: true, timeoutMs: 0 });
|
|
66
|
+
if (stdin.length)
|
|
67
|
+
await h.sendStdin(stdin);
|
|
68
|
+
await h.closeStdin();
|
|
69
|
+
return settle(h.wait());
|
|
70
|
+
},
|
|
71
|
+
// ponytail: the SDK hands output over as UTF-8 text, so binary stdout gets mangled; fine for text protocols
|
|
72
|
+
async openStream(id, script) {
|
|
73
|
+
const sbx = await box(id);
|
|
74
|
+
const stdout = new PassThrough();
|
|
75
|
+
const stderr = new PassThrough();
|
|
76
|
+
const h = await sbx.commands.run(script, {
|
|
77
|
+
background: true,
|
|
78
|
+
stdin: true,
|
|
79
|
+
timeoutMs: 0,
|
|
80
|
+
onStdout: (d) => void stdout.write(d),
|
|
81
|
+
onStderr: (d) => void stderr.write(d),
|
|
82
|
+
});
|
|
83
|
+
let code;
|
|
84
|
+
const closers = [];
|
|
85
|
+
const done = h.wait().then((r) => r.exitCode, (e) => (e instanceof CommandExitError ? e.exitCode : null));
|
|
86
|
+
void done.then((c) => {
|
|
87
|
+
code = c;
|
|
88
|
+
stdout.end();
|
|
89
|
+
stderr.end();
|
|
90
|
+
closers.splice(0).forEach((cb) => cb(c));
|
|
91
|
+
});
|
|
92
|
+
// Writable runs one write at a time, so stdin arrives in order
|
|
93
|
+
const stdin = new Writable({
|
|
94
|
+
write: (chunk, _enc, cb) => void h.sendStdin(chunk).then(() => cb(), cb),
|
|
95
|
+
final: (cb) => void h.closeStdin().then(() => cb(), cb),
|
|
96
|
+
});
|
|
97
|
+
stdin.on("error", () => { });
|
|
98
|
+
return {
|
|
99
|
+
stdin,
|
|
100
|
+
stdout,
|
|
101
|
+
stderr,
|
|
102
|
+
close: () => void h.kill().catch(() => { }),
|
|
103
|
+
onClose: (cb) => (code === undefined ? closers.push(cb) : cb(code)),
|
|
104
|
+
};
|
|
105
|
+
},
|
|
106
|
+
async status(id) {
|
|
107
|
+
boxes.delete(id);
|
|
108
|
+
try {
|
|
109
|
+
await box(id);
|
|
110
|
+
return "running";
|
|
111
|
+
}
|
|
112
|
+
catch (e) {
|
|
113
|
+
if (e instanceof NotFoundError)
|
|
114
|
+
return "gone";
|
|
115
|
+
throw e;
|
|
116
|
+
}
|
|
117
|
+
},
|
|
118
|
+
async destroy(id) {
|
|
119
|
+
boxes.delete(id);
|
|
120
|
+
return Sandbox.kill(id, { apiKey });
|
|
121
|
+
},
|
|
122
|
+
};
|
|
123
|
+
};
|
|
124
|
+
export default e2b;
|