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.
- package/CHANGELOG.md +10 -0
- package/README.md +118 -117
- package/dist/src/boxes.d.ts +41 -39
- package/dist/src/boxes.js +181 -179
- package/dist/src/cli.js +298 -84
- package/dist/src/config.d.ts +3 -6
- package/dist/src/config.js +19 -85
- package/dist/src/init.d.ts +2 -4
- package/dist/src/init.js +92 -228
- package/dist/src/local.d.ts +8 -0
- package/dist/src/local.js +24 -0
- package/dist/src/onboard.d.ts +5 -0
- package/dist/src/onboard.js +25 -0
- package/dist/src/paths.d.ts +0 -1
- package/dist/src/paths.js +0 -1
- package/dist/src/provider.d.ts +16 -0
- package/dist/src/provider.js +81 -0
- package/dist/src/repo.d.ts +21 -0
- package/dist/src/repo.js +75 -0
- package/dist/src/review/comments.d.ts +66 -0
- package/dist/src/review/comments.js +344 -0
- package/dist/src/review/contract.d.ts +82 -0
- package/dist/src/review/contract.js +189 -0
- package/dist/src/review/github.d.ts +120 -0
- package/dist/src/review/github.js +160 -0
- package/dist/src/review/local.d.ts +14 -0
- package/dist/src/review/local.js +70 -0
- package/dist/src/review/loop.d.ts +12 -0
- package/dist/src/review/loop.js +119 -0
- package/dist/src/review/pr.d.ts +14 -0
- package/dist/src/review/pr.js +68 -0
- package/dist/src/review/prompt.d.ts +15 -0
- package/dist/src/review/prompt.js +48 -0
- package/dist/src/review/serve.d.ts +1 -0
- package/dist/src/review/serve.js +8 -0
- package/dist/src/review/server.d.ts +14 -0
- package/dist/src/review/server.js +484 -0
- package/dist/src/review/setting-up.d.ts +4 -0
- package/dist/src/review/setting-up.js +74 -0
- package/dist/src/review/start.d.ts +32 -0
- package/dist/src/review/start.js +140 -0
- package/dist/src/review/target.d.ts +20 -0
- package/dist/src/review/target.js +66 -0
- package/dist/src/review/template.d.ts +3 -0
- package/dist/src/review/template.js +13 -0
- package/dist/src/review/terminal.d.ts +4 -0
- package/dist/src/review/terminal.js +91 -0
- package/dist/src/types.d.ts +1 -71
- package/dist/src/ui.d.ts +6 -1
- package/dist/src/ui.js +22 -23
- package/dist/src/util.d.ts +11 -0
- package/dist/src/util.js +40 -0
- package/package.json +7 -14
- package/presets/agents/claude.json +1 -5
- package/presets/agents/codex.json +1 -10
- package/presets/agents/cursor.json +1 -9
- package/presets/agents/opencode.json +1 -14
- package/presets/providers/docker/create +12 -0
- package/presets/providers/docker/destroy +2 -0
- package/presets/providers/docker/exec +2 -0
- package/presets/providers/docker/list +4 -0
- package/presets/providers/docker/status +2 -0
- package/presets/providers/docker/url +4 -0
- package/review-app/KIT.md +106 -0
- package/review-app/WRITING.md +93 -0
- package/review-app/examples/rply-16.tsx +98 -0
- package/review-app/index.html +15 -0
- package/review-app/package-lock.json +2539 -0
- package/review-app/package.json +24 -0
- package/review-app/playground/fixtures/excalidraw-12602/data/box.json +1 -0
- package/review-app/playground/fixtures/excalidraw-12602/data/pr.diff +91 -0
- package/review-app/playground/fixtures/excalidraw-12602/data/pr.json +62 -0
- package/review-app/playground/fixtures/excalidraw-12602/data/review-comments.json +44 -0
- package/review-app/playground/fixtures/excalidraw-12602/events.json +1 -0
- package/review-app/playground/fixtures/excalidraw-12602/files/shots/base-ar-zoom.png +0 -0
- package/review-app/playground/fixtures/excalidraw-12602/files/shots/base-en-US-zoom.png +0 -0
- package/review-app/playground/fixtures/excalidraw-12602/files/shots/base-he-IL-zoom.png +0 -0
- package/review-app/playground/fixtures/excalidraw-12602/files/shots/base-he-dark.png +0 -0
- package/review-app/playground/fixtures/excalidraw-12602/files/shots/base-he-hover.png +0 -0
- package/review-app/playground/fixtures/excalidraw-12602/files/shots/base-he-page.png +0 -0
- package/review-app/playground/fixtures/excalidraw-12602/files/shots/base-he-tip.png +0 -0
- package/review-app/playground/fixtures/excalidraw-12602/files/shots/base-he-zen.png +0 -0
- package/review-app/playground/fixtures/excalidraw-12602/files/shots/head-ar-zoom.png +0 -0
- package/review-app/playground/fixtures/excalidraw-12602/files/shots/head-en-US-zoom.png +0 -0
- package/review-app/playground/fixtures/excalidraw-12602/files/shots/head-he-IL-zoom.png +0 -0
- package/review-app/playground/fixtures/excalidraw-12602/files/shots/head-he-dark.png +0 -0
- package/review-app/playground/fixtures/excalidraw-12602/files/shots/head-he-hover.png +0 -0
- package/review-app/playground/fixtures/excalidraw-12602/files/shots/head-he-page.png +0 -0
- package/review-app/playground/fixtures/excalidraw-12602/files/shots/head-he-tip.png +0 -0
- package/review-app/playground/fixtures/excalidraw-12602/files/shots/head-he-zen.png +0 -0
- package/review-app/playground/fixtures/excalidraw-12602/instances.json +1 -0
- package/review-app/playground/fixtures/excalidraw-12602/review.tsx +103 -0
- package/review-app/playground/fixtures/rply-16/data/box.json +1 -0
- package/review-app/playground/fixtures/rply-16/data/hooks.diff +28 -0
- package/review-app/playground/fixtures/rply-16/data/pr.diff +114 -0
- package/review-app/playground/fixtures/rply-16/data/pr.json +68 -0
- package/review-app/playground/fixtures/rply-16/data/review-comments.json +1 -0
- package/review-app/playground/fixtures/rply-16/events.json +156 -0
- package/review-app/playground/fixtures/rply-16/instances.json +1 -0
- package/review-app/playground/fixtures/rply-16/review.tsx +98 -0
- package/review-app/playground/gallery.tsx +127 -0
- package/review-app/playground/index.html +15 -0
- package/review-app/playground/main.tsx +26 -0
- package/review-app/playground/mock.ts +215 -0
- package/review-app/playground/playground.css +18 -0
- package/review-app/playground/vite.config.ts +32 -0
- package/review-app/scripts/check.cjs +47 -0
- package/review-app/scripts/clip.cjs +104 -0
- package/review-app/scripts/shot.cjs +66 -0
- package/review-app/src/App.tsx +132 -0
- package/review-app/src/data/box.json +1 -0
- package/review-app/src/data/hooks.diff +28 -0
- package/review-app/src/data/pr.diff +114 -0
- package/review-app/src/data/pr.json +68 -0
- package/review-app/src/data/review-comments.json +1 -0
- package/review-app/src/kit/agent.tsx +287 -0
- package/review-app/src/kit/chapter.tsx +178 -0
- package/review-app/src/kit/code.tsx +217 -0
- package/review-app/src/kit/comments.tsx +377 -0
- package/review-app/src/kit/finish.tsx +274 -0
- package/review-app/src/kit/flow.tsx +184 -0
- package/review-app/src/kit/index.ts +10 -0
- package/review-app/src/kit/kit.css +696 -0
- package/review-app/src/kit/live.ts +91 -0
- package/review-app/src/kit/media.tsx +348 -0
- package/review-app/src/kit/overview.tsx +161 -0
- package/review-app/src/kit/pr.ts +100 -0
- package/review-app/src/kit/pullrequest.tsx +298 -0
- package/review-app/src/kit/review.ts +74 -0
- package/review-app/src/kit/theme.ts +30 -0
- package/review-app/src/kit/ui.tsx +127 -0
- package/review-app/src/main.tsx +6 -0
- package/review-app/src/review.tsx +22 -0
- package/review-app/tsconfig.json +17 -0
- package/review-app/vite.config.ts +12 -0
- package/dist/presets/providers/command.d.ts +0 -3
- package/dist/presets/providers/command.js +0 -39
- package/dist/presets/providers/daytona.d.ts +0 -3
- package/dist/presets/providers/daytona.js +0 -173
- package/dist/presets/providers/docker.d.ts +0 -3
- package/dist/presets/providers/docker.js +0 -31
- package/dist/presets/providers/e2b.d.ts +0 -3
- package/dist/presets/providers/e2b.js +0 -124
- package/dist/presets/providers/exe.d.ts +0 -3
- package/dist/presets/providers/exe.js +0 -28
- package/dist/presets/providers/http.example.d.ts +0 -3
- package/dist/presets/providers/http.example.js +0 -47
- package/dist/presets/providers/local.d.ts +0 -3
- package/dist/presets/providers/local.js +0 -52
- package/dist/presets/providers/modal.d.ts +0 -3
- package/dist/presets/providers/modal.js +0 -117
- package/dist/presets/providers/ssh.d.ts +0 -3
- package/dist/presets/providers/ssh.js +0 -49
- package/dist/presets/providers/util.d.ts +0 -13
- package/dist/presets/providers/util.js +0 -102
- package/presets/providers/command.ts +0 -43
- package/presets/providers/daytona.ts +0 -181
- package/presets/providers/docker.ts +0 -38
- package/presets/providers/e2b.ts +0 -130
- package/presets/providers/exe.ts +0 -35
- package/presets/providers/http.example.ts +0 -64
- package/presets/providers/local.ts +0 -56
- package/presets/providers/modal.ts +0 -121
- package/presets/providers/ssh.ts +0 -54
- package/presets/providers/util.ts +0 -112
package/presets/providers/e2b.ts
DELETED
|
@@ -1,130 +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
|
-
import type { BoxStream, ExecResult, ProviderFactory } from "../../src/types.ts";
|
|
9
|
-
import type { CommandResult, Sandbox as SandboxT } from "e2b";
|
|
10
|
-
|
|
11
|
-
const e2b: ProviderFactory = async ({ options, secret, log }) => {
|
|
12
|
-
const { Sandbox, Template, ApiClient, ConnectionConfig, CommandExitError, NotFoundError } = await import("e2b");
|
|
13
|
-
const apiKey: string | undefined = options.apiKey ? secret(options.apiKey) : undefined;
|
|
14
|
-
// 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
|
|
15
|
-
const timeoutMs = (options.timeoutMinutes ?? 60) * 60_000;
|
|
16
|
-
|
|
17
|
-
// connecting also extends the box's timeout, so every exec and status keeps a box someone checks on alive
|
|
18
|
-
const boxes = new Map<string, { sbx: SandboxT; at: number }>();
|
|
19
|
-
async function box(id: string): Promise<SandboxT> {
|
|
20
|
-
const hit = boxes.get(id);
|
|
21
|
-
if (hit && Date.now() - hit.at < 60_000) return hit.sbx;
|
|
22
|
-
const sbx = await Sandbox.connect(id, { apiKey, timeoutMs });
|
|
23
|
-
boxes.set(id, { sbx, at: Date.now() });
|
|
24
|
-
return sbx;
|
|
25
|
-
}
|
|
26
|
-
|
|
27
|
-
const result = (r: CommandResult): ExecResult => ({ code: r.exitCode, stdout: r.stdout, stderr: r.stderr });
|
|
28
|
-
// a non-zero exit is a result, not an error
|
|
29
|
-
const settle = (p: Promise<CommandResult>) => p.then(result, (e) => {
|
|
30
|
-
if (e instanceof CommandExitError) return result(e);
|
|
31
|
-
throw e;
|
|
32
|
-
});
|
|
33
|
-
|
|
34
|
-
// 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
|
|
35
|
-
async function ownTemplate(name: string): Promise<string | undefined> {
|
|
36
|
-
const { api } = new ApiClient(new ConnectionConfig({ apiKey }));
|
|
37
|
-
let nextToken: string | undefined;
|
|
38
|
-
do {
|
|
39
|
-
const res = await api.GET("/v2/templates", { params: { query: { limit: 100, nextToken } } });
|
|
40
|
-
if (!res.data) throw new Error(`e2b could not list templates: ${res.response.status} ${JSON.stringify(res.error)}`);
|
|
41
|
-
const hit = res.data.find((t) => !t.isDefault && t.buildStatus === "ready" && t.names.some((n) => n === name || n.endsWith(`/${name}`)));
|
|
42
|
-
if (hit) return hit.templateID;
|
|
43
|
-
nextToken = res.response.headers.get("X-Next-Token") || undefined;
|
|
44
|
-
} while (nextToken);
|
|
45
|
-
}
|
|
46
|
-
|
|
47
|
-
return {
|
|
48
|
-
// ponytail: two inits racing on one name both build, E2B keeps the later build; fine since both come from the same Dockerfile
|
|
49
|
-
async template({ name, dockerfile }) {
|
|
50
|
-
const found = await ownTemplate(name);
|
|
51
|
-
if (found) return found;
|
|
52
|
-
const built = await Template.build(Template().fromDockerfile(dockerfile), name, { apiKey, onBuildLogs: (e) => log(String(e)) });
|
|
53
|
-
return built.templateId;
|
|
54
|
-
},
|
|
55
|
-
|
|
56
|
-
// the bottleship id goes into metadata; E2B picks the sandbox id
|
|
57
|
-
async create({ id, template }) {
|
|
58
|
-
const sbx = await Sandbox.create(template ?? options.template ?? "base", { apiKey, timeoutMs, metadata: { bottleship: id } });
|
|
59
|
-
await sbx.commands.run("true");
|
|
60
|
-
boxes.set(sbx.sandboxId, { sbx, at: Date.now() });
|
|
61
|
-
return { id: sbx.sandboxId };
|
|
62
|
-
},
|
|
63
|
-
|
|
64
|
-
// the SDK runs `bash -l -c <script>`; timeoutMs 0 lifts its 60 second command limit
|
|
65
|
-
async exec(id, script, { stdin } = {}) {
|
|
66
|
-
const sbx = await box(id);
|
|
67
|
-
if (stdin === undefined) return settle(sbx.commands.run(script, { timeoutMs: 0 }));
|
|
68
|
-
const h = await sbx.commands.run(script, { background: true, stdin: true, timeoutMs: 0 });
|
|
69
|
-
if (stdin.length) await h.sendStdin(stdin);
|
|
70
|
-
await h.closeStdin();
|
|
71
|
-
return settle(h.wait());
|
|
72
|
-
},
|
|
73
|
-
|
|
74
|
-
// ponytail: the SDK hands output over as UTF-8 text, so binary stdout gets mangled; fine for text protocols
|
|
75
|
-
async openStream(id, script): Promise<BoxStream> {
|
|
76
|
-
const sbx = await box(id);
|
|
77
|
-
const stdout = new PassThrough();
|
|
78
|
-
const stderr = new PassThrough();
|
|
79
|
-
const h = await sbx.commands.run(script, {
|
|
80
|
-
background: true,
|
|
81
|
-
stdin: true,
|
|
82
|
-
timeoutMs: 0,
|
|
83
|
-
onStdout: (d) => void stdout.write(d),
|
|
84
|
-
onStderr: (d) => void stderr.write(d),
|
|
85
|
-
});
|
|
86
|
-
let code: number | null | undefined;
|
|
87
|
-
const closers: ((code?: number | null) => void)[] = [];
|
|
88
|
-
const done = h.wait().then(
|
|
89
|
-
(r) => r.exitCode,
|
|
90
|
-
(e) => (e instanceof CommandExitError ? e.exitCode : null),
|
|
91
|
-
);
|
|
92
|
-
void done.then((c) => {
|
|
93
|
-
code = c;
|
|
94
|
-
stdout.end();
|
|
95
|
-
stderr.end();
|
|
96
|
-
closers.splice(0).forEach((cb) => cb(c));
|
|
97
|
-
});
|
|
98
|
-
// Writable runs one write at a time, so stdin arrives in order
|
|
99
|
-
const stdin = new Writable({
|
|
100
|
-
write: (chunk: Buffer, _enc, cb) => void h.sendStdin(chunk).then(() => cb(), cb),
|
|
101
|
-
final: (cb) => void h.closeStdin().then(() => cb(), cb),
|
|
102
|
-
});
|
|
103
|
-
stdin.on("error", () => {});
|
|
104
|
-
return {
|
|
105
|
-
stdin,
|
|
106
|
-
stdout,
|
|
107
|
-
stderr,
|
|
108
|
-
close: () => void h.kill().catch(() => {}),
|
|
109
|
-
onClose: (cb) => (code === undefined ? closers.push(cb) : cb(code)),
|
|
110
|
-
};
|
|
111
|
-
},
|
|
112
|
-
|
|
113
|
-
async status(id) {
|
|
114
|
-
boxes.delete(id);
|
|
115
|
-
try {
|
|
116
|
-
await box(id);
|
|
117
|
-
return "running";
|
|
118
|
-
} catch (e) {
|
|
119
|
-
if (e instanceof NotFoundError) return "gone";
|
|
120
|
-
throw e;
|
|
121
|
-
}
|
|
122
|
-
},
|
|
123
|
-
|
|
124
|
-
async destroy(id) {
|
|
125
|
-
boxes.delete(id);
|
|
126
|
-
return Sandbox.kill(id, { apiKey });
|
|
127
|
-
},
|
|
128
|
-
};
|
|
129
|
-
};
|
|
130
|
-
export default e2b;
|
package/presets/providers/exe.ts
DELETED
|
@@ -1,35 +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.ts";
|
|
8
|
-
import type { ProviderFactory } from "../../src/types.ts";
|
|
9
|
-
|
|
10
|
-
const SSH = ["-o", "StrictHostKeyChecking=accept-new", "-o", "BatchMode=yes", "-o", "ServerAliveInterval=15"];
|
|
11
|
-
const host = (id: string) => `${id}.exe.xyz`;
|
|
12
|
-
const remote = (id: string, script: string) => [...SSH, "-T", host(id), `bash -c ${shq(script)}`];
|
|
13
|
-
const reachable = async (id: string, timeout: number) =>
|
|
14
|
-
(await run("ssh", [...SSH, "-o", `ConnectTimeout=${timeout}`, host(id), "true"], { check: false })).code === 0;
|
|
15
|
-
|
|
16
|
-
const exe: ProviderFactory = ({ options }) => ({
|
|
17
|
-
async create({ id }) {
|
|
18
|
-
await run("ssh", [...SSH, "exe.dev", "new", `--name=${id}`, `--tag=${options.tag ?? "bottleship"}`, "--no-email", "--json"]);
|
|
19
|
-
// ponytail: fixed 2 minute poll for sshd; make it an option if VMs boot slower
|
|
20
|
-
for (let i = 0; i < 60; i++) {
|
|
21
|
-
if (await reachable(id, 5)) return { id };
|
|
22
|
-
await new Promise((res) => setTimeout(res, 2000));
|
|
23
|
-
}
|
|
24
|
-
throw new Error(`exe VM ${id} never answered ssh`);
|
|
25
|
-
},
|
|
26
|
-
|
|
27
|
-
exec: (id, script, { stdin } = {}) => run("ssh", remote(id, script), { stdin, check: false }),
|
|
28
|
-
|
|
29
|
-
openStream: (id, script) => spawnStream("ssh", remote(id, script)),
|
|
30
|
-
|
|
31
|
-
status: async (id) => ((await reachable(id, 8)) ? "running" : "gone"),
|
|
32
|
-
|
|
33
|
-
destroy: (id) => run("ssh", [...SSH, "exe.dev", "rm", id], { check: false }),
|
|
34
|
-
});
|
|
35
|
-
export default exe;
|
|
@@ -1,64 +0,0 @@
|
|
|
1
|
-
// TEMPLATE for a sandbox system with an HTTP API (no CLI). Copy it to
|
|
2
|
-
// ~/.bottleship/providers/<name>.ts and replace every TODO with the real API.
|
|
3
|
-
// Node runs it directly (types are stripped); the `import type` below is erased,
|
|
4
|
-
// so it doesn't need to resolve at runtime. Plain .mjs works too.
|
|
5
|
-
// The endpoints below are made up; they show the shape, not a real service.
|
|
6
|
-
//
|
|
7
|
-
// config.json:
|
|
8
|
-
// "providers": { "corp": { "module": "providers/corp.ts", "baseUrl": "https://sandboxes.corp.example/api",
|
|
9
|
-
// "apiKey": { "store": "corp-sandbox" } } }
|
|
10
|
-
// `bottleship init` asks for the apiKey once and stores it; it never goes into a box.
|
|
11
|
-
//
|
|
12
|
-
// Required: create, exec, destroy. Recommended: status. Optional: openStream (for `bottleship exec -i`).
|
|
13
|
-
// The full contract is the Provider interface in bottleship's src/types.ts.
|
|
14
|
-
import type { ProviderFactory } from "../../src/types.ts"; // in ~/.bottleship/providers: from "bottleship"
|
|
15
|
-
|
|
16
|
-
const corp: ProviderFactory = ({ options, secret, kit }) => {
|
|
17
|
-
const base: string = options.baseUrl;
|
|
18
|
-
const headers = () => ({ authorization: `Bearer ${secret(options.apiKey)}`, "content-type": "application/json" });
|
|
19
|
-
const api = async (method: string, path: string, body?: unknown): Promise<any> => {
|
|
20
|
-
const res = await fetch(`${base}${path}`, { method, headers: headers(), body: body ? JSON.stringify(body) : undefined });
|
|
21
|
-
if (!res.ok) throw new Error(`${method} ${path}: ${res.status} ${await res.text()}`);
|
|
22
|
-
return res.status === 204 ? null : res.json();
|
|
23
|
-
};
|
|
24
|
-
|
|
25
|
-
return {
|
|
26
|
-
async create({ id }) {
|
|
27
|
-
// TODO: real create call. Wait until the box can run commands before returning.
|
|
28
|
-
// Boxes must stay up until destroy(): the agent runs in the background between bottleship calls.
|
|
29
|
-
const box = await api("POST", "/sandboxes", { name: id, template: options.template });
|
|
30
|
-
for (let i = 0; i < 60; i++) {
|
|
31
|
-
const s = await api("GET", `/sandboxes/${box.id}`);
|
|
32
|
-
if (s.state === "running") return { id: box.id };
|
|
33
|
-
await new Promise((r) => setTimeout(r, 2000));
|
|
34
|
-
}
|
|
35
|
-
throw new Error(`sandbox ${box.id} did not start`);
|
|
36
|
-
},
|
|
37
|
-
|
|
38
|
-
async exec(id, script, { stdin } = {}) {
|
|
39
|
-
// TODO: one-shot command. Must run `bash -c <script>` and feed `stdin` if given.
|
|
40
|
-
const r = await api("POST", `/sandboxes/${id}/exec`, { cmd: ["bash", "-c", script], stdin: stdin?.toString() ?? "" });
|
|
41
|
-
return { code: r.exitCode, stdout: r.stdout, stderr: r.stderr };
|
|
42
|
-
},
|
|
43
|
-
|
|
44
|
-
// TODO: if the API has a streaming exec (websocket/attach), wire it here; it must be binary-clean.
|
|
45
|
-
// Delete this method if it doesn't; only `bottleship exec -i` needs it.
|
|
46
|
-
openStream(id, script) {
|
|
47
|
-
return kit.wsStream(`${base.replace(/^http/, "ws")}/sandboxes/${id}/exec/stream?cmd=${encodeURIComponent(script)}`, headers());
|
|
48
|
-
},
|
|
49
|
-
|
|
50
|
-
async status(id) {
|
|
51
|
-
try {
|
|
52
|
-
const s = await api("GET", `/sandboxes/${id}`);
|
|
53
|
-
return s.state === "running" ? "running" : "gone";
|
|
54
|
-
} catch {
|
|
55
|
-
return "gone";
|
|
56
|
-
}
|
|
57
|
-
},
|
|
58
|
-
|
|
59
|
-
async destroy(id) {
|
|
60
|
-
await api("DELETE", `/sandboxes/${id}`).catch(() => {});
|
|
61
|
-
},
|
|
62
|
-
};
|
|
63
|
-
};
|
|
64
|
-
export default corp;
|
|
@@ -1,56 +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.ts";
|
|
10
|
-
import type { ProviderFactory } from "../../src/types.ts";
|
|
11
|
-
|
|
12
|
-
const local: ProviderFactory = ({ options }) => {
|
|
13
|
-
const root = resolve(options.root ?? "/tmp/bottleship-local");
|
|
14
|
-
const dir = (id: string) => {
|
|
15
|
-
// ids become directory names under root, and destroy rm -rf's them
|
|
16
|
-
if (!id || basename(id) !== id || id.startsWith(".")) throw new Error(`local provider: bad box id ${JSON.stringify(id)}`);
|
|
17
|
-
return join(root, id);
|
|
18
|
-
};
|
|
19
|
-
// 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
|
|
20
|
-
const groups = (id: string) => join(root, `.${id}.pgids`);
|
|
21
|
-
const box = (id: string) => ({ cwd: dir(id), env: { HOME: dir(id), BOTTLESHIP_BOX: dir(id) }, detached: true, onSpawn: (pid: number) => appendFileSync(groups(id), `${pid}\n`) });
|
|
22
|
-
|
|
23
|
-
// a worktree's .git is a file pointing into the source repo; removing it there keeps the branch
|
|
24
|
-
async function removeWorktree(ws: string) {
|
|
25
|
-
if (!existsSync(join(ws, ".git")) || !statSync(join(ws, ".git")).isFile()) return;
|
|
26
|
-
const common = (await run("git", ["-C", ws, "rev-parse", "--path-format=absolute", "--git-common-dir"], { check: false })).stdout.trim();
|
|
27
|
-
if (!common) 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
|
-
|
|
33
|
-
return {
|
|
34
|
-
async create({ id, source }) {
|
|
35
|
-
mkdirSync(dir(id), { recursive: true });
|
|
36
|
-
// ponytail: branches from the source's HEAD commit, so uncommitted changes stay behind; stash-and-apply if users expect them
|
|
37
|
-
if (source) await run("git", ["-C", source, "worktree", "add", "-q", "-b", `bottleship/${id}`, join(dir(id), "workspace"), "HEAD"]);
|
|
38
|
-
return { id };
|
|
39
|
-
},
|
|
40
|
-
|
|
41
|
-
// a destroyed box has no cwd, so spawn rejects with ENOENT like an unreachable box would
|
|
42
|
-
exec: (id, script, { stdin } = {}) => run("bash", ["-c", script], { ...box(id), stdin, check: false }),
|
|
43
|
-
|
|
44
|
-
openStream: (id, script) => spawnStream("bash", ["-c", script], box(id)),
|
|
45
|
-
|
|
46
|
-
status: async (id) => (existsSync(dir(id)) ? "running" : "gone"),
|
|
47
|
-
|
|
48
|
-
async destroy(id) {
|
|
49
|
-
await run("bash", ["-c", killBoxScript(shq(dir(id)), shq(groups(id)))]);
|
|
50
|
-
rmSync(groups(id), { force: true });
|
|
51
|
-
await removeWorktree(join(dir(id), "workspace"));
|
|
52
|
-
rmSync(dir(id), { recursive: true, force: true });
|
|
53
|
-
},
|
|
54
|
-
};
|
|
55
|
-
};
|
|
56
|
-
export default local;
|
|
@@ -1,121 +0,0 @@
|
|
|
1
|
-
// Modal Sandboxes (modal.com) as a provider, through the `modal` JS SDK. A box is a sandbox that sleeps until its timeout.
|
|
2
|
-
//
|
|
3
|
-
// options: { tokenId?, tokenSecret? (secret sources; without them the SDK reads ~/.modal.toml or MODAL_TOKEN_*),
|
|
4
|
-
// app? (default "bottleship"), image? (registry tag, default node:22-bookworm), timeoutMinutes? (default 120, max 1440) }
|
|
5
|
-
//
|
|
6
|
-
// template() builds the Dockerfile as Modal image layers and publishes it as a named image in the workspace.
|
|
7
|
-
// Modal ignores USER, so template boxes run as root (like the default image) with the box user's ENV and files; one FROM only, no COPY from a local context.
|
|
8
|
-
import { Readable, Writable } from "node:stream";
|
|
9
|
-
import type { ReadableStream as WebReadable, WritableStream as WebWritable } from "node:stream/web";
|
|
10
|
-
import type { ModalClient, Sandbox } from "modal";
|
|
11
|
-
import type { BoxStream, ExecResult, ProviderFactory } from "../../src/types.ts";
|
|
12
|
-
|
|
13
|
-
// errors that mean the sandbox id names nothing that runs
|
|
14
|
-
const isGone = (e: unknown) => {
|
|
15
|
-
const err = e as { name?: string; code?: number };
|
|
16
|
-
// nice-grpc NOT_FOUND (5) and INVALID_ARGUMENT (3) for unknown or malformed ids
|
|
17
|
-
return err?.name === "NotFoundError" || err?.code === 5 || err?.code === 3;
|
|
18
|
-
};
|
|
19
|
-
|
|
20
|
-
// the FROM image and the instructions after it; Modal builds the rest as one layer on that image
|
|
21
|
-
function splitDockerfile(dockerfile: string): { from: string; commands: string[] } {
|
|
22
|
-
const lines = dockerfile.split("\n");
|
|
23
|
-
const froms = lines.flatMap((l, i) => (/^\s*FROM\s/i.test(l) ? [i] : []));
|
|
24
|
-
if (froms.length !== 1) throw new Error(`modal templates take a Dockerfile with exactly one FROM, this one has ${froms.length}`);
|
|
25
|
-
const before = lines.slice(0, froms[0]).filter((l) => l.trim() && !l.trim().startsWith("#"));
|
|
26
|
-
if (before.length) throw new Error(`modal templates take nothing but comments before FROM, got: ${before[0]}`);
|
|
27
|
-
const m = /^\s*FROM\s+(\S+)(\s+AS\s+\S+)?\s*$/i.exec(lines[froms[0]]!);
|
|
28
|
-
if (!m || m[1]!.startsWith("--")) throw new Error(`modal templates take a plain FROM <image>, got: ${lines[froms[0]]}`);
|
|
29
|
-
return { from: m[1]!, commands: lines.slice(froms[0] + 1) };
|
|
30
|
-
}
|
|
31
|
-
|
|
32
|
-
const modal: ProviderFactory = ({ options, secret }) => {
|
|
33
|
-
const appName: string = options.app ?? "bottleship";
|
|
34
|
-
// debian based, runs as root with HOME=/root, has bash, git, curl, xz and node already
|
|
35
|
-
const imageTag: string = options.image ?? "node:22-bookworm";
|
|
36
|
-
// ponytail: a box dies at its timeout (Modal caps it at 24h) even if the agent still runs; snapshot and restore to go longer
|
|
37
|
-
const timeoutMs = Math.min(Number(options.timeoutMinutes ?? 120), 24 * 60) * 60_000;
|
|
38
|
-
|
|
39
|
-
let client: Promise<ModalClient> | undefined;
|
|
40
|
-
const getClient = () =>
|
|
41
|
-
(client ??= import("modal").then(({ ModalClient }) => new ModalClient({
|
|
42
|
-
tokenId: options.tokenId ? secret(options.tokenId) : undefined,
|
|
43
|
-
tokenSecret: options.tokenSecret ? secret(options.tokenSecret) : undefined,
|
|
44
|
-
})));
|
|
45
|
-
const sandbox = async (id: string): Promise<Sandbox> => (await getClient()).sandboxes.fromId(id);
|
|
46
|
-
const spawn = async (id: string, script: string) => (await sandbox(id)).exec(["bash", "-c", script], { mode: "binary" });
|
|
47
|
-
|
|
48
|
-
return {
|
|
49
|
-
// a named image is scoped to the Modal workspace, so the name cannot resolve to someone else's image
|
|
50
|
-
// ponytail: two inits racing on one name both build and publish; harmless, since Modal caches layers by content and both are the same image
|
|
51
|
-
async template({ name, dockerfile }) {
|
|
52
|
-
const mc = await getClient();
|
|
53
|
-
try {
|
|
54
|
-
await mc.images.fromName(name);
|
|
55
|
-
return name;
|
|
56
|
-
} catch (e) {
|
|
57
|
-
if ((e as Error)?.name !== "NotFoundError") throw e;
|
|
58
|
-
}
|
|
59
|
-
const { from, commands } = splitDockerfile(dockerfile);
|
|
60
|
-
const app = await mc.apps.fromName(appName, { createIfMissing: true });
|
|
61
|
-
const image = await mc.images.fromRegistry(from).dockerfileCommands(commands).build(app);
|
|
62
|
-
await image.publish(name);
|
|
63
|
-
return name;
|
|
64
|
-
},
|
|
65
|
-
|
|
66
|
-
async create({ template }) {
|
|
67
|
-
const mc = await getClient();
|
|
68
|
-
const app = await mc.apps.fromName(appName, { createIfMissing: true });
|
|
69
|
-
const image = template ? await mc.images.fromName(template) : mc.images.fromRegistry(imageTag);
|
|
70
|
-
// no idleTimeoutMs: the detached agent runs no exec, so Modal would count it as idle
|
|
71
|
-
const sb = await mc.sandboxes.create(app, image, { timeoutMs, tags: { bottleship: "1" } });
|
|
72
|
-
// the first exec waits until the sandbox is scheduled and its command router answers
|
|
73
|
-
await (await sb.exec(["true"])).wait();
|
|
74
|
-
return { id: sb.sandboxId };
|
|
75
|
-
},
|
|
76
|
-
|
|
77
|
-
async exec(id, script, { stdin } = {}): Promise<ExecResult> {
|
|
78
|
-
const p = await spawn(id, script);
|
|
79
|
-
const w = p.stdin.getWriter();
|
|
80
|
-
if (stdin !== undefined && stdin.length) await w.write(typeof stdin === "string" ? Buffer.from(stdin) : stdin);
|
|
81
|
-
await w.close();
|
|
82
|
-
const [out, err, code] = await Promise.all([p.stdout.readBytes(), p.stderr.readBytes(), p.wait()]);
|
|
83
|
-
return { code, stdout: Buffer.from(out).toString(), stderr: Buffer.from(err).toString() };
|
|
84
|
-
},
|
|
85
|
-
|
|
86
|
-
async openStream(id, script): Promise<BoxStream> {
|
|
87
|
-
const p = await spawn(id, script);
|
|
88
|
-
const stdin = Writable.fromWeb(p.stdin as unknown as WebWritable);
|
|
89
|
-
const stdout = Readable.fromWeb(p.stdout as unknown as WebReadable);
|
|
90
|
-
const stderr = Readable.fromWeb(p.stderr as unknown as WebReadable);
|
|
91
|
-
stdin.on("error", () => {});
|
|
92
|
-
const done = p.wait().catch(() => null);
|
|
93
|
-
return {
|
|
94
|
-
stdin,
|
|
95
|
-
stdout,
|
|
96
|
-
stderr,
|
|
97
|
-
// ponytail: the SDK cannot kill an exec, so close only drops our end; the remote process runs on until it reads EOF or exits
|
|
98
|
-
close: () => [stdin, stdout, stderr].forEach((s) => s.destroy()),
|
|
99
|
-
onClose: (cb) => void done.then(cb),
|
|
100
|
-
};
|
|
101
|
-
},
|
|
102
|
-
|
|
103
|
-
async status(id) {
|
|
104
|
-
try {
|
|
105
|
-
return (await (await sandbox(id)).poll()) === null ? "running" : "gone";
|
|
106
|
-
} catch (e) {
|
|
107
|
-
if (isGone(e)) return "gone";
|
|
108
|
-
throw e;
|
|
109
|
-
}
|
|
110
|
-
},
|
|
111
|
-
|
|
112
|
-
async destroy(id) {
|
|
113
|
-
try {
|
|
114
|
-
await (await sandbox(id)).terminate();
|
|
115
|
-
} catch (e) {
|
|
116
|
-
if (!isGone(e)) throw e;
|
|
117
|
-
}
|
|
118
|
-
},
|
|
119
|
-
};
|
|
120
|
-
};
|
|
121
|
-
export default modal;
|
package/presets/providers/ssh.ts
DELETED
|
@@ -1,54 +0,0 @@
|
|
|
1
|
-
// Boxes on any machine you can ssh into: each box is a directory <root>/<id> on that host, used as $HOME.
|
|
2
|
-
// Needs `ssh <host>` working non-interactively (keys, no password prompt). Boxes share the host, its user and network.
|
|
3
|
-
//
|
|
4
|
-
// options: { host (anything ssh accepts, e.g. a ~/.ssh/config alias), root? (default ~/bottleship-boxes) }
|
|
5
|
-
import { basename } from "node:path";
|
|
6
|
-
import { killBoxScript, run, shq, spawnStream } from "./util.ts";
|
|
7
|
-
import type { ProviderFactory } from "../../src/types.ts";
|
|
8
|
-
|
|
9
|
-
const SSH = ["-o", "BatchMode=yes", "-o", "ServerAliveInterval=15"];
|
|
10
|
-
|
|
11
|
-
const ssh: ProviderFactory = ({ options }) => {
|
|
12
|
-
const host: string = options.host;
|
|
13
|
-
if (!host) throw new Error('ssh provider: missing "host" option');
|
|
14
|
-
const root: string = options.root ?? "~/bottleship-boxes";
|
|
15
|
-
// the remote shell expands a leading ~ through $HOME; the rest stays quoted
|
|
16
|
-
const rootSh = root === "~" ? '"$HOME"' : root.startsWith("~/") ? `"$HOME"/${shq(root.slice(2))}` : shq(root);
|
|
17
|
-
const dir = (id: string) => {
|
|
18
|
-
if (!id || basename(id) !== id || id.startsWith(".")) throw new Error(`ssh provider: bad box id ${JSON.stringify(id)}`);
|
|
19
|
-
return `${rootSh}/${shq(id)}`;
|
|
20
|
-
};
|
|
21
|
-
const groups = (id: string) => `${rootSh}/${shq(`.${id}.pgids`)}`;
|
|
22
|
-
const args = (script: string) => [...SSH, "-T", host, `bash -c ${shq(script)}`];
|
|
23
|
-
const sh = (script: string, stdin?: string | Buffer) => run("ssh", args(script), { stdin, check: false });
|
|
24
|
-
// ssh itself exits 255 when it cannot reach the host
|
|
25
|
-
const reached = <T extends { code: number | null; stderr: string }>(r: T) => {
|
|
26
|
-
if (r.code === 255) throw new Error(`ssh ${host} failed: ${r.stderr.trim().slice(-500)}`);
|
|
27
|
-
return r;
|
|
28
|
-
};
|
|
29
|
-
// sshd starts each session in its own process group; the group and the BOTTLESHIP_BOX mark let destroy find what the box left running
|
|
30
|
-
const inBox = (id: string, script: string) =>
|
|
31
|
-
`cd ${dir(id)} 2>/dev/null || { echo ${shq(`no box ${id} on ${host}`)} >&2; exit 1; }
|
|
32
|
-
ps -o pgid= -p $$ | tr -d ' ' >> ${groups(id)}
|
|
33
|
-
export BOTTLESHIP_BOX=${dir(id)}
|
|
34
|
-
export HOME="$PWD"
|
|
35
|
-
exec bash -c ${shq(script)}`;
|
|
36
|
-
|
|
37
|
-
return {
|
|
38
|
-
async create({ id }) {
|
|
39
|
-
const r = reached(await sh(`mkdir -p ${dir(id)}`));
|
|
40
|
-
if (r.code !== 0) throw new Error(`ssh ${host}: could not create box ${id}: ${r.stderr.trim()}`);
|
|
41
|
-
return { id };
|
|
42
|
-
},
|
|
43
|
-
|
|
44
|
-
exec: async (id, script, { stdin } = {}) => sh(inBox(id, script), stdin),
|
|
45
|
-
|
|
46
|
-
openStream: (id, script) => spawnStream("ssh", args(inBox(id, script))),
|
|
47
|
-
|
|
48
|
-
status: async (id) => (reached(await sh(`test -d ${dir(id)} && echo running || echo gone`)).stdout.trim() === "running" ? "running" : "gone"),
|
|
49
|
-
|
|
50
|
-
destroy: async (id) => reached(await sh(`(${killBoxScript(dir(id), groups(id))})
|
|
51
|
-
rm -rf ${dir(id)} ${groups(id)}`)),
|
|
52
|
-
};
|
|
53
|
-
};
|
|
54
|
-
export default ssh;
|
|
@@ -1,112 +0,0 @@
|
|
|
1
|
-
// Helpers for provider modules, handed to them as ctx.kit.
|
|
2
|
-
import { spawn } from "node:child_process";
|
|
3
|
-
import { PassThrough, Writable } from "node:stream";
|
|
4
|
-
import type { BoxStream, ExecResult, RunOptions } from "../../src/types.ts";
|
|
5
|
-
|
|
6
|
-
export interface SpawnOptions {
|
|
7
|
-
env?: Record<string, string>;
|
|
8
|
-
cwd?: string;
|
|
9
|
-
// own process group, so killBoxScript can tell its processes apart
|
|
10
|
-
detached?: boolean;
|
|
11
|
-
onSpawn?: (pid: number) => void;
|
|
12
|
-
}
|
|
13
|
-
|
|
14
|
-
function spawnPiped(cmd: string, args: string[], { env, cwd, detached, onSpawn }: SpawnOptions = {}) {
|
|
15
|
-
const child = spawn(cmd, args, { stdio: ["pipe", "pipe", "pipe"], cwd, detached, env: env ? { ...process.env, ...env } : process.env });
|
|
16
|
-
if (child.pid) onSpawn?.(child.pid);
|
|
17
|
-
return child;
|
|
18
|
-
}
|
|
19
|
-
|
|
20
|
-
// resolves { code, stdout, stderr }; with check (default true) a non-zero exit rejects
|
|
21
|
-
export function run(cmd: string, args: string[], opts: RunOptions & SpawnOptions = {}): Promise<ExecResult> {
|
|
22
|
-
const { stdin, check = true } = opts;
|
|
23
|
-
return new Promise((resolve, reject) => {
|
|
24
|
-
const child = spawnPiped(cmd, args, opts);
|
|
25
|
-
let stdout = "";
|
|
26
|
-
let stderr = "";
|
|
27
|
-
child.stdout.on("data", (d) => (stdout += d));
|
|
28
|
-
child.stderr.on("data", (d) => (stderr += d));
|
|
29
|
-
child.on("error", reject);
|
|
30
|
-
child.on("close", (code) => {
|
|
31
|
-
if (check && code !== 0) reject(new Error(`${cmd} ${args.slice(0, 3).join(" ")}... exited ${code}: ${stderr.trim().slice(-500)}`));
|
|
32
|
-
else resolve({ code, stdout, stderr });
|
|
33
|
-
});
|
|
34
|
-
child.stdin.on("error", () => {});
|
|
35
|
-
child.stdin.end(stdin ?? "");
|
|
36
|
-
});
|
|
37
|
-
}
|
|
38
|
-
|
|
39
|
-
// kills each process that is verifiably the box's, never a whole recorded group (its id may be reused): marked BOTTLESHIP_BOX=<dir> (Linux /proc, macOS ps -E), or on macOS, where ps hides the env of Apple's binaries, in a recorded group with its cwd in the box
|
|
40
|
-
export const killBoxScript = (d: string, groups: string) => `d=${d}
|
|
41
|
-
g=${groups}
|
|
42
|
-
[ -d "$d" ] || exit 0
|
|
43
|
-
p=$(cd "$d" && pwd -P)
|
|
44
|
-
cd /
|
|
45
|
-
mine() {
|
|
46
|
-
if [ -d /proc/self ]; then
|
|
47
|
-
for e in /proc/[0-9]*; do
|
|
48
|
-
if tr '\\0' '\\n' < "$e/environ" 2>/dev/null | grep -qxF "BOTTLESHIP_BOX=$d"; then echo "\${e#/proc/}"; fi
|
|
49
|
-
done
|
|
50
|
-
else
|
|
51
|
-
ps -A -E -ww -o pid=,command= 2>/dev/null | awk -v d="$d" 'index($0 " ", " BOTTLESHIP_" "BOX=" d " ") { print $1 }'
|
|
52
|
-
[ -f "$g" ] || return 0
|
|
53
|
-
ingroup=$(ps -A -o pid=,pgid= | awk 'NR == FNR { g[$1] = 1; next } ($2 in g) { printf "%s,", $1 }' "$g" -)
|
|
54
|
-
[ -n "$ingroup" ] && lsof -nP -a -d cwd -p "\${ingroup%,}" -Fpn 2>/dev/null | awk -v p="$p/" '/^p/ { pid = substr($0, 2) } /^n/ && index(substr($0, 2) "/", p) == 1 { print pid }'
|
|
55
|
-
fi
|
|
56
|
-
}
|
|
57
|
-
for i in 1 2 3 4 5; do
|
|
58
|
-
pids=$(mine | grep -vxF -e $$ -e $PPID | sort -u)
|
|
59
|
-
[ -z "$pids" ] && break
|
|
60
|
-
kill -9 $pids 2>/dev/null
|
|
61
|
-
sleep 0.2
|
|
62
|
-
done
|
|
63
|
-
true`;
|
|
64
|
-
|
|
65
|
-
// single-quote for sh
|
|
66
|
-
export const shq = (s: unknown): string => `'${String(s).replace(/'/g, `'\\''`)}'`;
|
|
67
|
-
|
|
68
|
-
// fills {name} with the shell-quoted value, {name!} with the raw value, missing values with ""
|
|
69
|
-
export function fill(template: string, values: Record<string, unknown>): string {
|
|
70
|
-
return template.replace(/\{(\w+)(!?)\}/g, (_, k: string, raw: string) => {
|
|
71
|
-
const v = values[k];
|
|
72
|
-
if (v === undefined || v === null) return "";
|
|
73
|
-
return raw ? String(v) : shq(v);
|
|
74
|
-
});
|
|
75
|
-
}
|
|
76
|
-
|
|
77
|
-
// a long-lived child process as a BoxStream, for openStream over a CLI
|
|
78
|
-
export function spawnStream(cmd: string, args: string[], opts?: SpawnOptions): BoxStream {
|
|
79
|
-
const child = spawnPiped(cmd, args, opts);
|
|
80
|
-
child.stdin.on("error", () => {});
|
|
81
|
-
return { stdin: child.stdin, stdout: child.stdout, stderr: child.stderr, close: () => child.kill(), onClose: (cb) => child.on("close", (code) => cb(code)) };
|
|
82
|
-
}
|
|
83
|
-
|
|
84
|
-
// a binary-clean BoxStream over a WebSocket (Node's global undici WebSocket, which accepts { headers })
|
|
85
|
-
export function wsStream(url: string, headers: Record<string, string> = {}): BoxStream {
|
|
86
|
-
const stdout = new PassThrough();
|
|
87
|
-
const stderr = new PassThrough();
|
|
88
|
-
const ws = new WebSocket(url, { headers } as unknown as string[]);
|
|
89
|
-
ws.binaryType = "arraybuffer";
|
|
90
|
-
const queue: Buffer[] = [];
|
|
91
|
-
const closers: (() => void)[] = [];
|
|
92
|
-
ws.addEventListener("open", () => queue.splice(0).forEach((b) => ws.send(b)));
|
|
93
|
-
ws.addEventListener("message", (e) => stdout.write(Buffer.from(e.data as ArrayBuffer)));
|
|
94
|
-
ws.addEventListener("close", () => {
|
|
95
|
-
stdout.end();
|
|
96
|
-
stderr.end();
|
|
97
|
-
closers.forEach((cb) => cb());
|
|
98
|
-
});
|
|
99
|
-
ws.addEventListener("error", () => {});
|
|
100
|
-
const stdin = new Writable({
|
|
101
|
-
write(chunk: Buffer, _enc, cb) {
|
|
102
|
-
if (ws.readyState === WebSocket.OPEN) ws.send(chunk);
|
|
103
|
-
else queue.push(chunk);
|
|
104
|
-
cb();
|
|
105
|
-
},
|
|
106
|
-
final(cb) {
|
|
107
|
-
ws.close();
|
|
108
|
-
cb();
|
|
109
|
-
},
|
|
110
|
-
});
|
|
111
|
-
return { stdin, stdout, stderr, close: () => ws.close(), onClose: (cb) => closers.push(cb) };
|
|
112
|
-
}
|