bottleship 0.0.1 → 0.1.0-prototype.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +15 -0
- package/LICENSE +21 -0
- package/README.md +187 -2
- package/bin/bottleship +2 -0
- package/dist/presets/providers/command.d.ts +3 -0
- package/dist/presets/providers/command.js +39 -0
- package/dist/presets/providers/daytona.d.ts +3 -0
- package/dist/presets/providers/daytona.js +173 -0
- package/dist/presets/providers/docker.d.ts +3 -0
- package/dist/presets/providers/docker.js +31 -0
- package/dist/presets/providers/e2b.d.ts +3 -0
- package/dist/presets/providers/e2b.js +124 -0
- package/dist/presets/providers/exe.d.ts +3 -0
- package/dist/presets/providers/exe.js +28 -0
- package/dist/presets/providers/http.example.d.ts +3 -0
- package/dist/presets/providers/http.example.js +47 -0
- package/dist/presets/providers/local.d.ts +3 -0
- package/dist/presets/providers/local.js +52 -0
- package/dist/presets/providers/modal.d.ts +3 -0
- package/dist/presets/providers/modal.js +117 -0
- package/dist/presets/providers/ssh.d.ts +3 -0
- package/dist/presets/providers/ssh.js +49 -0
- package/dist/presets/providers/util.d.ts +13 -0
- package/dist/presets/providers/util.js +102 -0
- package/dist/src/boxes.d.ts +64 -0
- package/dist/src/boxes.js +265 -0
- package/dist/src/cli.d.ts +1 -0
- package/dist/src/cli.js +202 -0
- package/dist/src/config.d.ts +27 -0
- package/dist/src/config.js +151 -0
- package/dist/src/init.d.ts +13 -0
- package/dist/src/init.js +396 -0
- package/dist/src/paths.d.ts +4 -0
- package/dist/src/paths.js +17 -0
- package/dist/src/secrets.d.ts +6 -0
- package/dist/src/secrets.js +68 -0
- package/dist/src/types.d.ts +120 -0
- package/dist/src/types.js +1 -0
- package/dist/src/ui.d.ts +31 -0
- package/dist/src/ui.js +127 -0
- package/package.json +50 -4
- package/presets/agents/claude.json +12 -0
- package/presets/agents/codex.json +15 -0
- package/presets/agents/cursor.json +14 -0
- package/presets/agents/opencode.json +19 -0
- package/presets/providers/command.ts +43 -0
- package/presets/providers/daytona.ts +181 -0
- package/presets/providers/docker.ts +38 -0
- package/presets/providers/e2b.ts +130 -0
- package/presets/providers/exe.ts +35 -0
- package/presets/providers/http.example.ts +64 -0
- package/presets/providers/local.ts +56 -0
- package/presets/providers/modal.ts +121 -0
- package/presets/providers/ssh.ts +54 -0
- package/presets/providers/util.ts +112 -0
|
@@ -0,0 +1,28 @@
|
|
|
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;
|
|
@@ -0,0 +1,47 @@
|
|
|
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;
|
|
@@ -0,0 +1,52 @@
|
|
|
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;
|
|
@@ -0,0 +1,117 @@
|
|
|
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;
|
|
@@ -0,0 +1,49 @@
|
|
|
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;
|
|
@@ -0,0 +1,13 @@
|
|
|
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;
|
|
@@ -0,0 +1,102 @@
|
|
|
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
|
+
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import { BsError } from "./config.ts";
|
|
2
|
+
import type { Config, LoadedProvider, TemplateSpec } from "./types.ts";
|
|
3
|
+
export interface Repo {
|
|
4
|
+
url: string;
|
|
5
|
+
label: string;
|
|
6
|
+
}
|
|
7
|
+
export interface Box {
|
|
8
|
+
id: string;
|
|
9
|
+
provider: LoadedProvider;
|
|
10
|
+
box: string;
|
|
11
|
+
warning?: string;
|
|
12
|
+
login?: Login;
|
|
13
|
+
}
|
|
14
|
+
export interface Login {
|
|
15
|
+
url: string;
|
|
16
|
+
code?: string;
|
|
17
|
+
agent: string;
|
|
18
|
+
}
|
|
19
|
+
export type AgentState = {
|
|
20
|
+
agent: "running";
|
|
21
|
+
log: string;
|
|
22
|
+
} | {
|
|
23
|
+
agent: "exited";
|
|
24
|
+
exitCode: number;
|
|
25
|
+
log: string;
|
|
26
|
+
} | {
|
|
27
|
+
agent: "stopped";
|
|
28
|
+
log: string;
|
|
29
|
+
} | {
|
|
30
|
+
agent: "waiting_for_login";
|
|
31
|
+
login?: Login;
|
|
32
|
+
log: string;
|
|
33
|
+
} | {
|
|
34
|
+
agent: "login_failed";
|
|
35
|
+
exitCode: number;
|
|
36
|
+
log: string;
|
|
37
|
+
};
|
|
38
|
+
export declare const goneFix = "the box is gone. Start a new one with `bottleship new \"<task>\"` and tell its agent to continue on the branch the old box pushed";
|
|
39
|
+
export declare function templateSpec(cfg: Config, agentName: string): TemplateSpec;
|
|
40
|
+
export declare function currentTemplate(cfg: Config, provider: string): string | undefined;
|
|
41
|
+
export declare function parseId(cfg: Config, handle: string): {
|
|
42
|
+
provider: string;
|
|
43
|
+
box: string;
|
|
44
|
+
};
|
|
45
|
+
export declare function newBoxId(prompt: string): string;
|
|
46
|
+
export declare function openBox(cfg: Config, handle: string): Promise<Box>;
|
|
47
|
+
export interface SetupOptions {
|
|
48
|
+
agent: string;
|
|
49
|
+
prompt: string;
|
|
50
|
+
repo?: Repo;
|
|
51
|
+
dotenv?: Record<string, string>;
|
|
52
|
+
}
|
|
53
|
+
export declare function setupAndStart(cfg: Config, b: Box, { agent: agentName, prompt, repo, dotenv }: SetupOptions): Promise<void>;
|
|
54
|
+
export declare function missingSecrets(names: string[]): BsError;
|
|
55
|
+
export declare function newBox(cfg: Config, opts: SetupOptions & {
|
|
56
|
+
provider: string;
|
|
57
|
+
source?: string;
|
|
58
|
+
}): Promise<Box>;
|
|
59
|
+
export declare function parseLogin(output: string, agent: string): Login | undefined;
|
|
60
|
+
export declare function agentState(b: Box, lines?: number): Promise<AgentState>;
|
|
61
|
+
export declare function loginState(b: Box, setup?: string, seconds?: number): Promise<AgentState>;
|
|
62
|
+
export declare function waitAgent(b: Box, seconds: number, lines?: number): Promise<AgentState>;
|
|
63
|
+
export declare function ask(b: Box, prompt: string): Promise<Login | undefined>;
|
|
64
|
+
export declare const inBox: (script: string) => string;
|