bottleship 0.1.0-prototype.1 → 0.1.0-prototype.2

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.
Files changed (165) hide show
  1. package/CHANGELOG.md +10 -0
  2. package/README.md +118 -117
  3. package/dist/src/boxes.d.ts +41 -39
  4. package/dist/src/boxes.js +181 -179
  5. package/dist/src/cli.js +298 -84
  6. package/dist/src/config.d.ts +3 -6
  7. package/dist/src/config.js +19 -85
  8. package/dist/src/init.d.ts +2 -4
  9. package/dist/src/init.js +92 -228
  10. package/dist/src/local.d.ts +8 -0
  11. package/dist/src/local.js +24 -0
  12. package/dist/src/onboard.d.ts +5 -0
  13. package/dist/src/onboard.js +25 -0
  14. package/dist/src/paths.d.ts +0 -1
  15. package/dist/src/paths.js +0 -1
  16. package/dist/src/provider.d.ts +16 -0
  17. package/dist/src/provider.js +81 -0
  18. package/dist/src/repo.d.ts +21 -0
  19. package/dist/src/repo.js +75 -0
  20. package/dist/src/review/comments.d.ts +66 -0
  21. package/dist/src/review/comments.js +344 -0
  22. package/dist/src/review/contract.d.ts +82 -0
  23. package/dist/src/review/contract.js +189 -0
  24. package/dist/src/review/github.d.ts +120 -0
  25. package/dist/src/review/github.js +160 -0
  26. package/dist/src/review/local.d.ts +14 -0
  27. package/dist/src/review/local.js +70 -0
  28. package/dist/src/review/loop.d.ts +12 -0
  29. package/dist/src/review/loop.js +119 -0
  30. package/dist/src/review/pr.d.ts +14 -0
  31. package/dist/src/review/pr.js +68 -0
  32. package/dist/src/review/prompt.d.ts +15 -0
  33. package/dist/src/review/prompt.js +48 -0
  34. package/dist/src/review/serve.d.ts +1 -0
  35. package/dist/src/review/serve.js +8 -0
  36. package/dist/src/review/server.d.ts +14 -0
  37. package/dist/src/review/server.js +484 -0
  38. package/dist/src/review/setting-up.d.ts +4 -0
  39. package/dist/src/review/setting-up.js +74 -0
  40. package/dist/src/review/start.d.ts +32 -0
  41. package/dist/src/review/start.js +140 -0
  42. package/dist/src/review/target.d.ts +20 -0
  43. package/dist/src/review/target.js +66 -0
  44. package/dist/src/review/template.d.ts +3 -0
  45. package/dist/src/review/template.js +13 -0
  46. package/dist/src/review/terminal.d.ts +4 -0
  47. package/dist/src/review/terminal.js +91 -0
  48. package/dist/src/types.d.ts +1 -71
  49. package/dist/src/ui.d.ts +6 -1
  50. package/dist/src/ui.js +22 -23
  51. package/dist/src/util.d.ts +11 -0
  52. package/dist/src/util.js +40 -0
  53. package/package.json +7 -14
  54. package/presets/agents/claude.json +1 -5
  55. package/presets/agents/codex.json +1 -10
  56. package/presets/agents/cursor.json +1 -9
  57. package/presets/agents/opencode.json +1 -14
  58. package/presets/providers/docker/create +12 -0
  59. package/presets/providers/docker/destroy +2 -0
  60. package/presets/providers/docker/exec +2 -0
  61. package/presets/providers/docker/list +4 -0
  62. package/presets/providers/docker/status +2 -0
  63. package/presets/providers/docker/url +4 -0
  64. package/review-app/KIT.md +106 -0
  65. package/review-app/WRITING.md +93 -0
  66. package/review-app/examples/rply-16.tsx +98 -0
  67. package/review-app/index.html +15 -0
  68. package/review-app/package-lock.json +2539 -0
  69. package/review-app/package.json +24 -0
  70. package/review-app/playground/fixtures/excalidraw-12602/data/box.json +1 -0
  71. package/review-app/playground/fixtures/excalidraw-12602/data/pr.diff +91 -0
  72. package/review-app/playground/fixtures/excalidraw-12602/data/pr.json +62 -0
  73. package/review-app/playground/fixtures/excalidraw-12602/data/review-comments.json +44 -0
  74. package/review-app/playground/fixtures/excalidraw-12602/events.json +1 -0
  75. package/review-app/playground/fixtures/excalidraw-12602/files/shots/base-ar-zoom.png +0 -0
  76. package/review-app/playground/fixtures/excalidraw-12602/files/shots/base-en-US-zoom.png +0 -0
  77. package/review-app/playground/fixtures/excalidraw-12602/files/shots/base-he-IL-zoom.png +0 -0
  78. package/review-app/playground/fixtures/excalidraw-12602/files/shots/base-he-dark.png +0 -0
  79. package/review-app/playground/fixtures/excalidraw-12602/files/shots/base-he-hover.png +0 -0
  80. package/review-app/playground/fixtures/excalidraw-12602/files/shots/base-he-page.png +0 -0
  81. package/review-app/playground/fixtures/excalidraw-12602/files/shots/base-he-tip.png +0 -0
  82. package/review-app/playground/fixtures/excalidraw-12602/files/shots/base-he-zen.png +0 -0
  83. package/review-app/playground/fixtures/excalidraw-12602/files/shots/head-ar-zoom.png +0 -0
  84. package/review-app/playground/fixtures/excalidraw-12602/files/shots/head-en-US-zoom.png +0 -0
  85. package/review-app/playground/fixtures/excalidraw-12602/files/shots/head-he-IL-zoom.png +0 -0
  86. package/review-app/playground/fixtures/excalidraw-12602/files/shots/head-he-dark.png +0 -0
  87. package/review-app/playground/fixtures/excalidraw-12602/files/shots/head-he-hover.png +0 -0
  88. package/review-app/playground/fixtures/excalidraw-12602/files/shots/head-he-page.png +0 -0
  89. package/review-app/playground/fixtures/excalidraw-12602/files/shots/head-he-tip.png +0 -0
  90. package/review-app/playground/fixtures/excalidraw-12602/files/shots/head-he-zen.png +0 -0
  91. package/review-app/playground/fixtures/excalidraw-12602/instances.json +1 -0
  92. package/review-app/playground/fixtures/excalidraw-12602/review.tsx +103 -0
  93. package/review-app/playground/fixtures/rply-16/data/box.json +1 -0
  94. package/review-app/playground/fixtures/rply-16/data/hooks.diff +28 -0
  95. package/review-app/playground/fixtures/rply-16/data/pr.diff +114 -0
  96. package/review-app/playground/fixtures/rply-16/data/pr.json +68 -0
  97. package/review-app/playground/fixtures/rply-16/data/review-comments.json +1 -0
  98. package/review-app/playground/fixtures/rply-16/events.json +156 -0
  99. package/review-app/playground/fixtures/rply-16/instances.json +1 -0
  100. package/review-app/playground/fixtures/rply-16/review.tsx +98 -0
  101. package/review-app/playground/gallery.tsx +127 -0
  102. package/review-app/playground/index.html +15 -0
  103. package/review-app/playground/main.tsx +26 -0
  104. package/review-app/playground/mock.ts +215 -0
  105. package/review-app/playground/playground.css +18 -0
  106. package/review-app/playground/vite.config.ts +32 -0
  107. package/review-app/scripts/check.cjs +47 -0
  108. package/review-app/scripts/clip.cjs +104 -0
  109. package/review-app/scripts/shot.cjs +66 -0
  110. package/review-app/src/App.tsx +132 -0
  111. package/review-app/src/data/box.json +1 -0
  112. package/review-app/src/data/hooks.diff +28 -0
  113. package/review-app/src/data/pr.diff +114 -0
  114. package/review-app/src/data/pr.json +68 -0
  115. package/review-app/src/data/review-comments.json +1 -0
  116. package/review-app/src/kit/agent.tsx +287 -0
  117. package/review-app/src/kit/chapter.tsx +178 -0
  118. package/review-app/src/kit/code.tsx +217 -0
  119. package/review-app/src/kit/comments.tsx +377 -0
  120. package/review-app/src/kit/finish.tsx +274 -0
  121. package/review-app/src/kit/flow.tsx +184 -0
  122. package/review-app/src/kit/index.ts +10 -0
  123. package/review-app/src/kit/kit.css +696 -0
  124. package/review-app/src/kit/live.ts +91 -0
  125. package/review-app/src/kit/media.tsx +348 -0
  126. package/review-app/src/kit/overview.tsx +161 -0
  127. package/review-app/src/kit/pr.ts +100 -0
  128. package/review-app/src/kit/pullrequest.tsx +298 -0
  129. package/review-app/src/kit/review.ts +74 -0
  130. package/review-app/src/kit/theme.ts +30 -0
  131. package/review-app/src/kit/ui.tsx +127 -0
  132. package/review-app/src/main.tsx +6 -0
  133. package/review-app/src/review.tsx +22 -0
  134. package/review-app/tsconfig.json +17 -0
  135. package/review-app/vite.config.ts +12 -0
  136. package/dist/presets/providers/command.d.ts +0 -3
  137. package/dist/presets/providers/command.js +0 -39
  138. package/dist/presets/providers/daytona.d.ts +0 -3
  139. package/dist/presets/providers/daytona.js +0 -173
  140. package/dist/presets/providers/docker.d.ts +0 -3
  141. package/dist/presets/providers/docker.js +0 -31
  142. package/dist/presets/providers/e2b.d.ts +0 -3
  143. package/dist/presets/providers/e2b.js +0 -124
  144. package/dist/presets/providers/exe.d.ts +0 -3
  145. package/dist/presets/providers/exe.js +0 -28
  146. package/dist/presets/providers/http.example.d.ts +0 -3
  147. package/dist/presets/providers/http.example.js +0 -47
  148. package/dist/presets/providers/local.d.ts +0 -3
  149. package/dist/presets/providers/local.js +0 -52
  150. package/dist/presets/providers/modal.d.ts +0 -3
  151. package/dist/presets/providers/modal.js +0 -117
  152. package/dist/presets/providers/ssh.d.ts +0 -3
  153. package/dist/presets/providers/ssh.js +0 -49
  154. package/dist/presets/providers/util.d.ts +0 -13
  155. package/dist/presets/providers/util.js +0 -102
  156. package/presets/providers/command.ts +0 -43
  157. package/presets/providers/daytona.ts +0 -181
  158. package/presets/providers/docker.ts +0 -38
  159. package/presets/providers/e2b.ts +0 -130
  160. package/presets/providers/exe.ts +0 -35
  161. package/presets/providers/http.example.ts +0 -64
  162. package/presets/providers/local.ts +0 -56
  163. package/presets/providers/modal.ts +0 -121
  164. package/presets/providers/ssh.ts +0 -54
  165. package/presets/providers/util.ts +0 -112
