bottleship 0.1.0-prototype.0 → 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 +17 -0
- package/README.md +118 -113
- package/dist/src/boxes.d.ts +41 -39
- package/dist/src/boxes.js +181 -179
- package/dist/src/cli.js +298 -83
- package/dist/src/config.d.ts +3 -6
- package/dist/src/config.js +19 -85
- package/dist/src/init.d.ts +3 -4
- package/dist/src/init.js +120 -205
- 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/secrets.d.ts +1 -0
- package/dist/src/secrets.js +11 -1
- 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
|
@@ -1,117 +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
|
-
// errors that mean the sandbox id names nothing that runs
|
|
10
|
-
const isGone = (e) => {
|
|
11
|
-
const err = e;
|
|
12
|
-
// nice-grpc NOT_FOUND (5) and INVALID_ARGUMENT (3) for unknown or malformed ids
|
|
13
|
-
return err?.name === "NotFoundError" || err?.code === 5 || err?.code === 3;
|
|
14
|
-
};
|
|
15
|
-
// the FROM image and the instructions after it; Modal builds the rest as one layer on that image
|
|
16
|
-
function splitDockerfile(dockerfile) {
|
|
17
|
-
const lines = dockerfile.split("\n");
|
|
18
|
-
const froms = lines.flatMap((l, i) => (/^\s*FROM\s/i.test(l) ? [i] : []));
|
|
19
|
-
if (froms.length !== 1)
|
|
20
|
-
throw new Error(`modal templates take a Dockerfile with exactly one FROM, this one has ${froms.length}`);
|
|
21
|
-
const before = lines.slice(0, froms[0]).filter((l) => l.trim() && !l.trim().startsWith("#"));
|
|
22
|
-
if (before.length)
|
|
23
|
-
throw new Error(`modal templates take nothing but comments before FROM, got: ${before[0]}`);
|
|
24
|
-
const m = /^\s*FROM\s+(\S+)(\s+AS\s+\S+)?\s*$/i.exec(lines[froms[0]]);
|
|
25
|
-
if (!m || m[1].startsWith("--"))
|
|
26
|
-
throw new Error(`modal templates take a plain FROM <image>, got: ${lines[froms[0]]}`);
|
|
27
|
-
return { from: m[1], commands: lines.slice(froms[0] + 1) };
|
|
28
|
-
}
|
|
29
|
-
const modal = ({ options, secret }) => {
|
|
30
|
-
const appName = options.app ?? "bottleship";
|
|
31
|
-
// debian based, runs as root with HOME=/root, has bash, git, curl, xz and node already
|
|
32
|
-
const imageTag = options.image ?? "node:22-bookworm";
|
|
33
|
-
// ponytail: a box dies at its timeout (Modal caps it at 24h) even if the agent still runs; snapshot and restore to go longer
|
|
34
|
-
const timeoutMs = Math.min(Number(options.timeoutMinutes ?? 120), 24 * 60) * 60_000;
|
|
35
|
-
let client;
|
|
36
|
-
const getClient = () => (client ??= import("modal").then(({ ModalClient }) => new ModalClient({
|
|
37
|
-
tokenId: options.tokenId ? secret(options.tokenId) : undefined,
|
|
38
|
-
tokenSecret: options.tokenSecret ? secret(options.tokenSecret) : undefined,
|
|
39
|
-
})));
|
|
40
|
-
const sandbox = async (id) => (await getClient()).sandboxes.fromId(id);
|
|
41
|
-
const spawn = async (id, script) => (await sandbox(id)).exec(["bash", "-c", script], { mode: "binary" });
|
|
42
|
-
return {
|
|
43
|
-
// a named image is scoped to the Modal workspace, so the name cannot resolve to someone else's image
|
|
44
|
-
// ponytail: two inits racing on one name both build and publish; harmless, since Modal caches layers by content and both are the same image
|
|
45
|
-
async template({ name, dockerfile }) {
|
|
46
|
-
const mc = await getClient();
|
|
47
|
-
try {
|
|
48
|
-
await mc.images.fromName(name);
|
|
49
|
-
return name;
|
|
50
|
-
}
|
|
51
|
-
catch (e) {
|
|
52
|
-
if (e?.name !== "NotFoundError")
|
|
53
|
-
throw e;
|
|
54
|
-
}
|
|
55
|
-
const { from, commands } = splitDockerfile(dockerfile);
|
|
56
|
-
const app = await mc.apps.fromName(appName, { createIfMissing: true });
|
|
57
|
-
const image = await mc.images.fromRegistry(from).dockerfileCommands(commands).build(app);
|
|
58
|
-
await image.publish(name);
|
|
59
|
-
return name;
|
|
60
|
-
},
|
|
61
|
-
async create({ template }) {
|
|
62
|
-
const mc = await getClient();
|
|
63
|
-
const app = await mc.apps.fromName(appName, { createIfMissing: true });
|
|
64
|
-
const image = template ? await mc.images.fromName(template) : mc.images.fromRegistry(imageTag);
|
|
65
|
-
// no idleTimeoutMs: the detached agent runs no exec, so Modal would count it as idle
|
|
66
|
-
const sb = await mc.sandboxes.create(app, image, { timeoutMs, tags: { bottleship: "1" } });
|
|
67
|
-
// the first exec waits until the sandbox is scheduled and its command router answers
|
|
68
|
-
await (await sb.exec(["true"])).wait();
|
|
69
|
-
return { id: sb.sandboxId };
|
|
70
|
-
},
|
|
71
|
-
async exec(id, script, { stdin } = {}) {
|
|
72
|
-
const p = await spawn(id, script);
|
|
73
|
-
const w = p.stdin.getWriter();
|
|
74
|
-
if (stdin !== undefined && stdin.length)
|
|
75
|
-
await w.write(typeof stdin === "string" ? Buffer.from(stdin) : stdin);
|
|
76
|
-
await w.close();
|
|
77
|
-
const [out, err, code] = await Promise.all([p.stdout.readBytes(), p.stderr.readBytes(), p.wait()]);
|
|
78
|
-
return { code, stdout: Buffer.from(out).toString(), stderr: Buffer.from(err).toString() };
|
|
79
|
-
},
|
|
80
|
-
async openStream(id, script) {
|
|
81
|
-
const p = await spawn(id, script);
|
|
82
|
-
const stdin = Writable.fromWeb(p.stdin);
|
|
83
|
-
const stdout = Readable.fromWeb(p.stdout);
|
|
84
|
-
const stderr = Readable.fromWeb(p.stderr);
|
|
85
|
-
stdin.on("error", () => { });
|
|
86
|
-
const done = p.wait().catch(() => null);
|
|
87
|
-
return {
|
|
88
|
-
stdin,
|
|
89
|
-
stdout,
|
|
90
|
-
stderr,
|
|
91
|
-
// ponytail: the SDK cannot kill an exec, so close only drops our end; the remote process runs on until it reads EOF or exits
|
|
92
|
-
close: () => [stdin, stdout, stderr].forEach((s) => s.destroy()),
|
|
93
|
-
onClose: (cb) => void done.then(cb),
|
|
94
|
-
};
|
|
95
|
-
},
|
|
96
|
-
async status(id) {
|
|
97
|
-
try {
|
|
98
|
-
return (await (await sandbox(id)).poll()) === null ? "running" : "gone";
|
|
99
|
-
}
|
|
100
|
-
catch (e) {
|
|
101
|
-
if (isGone(e))
|
|
102
|
-
return "gone";
|
|
103
|
-
throw e;
|
|
104
|
-
}
|
|
105
|
-
},
|
|
106
|
-
async destroy(id) {
|
|
107
|
-
try {
|
|
108
|
-
await (await sandbox(id)).terminate();
|
|
109
|
-
}
|
|
110
|
-
catch (e) {
|
|
111
|
-
if (!isGone(e))
|
|
112
|
-
throw e;
|
|
113
|
-
}
|
|
114
|
-
},
|
|
115
|
-
};
|
|
116
|
-
};
|
|
117
|
-
export default modal;
|
|
@@ -1,49 +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.js";
|
|
7
|
-
const SSH = ["-o", "BatchMode=yes", "-o", "ServerAliveInterval=15"];
|
|
8
|
-
const ssh = ({ options }) => {
|
|
9
|
-
const host = options.host;
|
|
10
|
-
if (!host)
|
|
11
|
-
throw new Error('ssh provider: missing "host" option');
|
|
12
|
-
const root = options.root ?? "~/bottleship-boxes";
|
|
13
|
-
// the remote shell expands a leading ~ through $HOME; the rest stays quoted
|
|
14
|
-
const rootSh = root === "~" ? '"$HOME"' : root.startsWith("~/") ? `"$HOME"/${shq(root.slice(2))}` : shq(root);
|
|
15
|
-
const dir = (id) => {
|
|
16
|
-
if (!id || basename(id) !== id || id.startsWith("."))
|
|
17
|
-
throw new Error(`ssh provider: bad box id ${JSON.stringify(id)}`);
|
|
18
|
-
return `${rootSh}/${shq(id)}`;
|
|
19
|
-
};
|
|
20
|
-
const groups = (id) => `${rootSh}/${shq(`.${id}.pgids`)}`;
|
|
21
|
-
const args = (script) => [...SSH, "-T", host, `bash -c ${shq(script)}`];
|
|
22
|
-
const sh = (script, stdin) => run("ssh", args(script), { stdin, check: false });
|
|
23
|
-
// ssh itself exits 255 when it cannot reach the host
|
|
24
|
-
const reached = (r) => {
|
|
25
|
-
if (r.code === 255)
|
|
26
|
-
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, script) => `cd ${dir(id)} 2>/dev/null || { echo ${shq(`no box ${id} on ${host}`)} >&2; exit 1; }
|
|
31
|
-
ps -o pgid= -p $$ | tr -d ' ' >> ${groups(id)}
|
|
32
|
-
export BOTTLESHIP_BOX=${dir(id)}
|
|
33
|
-
export HOME="$PWD"
|
|
34
|
-
exec bash -c ${shq(script)}`;
|
|
35
|
-
return {
|
|
36
|
-
async create({ id }) {
|
|
37
|
-
const r = reached(await sh(`mkdir -p ${dir(id)}`));
|
|
38
|
-
if (r.code !== 0)
|
|
39
|
-
throw new Error(`ssh ${host}: could not create box ${id}: ${r.stderr.trim()}`);
|
|
40
|
-
return { id };
|
|
41
|
-
},
|
|
42
|
-
exec: async (id, script, { stdin } = {}) => sh(inBox(id, script), stdin),
|
|
43
|
-
openStream: (id, script) => spawnStream("ssh", args(inBox(id, script))),
|
|
44
|
-
status: async (id) => (reached(await sh(`test -d ${dir(id)} && echo running || echo gone`)).stdout.trim() === "running" ? "running" : "gone"),
|
|
45
|
-
destroy: async (id) => reached(await sh(`(${killBoxScript(dir(id), groups(id))})
|
|
46
|
-
rm -rf ${dir(id)} ${groups(id)}`)),
|
|
47
|
-
};
|
|
48
|
-
};
|
|
49
|
-
export default ssh;
|
|
@@ -1,13 +0,0 @@
|
|
|
1
|
-
import type { BoxStream, ExecResult, RunOptions } from "../../src/types.ts";
|
|
2
|
-
export interface SpawnOptions {
|
|
3
|
-
env?: Record<string, string>;
|
|
4
|
-
cwd?: string;
|
|
5
|
-
detached?: boolean;
|
|
6
|
-
onSpawn?: (pid: number) => void;
|
|
7
|
-
}
|
|
8
|
-
export declare function run(cmd: string, args: string[], opts?: RunOptions & SpawnOptions): Promise<ExecResult>;
|
|
9
|
-
export declare const killBoxScript: (d: string, groups: string) => string;
|
|
10
|
-
export declare const shq: (s: unknown) => string;
|
|
11
|
-
export declare function fill(template: string, values: Record<string, unknown>): string;
|
|
12
|
-
export declare function spawnStream(cmd: string, args: string[], opts?: SpawnOptions): BoxStream;
|
|
13
|
-
export declare function wsStream(url: string, headers?: Record<string, string>): BoxStream;
|
|
@@ -1,102 +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
|
-
function spawnPiped(cmd, args, { env, cwd, detached, onSpawn } = {}) {
|
|
5
|
-
const child = spawn(cmd, args, { stdio: ["pipe", "pipe", "pipe"], cwd, detached, env: env ? { ...process.env, ...env } : process.env });
|
|
6
|
-
if (child.pid)
|
|
7
|
-
onSpawn?.(child.pid);
|
|
8
|
-
return child;
|
|
9
|
-
}
|
|
10
|
-
// resolves { code, stdout, stderr }; with check (default true) a non-zero exit rejects
|
|
11
|
-
export function run(cmd, args, opts = {}) {
|
|
12
|
-
const { stdin, check = true } = opts;
|
|
13
|
-
return new Promise((resolve, reject) => {
|
|
14
|
-
const child = spawnPiped(cmd, args, opts);
|
|
15
|
-
let stdout = "";
|
|
16
|
-
let stderr = "";
|
|
17
|
-
child.stdout.on("data", (d) => (stdout += d));
|
|
18
|
-
child.stderr.on("data", (d) => (stderr += d));
|
|
19
|
-
child.on("error", reject);
|
|
20
|
-
child.on("close", (code) => {
|
|
21
|
-
if (check && code !== 0)
|
|
22
|
-
reject(new Error(`${cmd} ${args.slice(0, 3).join(" ")}... exited ${code}: ${stderr.trim().slice(-500)}`));
|
|
23
|
-
else
|
|
24
|
-
resolve({ code, stdout, stderr });
|
|
25
|
-
});
|
|
26
|
-
child.stdin.on("error", () => { });
|
|
27
|
-
child.stdin.end(stdin ?? "");
|
|
28
|
-
});
|
|
29
|
-
}
|
|
30
|
-
// 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
|
|
31
|
-
export const killBoxScript = (d, groups) => `d=${d}
|
|
32
|
-
g=${groups}
|
|
33
|
-
[ -d "$d" ] || exit 0
|
|
34
|
-
p=$(cd "$d" && pwd -P)
|
|
35
|
-
cd /
|
|
36
|
-
mine() {
|
|
37
|
-
if [ -d /proc/self ]; then
|
|
38
|
-
for e in /proc/[0-9]*; do
|
|
39
|
-
if tr '\\0' '\\n' < "$e/environ" 2>/dev/null | grep -qxF "BOTTLESHIP_BOX=$d"; then echo "\${e#/proc/}"; fi
|
|
40
|
-
done
|
|
41
|
-
else
|
|
42
|
-
ps -A -E -ww -o pid=,command= 2>/dev/null | awk -v d="$d" 'index($0 " ", " BOTTLESHIP_" "BOX=" d " ") { print $1 }'
|
|
43
|
-
[ -f "$g" ] || return 0
|
|
44
|
-
ingroup=$(ps -A -o pid=,pgid= | awk 'NR == FNR { g[$1] = 1; next } ($2 in g) { printf "%s,", $1 }' "$g" -)
|
|
45
|
-
[ -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 }'
|
|
46
|
-
fi
|
|
47
|
-
}
|
|
48
|
-
for i in 1 2 3 4 5; do
|
|
49
|
-
pids=$(mine | grep -vxF -e $$ -e $PPID | sort -u)
|
|
50
|
-
[ -z "$pids" ] && break
|
|
51
|
-
kill -9 $pids 2>/dev/null
|
|
52
|
-
sleep 0.2
|
|
53
|
-
done
|
|
54
|
-
true`;
|
|
55
|
-
// single-quote for sh
|
|
56
|
-
export const shq = (s) => `'${String(s).replace(/'/g, `'\\''`)}'`;
|
|
57
|
-
// fills {name} with the shell-quoted value, {name!} with the raw value, missing values with ""
|
|
58
|
-
export function fill(template, values) {
|
|
59
|
-
return template.replace(/\{(\w+)(!?)\}/g, (_, k, raw) => {
|
|
60
|
-
const v = values[k];
|
|
61
|
-
if (v === undefined || v === null)
|
|
62
|
-
return "";
|
|
63
|
-
return raw ? String(v) : shq(v);
|
|
64
|
-
});
|
|
65
|
-
}
|
|
66
|
-
// a long-lived child process as a BoxStream, for openStream over a CLI
|
|
67
|
-
export function spawnStream(cmd, args, opts) {
|
|
68
|
-
const child = spawnPiped(cmd, args, opts);
|
|
69
|
-
child.stdin.on("error", () => { });
|
|
70
|
-
return { stdin: child.stdin, stdout: child.stdout, stderr: child.stderr, close: () => child.kill(), onClose: (cb) => child.on("close", (code) => cb(code)) };
|
|
71
|
-
}
|
|
72
|
-
// a binary-clean BoxStream over a WebSocket (Node's global undici WebSocket, which accepts { headers })
|
|
73
|
-
export function wsStream(url, headers = {}) {
|
|
74
|
-
const stdout = new PassThrough();
|
|
75
|
-
const stderr = new PassThrough();
|
|
76
|
-
const ws = new WebSocket(url, { headers });
|
|
77
|
-
ws.binaryType = "arraybuffer";
|
|
78
|
-
const queue = [];
|
|
79
|
-
const closers = [];
|
|
80
|
-
ws.addEventListener("open", () => queue.splice(0).forEach((b) => ws.send(b)));
|
|
81
|
-
ws.addEventListener("message", (e) => stdout.write(Buffer.from(e.data)));
|
|
82
|
-
ws.addEventListener("close", () => {
|
|
83
|
-
stdout.end();
|
|
84
|
-
stderr.end();
|
|
85
|
-
closers.forEach((cb) => cb());
|
|
86
|
-
});
|
|
87
|
-
ws.addEventListener("error", () => { });
|
|
88
|
-
const stdin = new Writable({
|
|
89
|
-
write(chunk, _enc, cb) {
|
|
90
|
-
if (ws.readyState === WebSocket.OPEN)
|
|
91
|
-
ws.send(chunk);
|
|
92
|
-
else
|
|
93
|
-
queue.push(chunk);
|
|
94
|
-
cb();
|
|
95
|
-
},
|
|
96
|
-
final(cb) {
|
|
97
|
-
ws.close();
|
|
98
|
-
cb();
|
|
99
|
-
},
|
|
100
|
-
});
|
|
101
|
-
return { stdin, stdout, stderr, close: () => ws.close(), onClose: (cb) => closers.push(cb) };
|
|
102
|
-
}
|
|
@@ -1,43 +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.ts";
|
|
18
|
-
import type { ProviderFactory, RunOptions } from "../../src/types.ts";
|
|
19
|
-
|
|
20
|
-
const command: ProviderFactory = ({ options }) => {
|
|
21
|
-
for (const k of ["create", "exec", "destroy"]) if (!options[k]) throw new Error(`command provider: missing "${k}" template`);
|
|
22
|
-
const sh = (tpl: string, values: Record<string, unknown>, opts?: RunOptions) => run("sh", ["-c", fill(tpl, values)], opts);
|
|
23
|
-
|
|
24
|
-
return {
|
|
25
|
-
async create({ id }) {
|
|
26
|
-
const out = (await sh(options.create, { id })).stdout.trim();
|
|
27
|
-
try {
|
|
28
|
-
return { id: JSON.parse(out).id ?? id };
|
|
29
|
-
} catch {
|
|
30
|
-
return { id: out && !/\s/.test(out) ? out : id };
|
|
31
|
-
}
|
|
32
|
-
},
|
|
33
|
-
|
|
34
|
-
exec: (id, script, { stdin } = {}) => sh(options.exec, { id, script }, { stdin, check: false }),
|
|
35
|
-
|
|
36
|
-
openStream: (id, script) => spawnStream("sh", ["-c", fill(options.stream ?? options.exec, { id, script })]),
|
|
37
|
-
|
|
38
|
-
...(options.status && { status: async (id: string) => ((await sh(options.status, { id }, { check: false })).code === 0 ? "running" : "gone") }),
|
|
39
|
-
|
|
40
|
-
destroy: (id) => sh(options.destroy, { id }, { check: false }),
|
|
41
|
-
};
|
|
42
|
-
};
|
|
43
|
-
export default command;
|
|
@@ -1,181 +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.ts";
|
|
17
|
-
import type { Sandbox } from "@daytonaio/sdk";
|
|
18
|
-
import type { BoxStream, ExecResult, ProviderFactory } from "../../src/types.ts";
|
|
19
|
-
|
|
20
|
-
const GONE = new Set(["destroyed", "destroying", "error", "build_failed"]);
|
|
21
|
-
const RESTARTABLE = new Set(["stopped", "archived", "paused"]);
|
|
22
|
-
const hex = () => randomBytes(6).toString("hex");
|
|
23
|
-
const SNAPSHOT_BUSY = new Set(["pending", "building", "pulling", "snapshotting", "removing"]);
|
|
24
|
-
|
|
25
|
-
const daytona: ProviderFactory = async ({ options, secret, log }) => {
|
|
26
|
-
if (!options.apiKey) throw new Error('daytona needs an apiKey option, e.g. "apiKey": { "store": "daytona" }');
|
|
27
|
-
// loaded here so other providers never pay for the SDK
|
|
28
|
-
const { Daytona, DaytonaNotFoundError, Image } = await import("@daytonaio/sdk");
|
|
29
|
-
const client = new Daytona({ apiKey: secret(options.apiKey), apiUrl: options.apiUrl, target: options.target });
|
|
30
|
-
// 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
|
|
31
|
-
const autoStopInterval: number = options.autoStopMinutes ?? 0;
|
|
32
|
-
|
|
33
|
-
const cache = new Map<string, Sandbox>();
|
|
34
|
-
const lookup = async (id: string): Promise<Sandbox | undefined> => {
|
|
35
|
-
try {
|
|
36
|
-
const sb = await client.get(id);
|
|
37
|
-
cache.set(id, sb);
|
|
38
|
-
return sb;
|
|
39
|
-
} catch (e) {
|
|
40
|
-
if (e instanceof DaytonaNotFoundError) return undefined;
|
|
41
|
-
throw e;
|
|
42
|
-
}
|
|
43
|
-
};
|
|
44
|
-
|
|
45
|
-
// a sandbox that can run commands, started again if it was stopped or archived
|
|
46
|
-
async function started(id: string): Promise<Sandbox> {
|
|
47
|
-
const cached = cache.get(id);
|
|
48
|
-
if (cached?.state === "started") return cached;
|
|
49
|
-
const sb = await lookup(id);
|
|
50
|
-
if (!sb || GONE.has(sb.state ?? "")) throw new Error(`daytona sandbox ${id} is gone`);
|
|
51
|
-
if (RESTARTABLE.has(sb.state ?? "")) await sb.start(600);
|
|
52
|
-
else await sb.waitUntilStarted(600);
|
|
53
|
-
return sb;
|
|
54
|
-
}
|
|
55
|
-
|
|
56
|
-
// drops the cached sandbox on failure, so the next call looks its state up again
|
|
57
|
-
async function withBox<T>(id: string, fn: (sb: Sandbox) => Promise<T>): Promise<T> {
|
|
58
|
-
try {
|
|
59
|
-
return await fn(await started(id));
|
|
60
|
-
} catch (e) {
|
|
61
|
-
cache.delete(id);
|
|
62
|
-
throw e;
|
|
63
|
-
}
|
|
64
|
-
}
|
|
65
|
-
|
|
66
|
-
// the snapshot's state, or undefined once there is none by that name
|
|
67
|
-
const snapshotState = (name: string) =>
|
|
68
|
-
client.snapshot.get(name).then((s) => s.state as string, (e) => {
|
|
69
|
-
if (e instanceof DaytonaNotFoundError) return undefined;
|
|
70
|
-
throw e;
|
|
71
|
-
});
|
|
72
|
-
|
|
73
|
-
// ponytail: a snapshot another init is still building is polled for at most 30 minutes; raise it if agent installs get that slow
|
|
74
|
-
async function settled(name: string): Promise<string | undefined> {
|
|
75
|
-
for (let i = 0; i < 900; i++) {
|
|
76
|
-
const state = await snapshotState(name);
|
|
77
|
-
if (!state || !SNAPSHOT_BUSY.has(state)) 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
|
-
|
|
83
|
-
return {
|
|
84
|
-
// snapshots belong to the organization, so a name cannot resolve to another organization's snapshot
|
|
85
|
-
async template({ name, dockerfile }) {
|
|
86
|
-
let state = await settled(name);
|
|
87
|
-
// Daytona deactivates a snapshot after 2 weeks without use
|
|
88
|
-
if (state === "inactive") {
|
|
89
|
-
await client.snapshot.activate(name);
|
|
90
|
-
state = await settled(name);
|
|
91
|
-
}
|
|
92
|
-
if (state === "active") 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
|
-
} finally {
|
|
104
|
-
rmSync(dir, { recursive: true, force: true });
|
|
105
|
-
}
|
|
106
|
-
return name;
|
|
107
|
-
},
|
|
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
|
-
|
|
118
|
-
// 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
|
|
119
|
-
exec: (id, script, { stdin } = {}) =>
|
|
120
|
-
withBox(id, async (sb): Promise<ExecResult> => {
|
|
121
|
-
// a fresh 0700 dir, since stdin carries secrets (env.sh) and output may too
|
|
122
|
-
const d = `/tmp/bottleship-${hex()}`;
|
|
123
|
-
if (stdin !== undefined) {
|
|
124
|
-
await sb.fs.createFolder(d, "700");
|
|
125
|
-
await sb.fs.uploadFile(Buffer.from(stdin), `${d}/in`);
|
|
126
|
-
}
|
|
127
|
-
const r = await sb.process.executeCommand(
|
|
128
|
-
`${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}`,
|
|
129
|
-
);
|
|
130
|
-
const [code, out, err] = r.result.split("\n");
|
|
131
|
-
if (r.exitCode !== 0 || !/^\d+$/.test(code ?? "")) throw new Error(`daytona exec in ${id} failed (exit ${r.exitCode}): ${r.result.slice(-500)}`);
|
|
132
|
-
return { code: Number(code), stdout: Buffer.from(out ?? "", "base64").toString(), stderr: Buffer.from(err ?? "", "base64").toString() };
|
|
133
|
-
}),
|
|
134
|
-
|
|
135
|
-
// a session command: stdin goes as base64 lines decoded in the box, so it stays binary-clean and "." ends it
|
|
136
|
-
// 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
|
|
137
|
-
openStream: (id, script) =>
|
|
138
|
-
withBox(id, async (sb): Promise<BoxStream> => {
|
|
139
|
-
const session = `bottleship-${hex()}`;
|
|
140
|
-
await sb.process.createSession(session);
|
|
141
|
-
const wrapper = `exec 3< <(while IFS= read -r l; do [ "$l" = . ] && break; printf %s "$l" | base64 -d; done)
|
|
142
|
-
bash -c ${shq(script)} <&3; c=$?; kill $! 2>/dev/null; exit $c`;
|
|
143
|
-
const { cmdId } = await sb.process.executeSessionCommand(session, { command: `bash -c ${shq(wrapper)}`, runAsync: true, suppressInputEcho: true });
|
|
144
|
-
const send = (line: string) => sb.process.sendSessionCommandInput(session, cmdId!, line).catch(() => {});
|
|
145
|
-
|
|
146
|
-
const stdout = new PassThrough();
|
|
147
|
-
const stderr = new PassThrough();
|
|
148
|
-
const closers: ((code?: number | null) => void)[] = [];
|
|
149
|
-
const end = () => sb.process.deleteSession(session).catch(() => {});
|
|
150
|
-
const stdin = new Writable({
|
|
151
|
-
write: (chunk: Buffer, _enc, cb) => void send(chunk.toString("base64") + "\n").then(() => cb()),
|
|
152
|
-
final: (cb) => void send(".\n").then(() => cb()),
|
|
153
|
-
});
|
|
154
|
-
|
|
155
|
-
sb.process
|
|
156
|
-
.getSessionCommandLogs(session, cmdId!, (s) => stdout.write(s), (s) => stderr.write(s))
|
|
157
|
-
.catch(() => {})
|
|
158
|
-
.then(() => sb.process.getSessionCommand(session, cmdId!).then((c) => c.exitCode ?? null, () => null))
|
|
159
|
-
.then((code) => {
|
|
160
|
-
stdout.end();
|
|
161
|
-
stderr.end();
|
|
162
|
-
closers.forEach((cb) => cb(code));
|
|
163
|
-
void end();
|
|
164
|
-
});
|
|
165
|
-
return { stdin, stdout, stderr, close: () => void end(), onClose: (cb) => closers.push(cb) };
|
|
166
|
-
}),
|
|
167
|
-
|
|
168
|
-
// a stopped or archived sandbox still counts as running, since exec starts it again
|
|
169
|
-
async status(id) {
|
|
170
|
-
const sb = await lookup(id);
|
|
171
|
-
return sb && !GONE.has(sb.state ?? "") ? "running" : "gone";
|
|
172
|
-
},
|
|
173
|
-
|
|
174
|
-
async destroy(id) {
|
|
175
|
-
const sb = await lookup(id);
|
|
176
|
-
cache.delete(id);
|
|
177
|
-
if (sb && !GONE.has(sb.state ?? "")) await sb.delete(120);
|
|
178
|
-
},
|
|
179
|
-
};
|
|
180
|
-
};
|
|
181
|
-
export default daytona;
|
|
@@ -1,38 +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.ts";
|
|
6
|
-
import type { ProviderFactory } from "../../src/types.ts";
|
|
7
|
-
|
|
8
|
-
const docker: ProviderFactory = ({ options }) => {
|
|
9
|
-
const image: string = options.image ?? "ghcr.io/boldsoftware/exeuntu:latest";
|
|
10
|
-
const user: string | undefined = options.user ?? (image.includes("exeuntu") ? "exedev" : undefined);
|
|
11
|
-
// execs run as the container's user, set at create
|
|
12
|
-
const execArgs = (id: string, script: string) => ["exec", "-i", id, "bash", "-c", script];
|
|
13
|
-
|
|
14
|
-
return {
|
|
15
|
-
async template({ name, dockerfile }) {
|
|
16
|
-
if ((await run("docker", ["image", "inspect", name], { check: false })).code !== 0) await run("docker", ["build", "-q", "-t", name, "-"], { stdin: dockerfile });
|
|
17
|
-
return name;
|
|
18
|
-
},
|
|
19
|
-
|
|
20
|
-
async create({ id, template }) {
|
|
21
|
-
const from = template ? [template] : [...(user ? ["--user", user] : []), image];
|
|
22
|
-
await run("docker", ["run", "-d", "--name", id, "--label", "bottleship=1", "--entrypoint", "sleep", ...from, "infinity"]);
|
|
23
|
-
return { id };
|
|
24
|
-
},
|
|
25
|
-
|
|
26
|
-
exec: (id, script, { stdin } = {}) => run("docker", execArgs(id, script), { stdin, check: false }),
|
|
27
|
-
|
|
28
|
-
openStream: (id, script) => spawnStream("docker", execArgs(id, script)),
|
|
29
|
-
|
|
30
|
-
async status(id) {
|
|
31
|
-
const r = await run("docker", ["inspect", "-f", "{{.State.Running}}", id], { check: false });
|
|
32
|
-
return r.code === 0 && r.stdout.trim() === "true" ? "running" : "gone";
|
|
33
|
-
},
|
|
34
|
-
|
|
35
|
-
destroy: (id) => run("docker", ["rm", "-f", id], { check: false }),
|
|
36
|
-
};
|
|
37
|
-
};
|
|
38
|
-
export default docker;
|