bottleship 0.0.1 → 0.1.0-prototype.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (55) hide show
  1. package/CHANGELOG.md +8 -0
  2. package/LICENSE +21 -0
  3. package/README.md +183 -2
  4. package/bin/bottleship +2 -0
  5. package/dist/presets/providers/command.d.ts +3 -0
  6. package/dist/presets/providers/command.js +39 -0
  7. package/dist/presets/providers/daytona.d.ts +3 -0
  8. package/dist/presets/providers/daytona.js +173 -0
  9. package/dist/presets/providers/docker.d.ts +3 -0
  10. package/dist/presets/providers/docker.js +31 -0
  11. package/dist/presets/providers/e2b.d.ts +3 -0
  12. package/dist/presets/providers/e2b.js +124 -0
  13. package/dist/presets/providers/exe.d.ts +3 -0
  14. package/dist/presets/providers/exe.js +28 -0
  15. package/dist/presets/providers/http.example.d.ts +3 -0
  16. package/dist/presets/providers/http.example.js +47 -0
  17. package/dist/presets/providers/local.d.ts +3 -0
  18. package/dist/presets/providers/local.js +52 -0
  19. package/dist/presets/providers/modal.d.ts +3 -0
  20. package/dist/presets/providers/modal.js +117 -0
  21. package/dist/presets/providers/ssh.d.ts +3 -0
  22. package/dist/presets/providers/ssh.js +49 -0
  23. package/dist/presets/providers/util.d.ts +13 -0
  24. package/dist/presets/providers/util.js +102 -0
  25. package/dist/src/boxes.d.ts +64 -0
  26. package/dist/src/boxes.js +265 -0
  27. package/dist/src/cli.d.ts +1 -0
  28. package/dist/src/cli.js +201 -0
  29. package/dist/src/config.d.ts +27 -0
  30. package/dist/src/config.js +151 -0
  31. package/dist/src/init.d.ts +12 -0
  32. package/dist/src/init.js +345 -0
  33. package/dist/src/paths.d.ts +4 -0
  34. package/dist/src/paths.js +17 -0
  35. package/dist/src/secrets.d.ts +5 -0
  36. package/dist/src/secrets.js +58 -0
  37. package/dist/src/types.d.ts +120 -0
  38. package/dist/src/types.js +1 -0
  39. package/dist/src/ui.d.ts +31 -0
  40. package/dist/src/ui.js +127 -0
  41. package/package.json +50 -4
  42. package/presets/agents/claude.json +12 -0
  43. package/presets/agents/codex.json +15 -0
  44. package/presets/agents/cursor.json +14 -0
  45. package/presets/agents/opencode.json +19 -0
  46. package/presets/providers/command.ts +43 -0
  47. package/presets/providers/daytona.ts +181 -0
  48. package/presets/providers/docker.ts +38 -0
  49. package/presets/providers/e2b.ts +130 -0
  50. package/presets/providers/exe.ts +35 -0
  51. package/presets/providers/http.example.ts +64 -0
  52. package/presets/providers/local.ts +56 -0
  53. package/presets/providers/modal.ts +121 -0
  54. package/presets/providers/ssh.ts +54 -0
  55. 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,3 @@
1
+ import type { ProviderFactory } from "../../src/types.ts";
2
+ declare const corp: ProviderFactory;
3
+ export default corp;
@@ -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,3 @@
1
+ import type { ProviderFactory } from "../../src/types.ts";
2
+ declare const local: ProviderFactory;
3
+ export default local;
@@ -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,3 @@
1
+ import type { ProviderFactory } from "../../src/types.ts";
2
+ declare const modal: ProviderFactory;
3
+ export default modal;
@@ -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,3 @@
1
+ import type { ProviderFactory } from "../../src/types.ts";
2
+ declare const ssh: ProviderFactory;
3
+ export default ssh;
@@ -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;