@@ -0,0 +1,17 @@
1
+ {
2
+ "compilerOptions": {
3
+ "target": "ES2022",
4
+ "module": "ESNext",
5
+ "moduleResolution": "bundler",
6
+ "lib": ["ES2023", "DOM", "DOM.Iterable"],
7
+ "jsx": "react-jsx",
8
+ "types": ["vite/client"],
9
+ "strict": true,
10
+ "noEmit": true,
11
+ "resolveJsonModule": true,
12
+ "verbatimModuleSyntax": true,
13
+ "skipLibCheck": true,
14
+ "paths": { "@data/*": ["./src/data/*"] }
15
+ },
16
+ "include": ["src", "examples"]
17
+ }
@@ -0,0 +1,12 @@
1
+ // In the box the review server serves the built app; `npm run dev` proxies its API from REVIEW_URL.
2
+ import { defineConfig } from "vite";
3
+ import react from "@vitejs/plugin-react";
4
+ import { fileURLToPath } from "node:url";
5
+
6
+ const api = process.env.REVIEW_URL ?? "http://127.0.0.1:7777";
7
+ export default defineConfig({
8
+ plugins: [react()],
9
+ base: "./",
10
+ resolve: { alias: { "@data": fileURLToPath(new URL("./src/data", import.meta.url)) } },
11
+ server: { proxy: { "/api": api, "/files": api } },
12
+ });
@@ -1,3 +0,0 @@
1
- import type { ProviderFactory } from "../../src/types.ts";
2
- declare const command: ProviderFactory;
3
- export default command;
@@ -1,39 +0,0 @@
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;
@@ -1,3 +0,0 @@
1
- import type { ProviderFactory } from "../../src/types.ts";
2
- declare const daytona: ProviderFactory;
3
- export default daytona;
@@ -1,173 +0,0 @@
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;
@@ -1,3 +0,0 @@
1
- import type { ProviderFactory } from "../../src/types.ts";
2
- declare const docker: ProviderFactory;
3
- export default docker;
@@ -1,31 +0,0 @@
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;
@@ -1,3 +0,0 @@
1
- import type { ProviderFactory } from "../../src/types.ts";
2
- declare const e2b: ProviderFactory;
3
- export default e2b;
@@ -1,124 +0,0 @@
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;
@@ -1,3 +0,0 @@
1
- import type { ProviderFactory } from "../../src/types.ts";
2
- declare const exe: ProviderFactory;
3
- export default exe;
@@ -1,28 +0,0 @@
1
- // exe.dev: VMs over ssh. Needs `ssh exe.dev` working on this machine (your exe.dev ssh key).
2
- //
3
- // options: { tag? (default "bottleship") }
4
- //
5
- // No template(): exe.dev boots VMs only from registry images (`new --image`), and bottleship has no registry to push a built image to.
6
- // `cp` clones a VM, but a VM built by running the Dockerfile over ssh keeps root as its ssh login user, so it would not match the spec.
7
- import { run, shq, spawnStream } from "./util.js";
8
- const SSH = ["-o", "StrictHostKeyChecking=accept-new", "-o", "BatchMode=yes", "-o", "ServerAliveInterval=15"];
9
- const host = (id) => `${id}.exe.xyz`;
10
- const remote = (id, script) => [...SSH, "-T", host(id), `bash -c ${shq(script)}`];
11
- const reachable = async (id, timeout) => (await run("ssh", [...SSH, "-o", `ConnectTimeout=${timeout}`, host(id), "true"], { check: false })).code === 0;
12
- const exe = ({ options }) => ({
13
- async create({ id }) {
14
- await run("ssh", [...SSH, "exe.dev", "new", `--name=${id}`, `--tag=${options.tag ?? "bottleship"}`, "--no-email", "--json"]);
15
- // ponytail: fixed 2 minute poll for sshd; make it an option if VMs boot slower
16
- for (let i = 0; i < 60; i++) {
17
- if (await reachable(id, 5))
18
- return { id };
19
- await new Promise((res) => setTimeout(res, 2000));
20
- }
21
- throw new Error(`exe VM ${id} never answered ssh`);
22
- },
23
- exec: (id, script, { stdin } = {}) => run("ssh", remote(id, script), { stdin, check: false }),
24
- openStream: (id, script) => spawnStream("ssh", remote(id, script)),
25
- status: async (id) => ((await reachable(id, 8)) ? "running" : "gone"),
26
- destroy: (id) => run("ssh", [...SSH, "exe.dev", "rm", id], { check: false }),
27
- });
28
- export default exe;
@@ -1,3 +0,0 @@
1
- import type { ProviderFactory } from "../../src/types.ts";
2
- declare const corp: ProviderFactory;
3
- export default corp;
@@ -1,47 +0,0 @@
1
- const corp = ({ options, secret, kit }) => {
2
- const base = options.baseUrl;
3
- const headers = () => ({ authorization: `Bearer ${secret(options.apiKey)}`, "content-type": "application/json" });
4
- const api = async (method, path, body) => {
5
- const res = await fetch(`${base}${path}`, { method, headers: headers(), body: body ? JSON.stringify(body) : undefined });
6
- if (!res.ok)
7
- throw new Error(`${method} ${path}: ${res.status} ${await res.text()}`);
8
- return res.status === 204 ? null : res.json();
9
- };
10
- return {
11
- async create({ id }) {
12
- // TODO: real create call. Wait until the box can run commands before returning.
13
- // Boxes must stay up until destroy(): the agent runs in the background between bottleship calls.
14
- const box = await api("POST", "/sandboxes", { name: id, template: options.template });
15
- for (let i = 0; i < 60; i++) {
16
- const s = await api("GET", `/sandboxes/${box.id}`);
17
- if (s.state === "running")
18
- return { id: box.id };
19
- await new Promise((r) => setTimeout(r, 2000));
20
- }
21
- throw new Error(`sandbox ${box.id} did not start`);
22
- },
23
- async exec(id, script, { stdin } = {}) {
24
- // TODO: one-shot command. Must run `bash -c <script>` and feed `stdin` if given.
25
- const r = await api("POST", `/sandboxes/${id}/exec`, { cmd: ["bash", "-c", script], stdin: stdin?.toString() ?? "" });
26
- return { code: r.exitCode, stdout: r.stdout, stderr: r.stderr };
27
- },
28
- // TODO: if the API has a streaming exec (websocket/attach), wire it here; it must be binary-clean.
29
- // Delete this method if it doesn't; only `bottleship exec -i` needs it.
30
- openStream(id, script) {
31
- return kit.wsStream(`${base.replace(/^http/, "ws")}/sandboxes/${id}/exec/stream?cmd=${encodeURIComponent(script)}`, headers());
32
- },
33
- async status(id) {
34
- try {
35
- const s = await api("GET", `/sandboxes/${id}`);
36
- return s.state === "running" ? "running" : "gone";
37
- }
38
- catch {
39
- return "gone";
40
- }
41
- },
42
- async destroy(id) {
43
- await api("DELETE", `/sandboxes/${id}`).catch(() => { });
44
- },
45
- };
46
- };
47
- export default corp;
@@ -1,3 +0,0 @@
1
- import type { ProviderFactory } from "../../src/types.ts";
2
- declare const local: ProviderFactory;
3
- export default local;
@@ -1,52 +0,0 @@
1
- // Boxes on this machine: each box is a directory used as $HOME, and its ~/workspace is a git worktree
2
- // of your local repo on branch bottleship/<id> (a plain directory when the task has no local repo).
3
- // No isolation: box scripts run as you, with your PATH and network, so only use it with agents you trust.
4
- // Boxes stop when the machine sleeps.
5
- //
6
- // options: { root? (default /tmp/bottleship-local) }
7
- import { appendFileSync, existsSync, mkdirSync, rmSync, statSync } from "node:fs";
8
- import { basename, join, resolve } from "node:path";
9
- import { killBoxScript, run, shq, spawnStream } from "./util.js";
10
- const local = ({ options }) => {
11
- const root = resolve(options.root ?? "/tmp/bottleship-local");
12
- const dir = (id) => {
13
- // ids become directory names under root, and destroy rm -rf's them
14
- if (!id || basename(id) !== id || id.startsWith("."))
15
- throw new Error(`local provider: bad box id ${JSON.stringify(id)}`);
16
- return join(root, id);
17
- };
18
- // every exec is marked as the box's and runs in its own process group, recorded next to the box, so destroy finds what it left behind
19
- const groups = (id) => join(root, `.${id}.pgids`);
20
- const box = (id) => ({ cwd: dir(id), env: { HOME: dir(id), BOTTLESHIP_BOX: dir(id) }, detached: true, onSpawn: (pid) => appendFileSync(groups(id), `${pid}\n`) });
21
- // a worktree's .git is a file pointing into the source repo; removing it there keeps the branch
22
- async function removeWorktree(ws) {
23
- if (!existsSync(join(ws, ".git")) || !statSync(join(ws, ".git")).isFile())
24
- return;
25
- const common = (await run("git", ["-C", ws, "rev-parse", "--path-format=absolute", "--git-common-dir"], { check: false })).stdout.trim();
26
- if (!common)
27
- return;
28
- await run("git", ["--git-dir", common, "worktree", "remove", "--force", ws], { check: false });
29
- rmSync(ws, { recursive: true, force: true });
30
- await run("git", ["--git-dir", common, "worktree", "prune"], { check: false });
31
- }
32
- return {
33
- async create({ id, source }) {
34
- mkdirSync(dir(id), { recursive: true });
35
- // ponytail: branches from the source's HEAD commit, so uncommitted changes stay behind; stash-and-apply if users expect them
36
- if (source)
37
- await run("git", ["-C", source, "worktree", "add", "-q", "-b", `bottleship/${id}`, join(dir(id), "workspace"), "HEAD"]);
38
- return { id };
39
- },
40
- // a destroyed box has no cwd, so spawn rejects with ENOENT like an unreachable box would
41
- exec: (id, script, { stdin } = {}) => run("bash", ["-c", script], { ...box(id), stdin, check: false }),
42
- openStream: (id, script) => spawnStream("bash", ["-c", script], box(id)),
43
- status: async (id) => (existsSync(dir(id)) ? "running" : "gone"),
44
- async destroy(id) {
45
- await run("bash", ["-c", killBoxScript(shq(dir(id)), shq(groups(id)))]);
46
- rmSync(groups(id), { force: true });
47
- await removeWorktree(join(dir(id), "workspace"));
48
- rmSync(dir(id), { recursive: true, force: true });
49
- },
50
- };
51
- };
52
- export default local;
@@ -1,3 +0,0 @@
1
- import type { ProviderFactory } from "../../src/types.ts";
2
- declare const modal: ProviderFactory;
3
- export default modal;