bottleship 0.1.0-prototype.1 → 0.1.0-prototype.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +10 -0
- package/README.md +118 -117
- package/dist/src/boxes.d.ts +41 -39
- package/dist/src/boxes.js +181 -179
- package/dist/src/cli.js +298 -84
- package/dist/src/config.d.ts +3 -6
- package/dist/src/config.js +19 -85
- package/dist/src/init.d.ts +2 -4
- package/dist/src/init.js +92 -228
- package/dist/src/local.d.ts +8 -0
- package/dist/src/local.js +24 -0
- package/dist/src/onboard.d.ts +5 -0
- package/dist/src/onboard.js +25 -0
- package/dist/src/paths.d.ts +0 -1
- package/dist/src/paths.js +0 -1
- package/dist/src/provider.d.ts +16 -0
- package/dist/src/provider.js +81 -0
- package/dist/src/repo.d.ts +21 -0
- package/dist/src/repo.js +75 -0
- package/dist/src/review/comments.d.ts +66 -0
- package/dist/src/review/comments.js +344 -0
- package/dist/src/review/contract.d.ts +82 -0
- package/dist/src/review/contract.js +189 -0
- package/dist/src/review/github.d.ts +120 -0
- package/dist/src/review/github.js +160 -0
- package/dist/src/review/local.d.ts +14 -0
- package/dist/src/review/local.js +70 -0
- package/dist/src/review/loop.d.ts +12 -0
- package/dist/src/review/loop.js +119 -0
- package/dist/src/review/pr.d.ts +14 -0
- package/dist/src/review/pr.js +68 -0
- package/dist/src/review/prompt.d.ts +15 -0
- package/dist/src/review/prompt.js +48 -0
- package/dist/src/review/serve.d.ts +1 -0
- package/dist/src/review/serve.js +8 -0
- package/dist/src/review/server.d.ts +14 -0
- package/dist/src/review/server.js +484 -0
- package/dist/src/review/setting-up.d.ts +4 -0
- package/dist/src/review/setting-up.js +74 -0
- package/dist/src/review/start.d.ts +32 -0
- package/dist/src/review/start.js +140 -0
- package/dist/src/review/target.d.ts +20 -0
- package/dist/src/review/target.js +66 -0
- package/dist/src/review/template.d.ts +3 -0
- package/dist/src/review/template.js +13 -0
- package/dist/src/review/terminal.d.ts +4 -0
- package/dist/src/review/terminal.js +91 -0
- package/dist/src/types.d.ts +1 -71
- package/dist/src/ui.d.ts +6 -1
- package/dist/src/ui.js +22 -23
- package/dist/src/util.d.ts +11 -0
- package/dist/src/util.js +40 -0
- package/package.json +7 -14
- package/presets/agents/claude.json +1 -5
- package/presets/agents/codex.json +1 -10
- package/presets/agents/cursor.json +1 -9
- package/presets/agents/opencode.json +1 -14
- package/presets/providers/docker/create +12 -0
- package/presets/providers/docker/destroy +2 -0
- package/presets/providers/docker/exec +2 -0
- package/presets/providers/docker/list +4 -0
- package/presets/providers/docker/status +2 -0
- package/presets/providers/docker/url +4 -0
- package/review-app/KIT.md +106 -0
- package/review-app/WRITING.md +93 -0
- package/review-app/examples/rply-16.tsx +98 -0
- package/review-app/index.html +15 -0
- package/review-app/package-lock.json +2539 -0
- package/review-app/package.json +24 -0
- package/review-app/playground/fixtures/excalidraw-12602/data/box.json +1 -0
- package/review-app/playground/fixtures/excalidraw-12602/data/pr.diff +91 -0
- package/review-app/playground/fixtures/excalidraw-12602/data/pr.json +62 -0
- package/review-app/playground/fixtures/excalidraw-12602/data/review-comments.json +44 -0
- package/review-app/playground/fixtures/excalidraw-12602/events.json +1 -0
- package/review-app/playground/fixtures/excalidraw-12602/files/shots/base-ar-zoom.png +0 -0
- package/review-app/playground/fixtures/excalidraw-12602/files/shots/base-en-US-zoom.png +0 -0
- package/review-app/playground/fixtures/excalidraw-12602/files/shots/base-he-IL-zoom.png +0 -0
- package/review-app/playground/fixtures/excalidraw-12602/files/shots/base-he-dark.png +0 -0
- package/review-app/playground/fixtures/excalidraw-12602/files/shots/base-he-hover.png +0 -0
- package/review-app/playground/fixtures/excalidraw-12602/files/shots/base-he-page.png +0 -0
- package/review-app/playground/fixtures/excalidraw-12602/files/shots/base-he-tip.png +0 -0
- package/review-app/playground/fixtures/excalidraw-12602/files/shots/base-he-zen.png +0 -0
- package/review-app/playground/fixtures/excalidraw-12602/files/shots/head-ar-zoom.png +0 -0
- package/review-app/playground/fixtures/excalidraw-12602/files/shots/head-en-US-zoom.png +0 -0
- package/review-app/playground/fixtures/excalidraw-12602/files/shots/head-he-IL-zoom.png +0 -0
- package/review-app/playground/fixtures/excalidraw-12602/files/shots/head-he-dark.png +0 -0
- package/review-app/playground/fixtures/excalidraw-12602/files/shots/head-he-hover.png +0 -0
- package/review-app/playground/fixtures/excalidraw-12602/files/shots/head-he-page.png +0 -0
- package/review-app/playground/fixtures/excalidraw-12602/files/shots/head-he-tip.png +0 -0
- package/review-app/playground/fixtures/excalidraw-12602/files/shots/head-he-zen.png +0 -0
- package/review-app/playground/fixtures/excalidraw-12602/instances.json +1 -0
- package/review-app/playground/fixtures/excalidraw-12602/review.tsx +103 -0
- package/review-app/playground/fixtures/rply-16/data/box.json +1 -0
- package/review-app/playground/fixtures/rply-16/data/hooks.diff +28 -0
- package/review-app/playground/fixtures/rply-16/data/pr.diff +114 -0
- package/review-app/playground/fixtures/rply-16/data/pr.json +68 -0
- package/review-app/playground/fixtures/rply-16/data/review-comments.json +1 -0
- package/review-app/playground/fixtures/rply-16/events.json +156 -0
- package/review-app/playground/fixtures/rply-16/instances.json +1 -0
- package/review-app/playground/fixtures/rply-16/review.tsx +98 -0
- package/review-app/playground/gallery.tsx +127 -0
- package/review-app/playground/index.html +15 -0
- package/review-app/playground/main.tsx +26 -0
- package/review-app/playground/mock.ts +215 -0
- package/review-app/playground/playground.css +18 -0
- package/review-app/playground/vite.config.ts +32 -0
- package/review-app/scripts/check.cjs +47 -0
- package/review-app/scripts/clip.cjs +104 -0
- package/review-app/scripts/shot.cjs +66 -0
- package/review-app/src/App.tsx +132 -0
- package/review-app/src/data/box.json +1 -0
- package/review-app/src/data/hooks.diff +28 -0
- package/review-app/src/data/pr.diff +114 -0
- package/review-app/src/data/pr.json +68 -0
- package/review-app/src/data/review-comments.json +1 -0
- package/review-app/src/kit/agent.tsx +287 -0
- package/review-app/src/kit/chapter.tsx +178 -0
- package/review-app/src/kit/code.tsx +217 -0
- package/review-app/src/kit/comments.tsx +377 -0
- package/review-app/src/kit/finish.tsx +274 -0
- package/review-app/src/kit/flow.tsx +184 -0
- package/review-app/src/kit/index.ts +10 -0
- package/review-app/src/kit/kit.css +696 -0
- package/review-app/src/kit/live.ts +91 -0
- package/review-app/src/kit/media.tsx +348 -0
- package/review-app/src/kit/overview.tsx +161 -0
- package/review-app/src/kit/pr.ts +100 -0
- package/review-app/src/kit/pullrequest.tsx +298 -0
- package/review-app/src/kit/review.ts +74 -0
- package/review-app/src/kit/theme.ts +30 -0
- package/review-app/src/kit/ui.tsx +127 -0
- package/review-app/src/main.tsx +6 -0
- package/review-app/src/review.tsx +22 -0
- package/review-app/tsconfig.json +17 -0
- package/review-app/vite.config.ts +12 -0
- package/dist/presets/providers/command.d.ts +0 -3
- package/dist/presets/providers/command.js +0 -39
- package/dist/presets/providers/daytona.d.ts +0 -3
- package/dist/presets/providers/daytona.js +0 -173
- package/dist/presets/providers/docker.d.ts +0 -3
- package/dist/presets/providers/docker.js +0 -31
- package/dist/presets/providers/e2b.d.ts +0 -3
- package/dist/presets/providers/e2b.js +0 -124
- package/dist/presets/providers/exe.d.ts +0 -3
- package/dist/presets/providers/exe.js +0 -28
- package/dist/presets/providers/http.example.d.ts +0 -3
- package/dist/presets/providers/http.example.js +0 -47
- package/dist/presets/providers/local.d.ts +0 -3
- package/dist/presets/providers/local.js +0 -52
- package/dist/presets/providers/modal.d.ts +0 -3
- package/dist/presets/providers/modal.js +0 -117
- package/dist/presets/providers/ssh.d.ts +0 -3
- package/dist/presets/providers/ssh.js +0 -49
- package/dist/presets/providers/util.d.ts +0 -13
- package/dist/presets/providers/util.js +0 -102
- package/presets/providers/command.ts +0 -43
- package/presets/providers/daytona.ts +0 -181
- package/presets/providers/docker.ts +0 -38
- package/presets/providers/e2b.ts +0 -130
- package/presets/providers/exe.ts +0 -35
- package/presets/providers/http.example.ts +0 -64
- package/presets/providers/local.ts +0 -56
- package/presets/providers/modal.ts +0 -121
- package/presets/providers/ssh.ts +0 -54
- package/presets/providers/util.ts +0 -112
package/dist/src/boxes.js
CHANGED
|
@@ -1,15 +1,34 @@
|
|
|
1
|
-
// Boxes
|
|
2
|
-
//
|
|
3
|
-
// and
|
|
1
|
+
// Boxes: a provider box set up as the repo's dev environment, optionally with an agent working in it.
|
|
2
|
+
// All state lives in the box under ~/.bottleship: env.sh, prepare.sh, ports, stage, setup.log, app.log, app.pid,
|
|
3
|
+
// and for agent boxes launch.sh, run.sh, resume.sh, prompt.txt, pid, exit, prepare.log, agent.log.
|
|
4
4
|
import { createHash, randomBytes } from "node:crypto";
|
|
5
|
-
import {
|
|
6
|
-
import {
|
|
5
|
+
import { BsError, boxEnv, getAgent, missingSecrets } from "./config.js";
|
|
6
|
+
import { getProvider } from "./provider.js";
|
|
7
|
+
import { fill, shq } from "./util.js";
|
|
7
8
|
const B = '"$HOME/.bottleship"';
|
|
8
9
|
const MARK = "__BOTTLESHIP_PROMPT__";
|
|
9
10
|
const tail = (s, n = 2000) => (s.length > n ? "..." + s.slice(-n) : s);
|
|
10
|
-
export 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';
|
|
11
|
-
// the
|
|
12
|
-
const
|
|
11
|
+
export const goneFix = 'the box is gone. Start a new one with `bottleship agent new "<task>"` and tell its agent to continue on the branch the old box pushed';
|
|
12
|
+
// the image every box starts from; the repo's .bottleship/Dockerfile lines run as root on top, then the agent installs as box
|
|
13
|
+
const BASE = `FROM node:22-bookworm-slim
|
|
14
|
+
SHELL ["/bin/bash", "-o", "pipefail", "-c"]
|
|
15
|
+
RUN apt-get update && apt-get install -y --no-install-recommends git curl ca-certificates ripgrep \\
|
|
16
|
+
&& mkdir -p -m 755 /etc/apt/keyrings \\
|
|
17
|
+
&& curl -fsSL https://cli.github.com/packages/githubcli-archive-keyring.gpg -o /etc/apt/keyrings/githubcli-archive-keyring.gpg \\
|
|
18
|
+
&& echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/githubcli-archive-keyring.gpg] https://cli.github.com/packages stable main" > /etc/apt/sources.list.d/github-cli.list \\
|
|
19
|
+
&& apt-get update && apt-get install -y --no-install-recommends gh \\
|
|
20
|
+
&& rm -rf /var/lib/apt/lists/*
|
|
21
|
+
RUN userdel -r node && useradd -m -s /bin/bash -u 1000 box
|
|
22
|
+
ENV NPM_CONFIG_PREFIX=/home/box/.bottleship/npm PATH=/home/box/.bottleship/npm/bin:/home/box/.local/bin:$PATH
|
|
23
|
+
`;
|
|
24
|
+
// the box image for a repo: base, the repo's lines, the agent's install; named by its content so a change builds a new one
|
|
25
|
+
// extra: lines bottleship itself adds last, e.g. the review ports, so they never invalidate the cached layers above
|
|
26
|
+
export function boxImage(cfg, agentName, repoEnv, extra = "") {
|
|
27
|
+
const repo = repoEnv?.dockerfile ? `# .bottleship/Dockerfile\n${repoEnv.dockerfile.trimEnd()}\n` : "";
|
|
28
|
+
const install = (getAgent(cfg, agentName).install ?? []).map((line) => `RUN ${line}\n`).join("");
|
|
29
|
+
const dockerfile = `${BASE}${repo}USER box\nWORKDIR /home/box\n${install}${extra}`;
|
|
30
|
+
return { dockerfile, image: `bottleship-${createHash("sha256").update(dockerfile).digest("hex").slice(0, 12)}` };
|
|
31
|
+
}
|
|
13
32
|
// writes files under the box's $HOME from inside a script fed on stdin, so secrets never show up in argv
|
|
14
33
|
function writeFiles(files) {
|
|
15
34
|
const eof = `BOTTLESHIP_EOF_${randomBytes(8).toString("hex")}`;
|
|
@@ -20,246 +39,229 @@ function writeFiles(files) {
|
|
|
20
39
|
})
|
|
21
40
|
.join("\n");
|
|
22
41
|
}
|
|
23
|
-
//
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
nohup bash ${B}/launch.sh ${mode} </dev/null >/dev/null 2>&1 &
|
|
27
|
-
echo $! > ${B}/pid`;
|
|
28
|
-
const RUNNING = `[ ! -f ${B}/exit ] && kill -0 "$(cat ${B}/pid 2>/dev/null)" 2>/dev/null`;
|
|
29
|
-
// on the device route it logs in first when login-status.sh fails, giving up after login-timeout seconds; until a run started, a follow-up runs instead of resuming
|
|
30
|
-
const LAUNCH = `B="$HOME/.bottleship"
|
|
42
|
+
// gets the repo's environment going: setup.sh once, then start.sh in the background until every port listens.
|
|
43
|
+
// Writes the stage it is in (setup, start, ready, failed), exits non-zero on failure; $1 is the deadline in seconds.
|
|
44
|
+
const PREPARE = `B="$HOME/.bottleship"
|
|
31
45
|
. "$B/env.sh"
|
|
32
46
|
cd "$HOME/workspace"
|
|
33
|
-
|
|
34
|
-
# state files are replaced whole, so status never reads one half-written
|
|
47
|
+
deadline=$((SECONDS + \${1:-900}))
|
|
35
48
|
put() { echo "$2" > "$B/$1.tmp" && mv -f "$B/$1.tmp" "$B/$1"; }
|
|
49
|
+
fail() { put stage failed; echo "$1"; exit 1; }
|
|
36
50
|
killtree() { for c in $(ps -A -o pid=,ppid= | awk -v p="$1" '$2 == p { print $1 }'); do killtree "$c"; done; kill "$1" 2>/dev/null; }
|
|
37
|
-
[ -f
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
51
|
+
if [ -f .bottleship/setup.sh ]; then
|
|
52
|
+
put stage setup
|
|
53
|
+
bash .bottleship/setup.sh > "$B/setup.log" 2>&1 </dev/null &
|
|
54
|
+
pid=$!
|
|
55
|
+
while kill -0 $pid 2>/dev/null; do
|
|
56
|
+
[ $SECONDS -lt $deadline ] || { killtree $pid; fail "setup.sh did not finish in time, see ~/.bottleship/setup.log"; }
|
|
57
|
+
sleep 1
|
|
58
|
+
done
|
|
59
|
+
wait $pid || fail "setup.sh failed (exit $?), see ~/.bottleship/setup.log"
|
|
60
|
+
fi
|
|
61
|
+
if [ -f .bottleship/start.sh ]; then
|
|
62
|
+
put stage start
|
|
63
|
+
nohup bash .bottleship/start.sh > "$B/app.log" 2>&1 </dev/null &
|
|
64
|
+
echo $! > "$B/app.pid"
|
|
65
|
+
for port in $(cat "$B/ports"); do
|
|
66
|
+
until (exec 3<>"/dev/tcp/127.0.0.1/$port") 2>/dev/null; do
|
|
67
|
+
kill -0 "$(cat "$B/app.pid")" 2>/dev/null || fail "start.sh exited before port $port was up, see ~/.bottleship/app.log"
|
|
68
|
+
[ $SECONDS -lt $deadline ] || fail "port $port was not up in time, see ~/.bottleship/app.log"
|
|
69
|
+
sleep 1
|
|
70
|
+
done
|
|
71
|
+
done
|
|
52
72
|
fi
|
|
73
|
+
put stage ready
|
|
74
|
+
`;
|
|
75
|
+
// prepares the environment once, then runs the agent; the agent runs even if preparing failed, and can see why in prepare.log
|
|
76
|
+
const LAUNCH = `B="$HOME/.bottleship"
|
|
77
|
+
. "$B/env.sh"
|
|
78
|
+
mode=$1
|
|
79
|
+
put() { echo "$2" > "$B/$1.tmp" && mv -f "$B/$1.tmp" "$B/$1"; }
|
|
80
|
+
[ -f "$B/stage" ] || bash "$B/prepare.sh" > "$B/prepare.log" 2>&1
|
|
81
|
+
cd "$HOME/workspace"
|
|
82
|
+
[ -f "$B/started" ] || mode=run
|
|
53
83
|
touch "$B/started"
|
|
54
84
|
bash "$B/$mode.sh" > "$B/agent.log" 2>&1 </dev/null
|
|
55
85
|
put exit $?
|
|
56
86
|
`;
|
|
57
|
-
//
|
|
58
|
-
const
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
87
|
+
// starts the agent detached; launch.sh records the exit code when it ends
|
|
88
|
+
const start = (mode) => `rm -f ${B}/exit
|
|
89
|
+
nohup bash ${B}/launch.sh ${mode} </dev/null >/dev/null 2>&1 &
|
|
90
|
+
echo $! > ${B}/pid`;
|
|
91
|
+
const RUNNING = `[ ! -f ${B}/exit ] && kill -0 "$(cat ${B}/pid 2>/dev/null)" 2>/dev/null`;
|
|
92
|
+
// a follow-up for the agent, read from stdin: prints running (the agent is busy, nothing changes) or started.
|
|
93
|
+
// bottleship agent ask runs it, and so does the review server when the reviewer sends comments.
|
|
94
|
+
const ASK = `if ${RUNNING}; then echo running; exit 0; fi
|
|
95
|
+
cat > ${B}/prompt.txt
|
|
96
|
+
${start("resume")}
|
|
97
|
+
echo started
|
|
98
|
+
`;
|
|
99
|
+
// whether the agent is still running, for the review server: prints running or idle
|
|
100
|
+
const STATE = `if ${RUNNING}; then echo running; else echo idle; fi
|
|
101
|
+
`;
|
|
63
102
|
// gh and git both read GH_TOKEN, so pushes and PRs work with no further login
|
|
64
103
|
const GIT_HELPER = `!f() { test "$1" = get && echo username=x-access-token && echo "password=$GH_TOKEN"; }; f`;
|
|
65
|
-
// the
|
|
66
|
-
const
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
&& mkdir -p -m 755 /etc/apt/keyrings \\
|
|
70
|
-
&& curl -fsSL https://cli.github.com/packages/githubcli-archive-keyring.gpg -o /etc/apt/keyrings/githubcli-archive-keyring.gpg \\
|
|
71
|
-
&& echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/githubcli-archive-keyring.gpg] https://cli.github.com/packages stable main" > /etc/apt/sources.list.d/github-cli.list \\
|
|
72
|
-
&& apt-get update && apt-get install -y --no-install-recommends gh \\
|
|
73
|
-
&& rm -rf /var/lib/apt/lists/*
|
|
74
|
-
RUN userdel -r node && useradd -m -s /bin/bash -u 1000 box
|
|
75
|
-
USER box
|
|
76
|
-
WORKDIR /home/box
|
|
77
|
-
ENV NPM_CONFIG_PREFIX=/home/box/.bottleship/npm PATH=/home/box/.bottleship/npm/bin:/home/box/.local/bin:$PATH
|
|
78
|
-
`;
|
|
79
|
-
// bakes in only this agent's install: other agents install at start, and a changed agent gets a new template
|
|
80
|
-
export function templateSpec(cfg, agentName) {
|
|
81
|
-
const dockerfile = DOCKERFILE + (getAgent(cfg, agentName).install ?? []).map((line) => `RUN ${line}\n`).join("");
|
|
82
|
-
return { name: `bottleship-${createHash("sha256").update(dockerfile).digest("hex").slice(0, 8)}`, dockerfile };
|
|
83
|
-
}
|
|
84
|
-
// the template init recorded for this provider, if it was built from the current spec of the configured agent
|
|
85
|
-
export function currentTemplate(cfg, provider) {
|
|
86
|
-
const t = cfg.templates[provider];
|
|
87
|
-
return t && t.name === templateSpec(cfg, cfg.agent).name ? t.ref : undefined;
|
|
88
|
-
}
|
|
104
|
+
// the host of an https clone URL, e.g. a GitHub Enterprise host, so GH_TOKEN reaches it too
|
|
105
|
+
const cloneHost = (url) => url.match(/^https:\/\/([^/:]+)/)?.[1]?.toLowerCase();
|
|
106
|
+
// the prompt is read from a file at run time, so one script serves every turn
|
|
107
|
+
const agentScript = (template) => fill(template, { prompt: MARK }).split(shq(MARK)).join(`"$(cat ${B}/prompt.txt)"`);
|
|
89
108
|
export function parseId(cfg, handle) {
|
|
90
109
|
const i = handle.indexOf(":");
|
|
91
110
|
return i < 0 ? { provider: cfg.provider, box: handle } : { provider: handle.slice(0, i), box: handle.slice(i + 1) };
|
|
92
111
|
}
|
|
93
|
-
export function newBoxId(
|
|
94
|
-
const slug =
|
|
112
|
+
export function newBoxId(label) {
|
|
113
|
+
const slug = label.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "").slice(0, 20).replace(/-$/, "");
|
|
95
114
|
return `bs-${slug ? slug + "-" : ""}${randomBytes(2).toString("hex")}`;
|
|
96
115
|
}
|
|
97
116
|
async function sh(b, script, stdin) {
|
|
98
117
|
try {
|
|
99
|
-
return await b.provider.exec(b.box, script,
|
|
118
|
+
return await b.provider.exec(b.box, script, stdin);
|
|
100
119
|
}
|
|
101
120
|
catch (e) {
|
|
102
|
-
throw new BsError("box_unreachable", `could not run a command in ${b.id}: ${e.message}`, `bottleship status ${b.id}`);
|
|
121
|
+
throw new BsError("box_unreachable", `could not run a command in ${b.id}: ${e.message}`, `bottleship agent status ${b.id}`);
|
|
103
122
|
}
|
|
104
123
|
}
|
|
105
124
|
// a box that exists; throws box_gone if the provider says it does not
|
|
106
125
|
export async function openBox(cfg, handle) {
|
|
107
126
|
const { provider: name, box } = parseId(cfg, handle);
|
|
108
|
-
const provider =
|
|
109
|
-
if (
|
|
127
|
+
const provider = getProvider(name);
|
|
128
|
+
if ((await provider.status(box)) === "gone")
|
|
110
129
|
throw new BsError("box_gone", `box ${handle} is gone`, goneFix);
|
|
111
130
|
return { id: `${name}:${box}`, provider, box };
|
|
112
131
|
}
|
|
113
|
-
//
|
|
114
|
-
export async function
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
if (
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
132
|
+
// creates the box from the repo's image and sets up env, git and the workspace; a failure leaves no box
|
|
133
|
+
export async function createBox(cfg, opts) {
|
|
134
|
+
let env = {};
|
|
135
|
+
let unset = [];
|
|
136
|
+
if (opts.agent) {
|
|
137
|
+
const r = boxEnv(cfg, opts.agent);
|
|
138
|
+
if (r.missing.length)
|
|
139
|
+
throw missingSecrets(r.missing.map((m) => m.name));
|
|
140
|
+
env = r.env;
|
|
141
|
+
unset = (getAgent(cfg, opts.agent).unset ?? []).filter((k) => !(k in env));
|
|
142
|
+
}
|
|
143
|
+
env = { ...env, ...opts.env };
|
|
144
|
+
const provider = getProvider(opts.provider);
|
|
145
|
+
const { dockerfile, image } = boxImage(cfg, opts.agent ?? cfg.agent, opts.repoEnv, opts.dockerfile);
|
|
146
|
+
const box = newBoxId(opts.label);
|
|
147
|
+
try {
|
|
148
|
+
await provider.create(box, dockerfile, image);
|
|
149
|
+
}
|
|
150
|
+
catch (e) {
|
|
151
|
+
await provider.destroy(box).catch(() => { });
|
|
152
|
+
throw new BsError("create_failed", `could not create the box: ${e.message}`, "if the image build failed, fix .bottleship/Dockerfile and try again");
|
|
153
|
+
}
|
|
154
|
+
const b = { id: `${provider.name}:${box}`, provider, box };
|
|
155
|
+
try {
|
|
156
|
+
await setup(cfg, b, opts, env, unset);
|
|
157
|
+
}
|
|
158
|
+
catch (e) {
|
|
159
|
+
await provider.destroy(box).catch(() => { });
|
|
160
|
+
throw e;
|
|
161
|
+
}
|
|
162
|
+
return b;
|
|
163
|
+
}
|
|
164
|
+
async function setup(cfg, b, opts, env, unset) {
|
|
123
165
|
const envSh = [
|
|
124
166
|
...(unset.length ? [`unset ${unset.join(" ")}`] : []),
|
|
125
|
-
'export PATH="$HOME/.bottleship/node/bin:$HOME/.bottleship/npm/bin:$HOME/.local/bin:$PATH"',
|
|
126
|
-
'export NPM_CONFIG_PREFIX="$HOME/.bottleship/npm"',
|
|
127
167
|
...Object.entries(env).map(([k, v]) => `export ${k}=${shq(v)}`),
|
|
128
168
|
].join("\n");
|
|
169
|
+
const agent = opts.agent ? getAgent(cfg, opts.agent) : undefined;
|
|
129
170
|
const files = {
|
|
130
171
|
// the agent needs these secrets, so it can read them; README says to scope them narrowly
|
|
131
172
|
".bottleship/env.sh": envSh,
|
|
132
|
-
".bottleship/
|
|
133
|
-
".bottleship/
|
|
134
|
-
".bottleship/resume.sh": agentScript(agent.resume),
|
|
135
|
-
|
|
136
|
-
...(device && {
|
|
137
|
-
".bottleship/login.sh": device.command,
|
|
138
|
-
".bottleship/login-status.sh": device.status,
|
|
139
|
-
".bottleship/login-timeout": String(device.timeout ?? 900),
|
|
140
|
-
".bottleship/agent-name": agentName,
|
|
141
|
-
}),
|
|
142
|
-
...Object.fromEntries(Object.entries(dotenv).map(([name, content]) => [`.bottleship/dotenv/${name}`, content])),
|
|
173
|
+
".bottleship/prepare.sh": PREPARE,
|
|
174
|
+
".bottleship/ports": (opts.repoEnv?.ports ?? []).join("\n"),
|
|
175
|
+
...(agent && { ".bottleship/launch.sh": LAUNCH, ".bottleship/run.sh": agentScript(agent.run), ".bottleship/resume.sh": agentScript(agent.resume), ".bottleship/ask.sh": ASK, ".bottleship/agent-state.sh": STATE }),
|
|
176
|
+
...Object.fromEntries(Object.entries(opts.dotenv ?? {}).map(([name, content]) => [`.bottleship/dotenv/${name}`, content])),
|
|
143
177
|
};
|
|
178
|
+
const ws = opts.workspace;
|
|
144
179
|
const script = `set -e
|
|
145
180
|
${writeFiles(files)}
|
|
146
181
|
chmod 600 ${B}/env.sh
|
|
147
|
-
. ${B}/env.sh
|
|
148
|
-
${NODE}
|
|
149
182
|
${cfg.git.name ? `git config --global user.name ${shq(cfg.git.name)}` : ""}
|
|
150
183
|
${cfg.git.email ? `git config --global user.email ${shq(cfg.git.email)}` : ""}
|
|
151
|
-
git config --global credential.https
|
|
152
|
-
${repo ? `[ -e "$HOME/workspace/.git" ] || git clone --quiet ${shq(repo.url)} "$HOME/workspace"` : ""}
|
|
184
|
+
${[...new Set(["github.com", ...(ws && "clone" in ws ? [cloneHost(ws.clone.url)] : [])].filter(Boolean))].map((h) => `git config --global credential.https://${h}.helper ${shq(GIT_HELPER)}`).join("\n")}
|
|
153
185
|
mkdir -p "$HOME/workspace"
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
${
|
|
157
|
-
${
|
|
186
|
+
${ws && "clone" in ws ? `(. ${B}/env.sh
|
|
187
|
+
git clone --quiet ${shq(ws.clone.url)} "$HOME/workspace"
|
|
188
|
+
${opts.pr ? `cd "$HOME/workspace"
|
|
189
|
+
git fetch --quiet origin ${shq(`pull/${opts.pr.number}/head:review/pr-${opts.pr.number}`)} ${shq(`${opts.pr.base}:review/base`)}
|
|
190
|
+
git checkout --quiet ${shq(`review/pr-${opts.pr.number}`)}
|
|
191
|
+
git branch --quiet -f review/base "$(git merge-base review/base ${shq(`review/pr-${opts.pr.number}`)})"
|
|
192
|
+
git worktree add --quiet "$HOME/base" review/base` : ""})` : ""}
|
|
193
|
+
if [ -d ${B}/dotenv ]; then cp -R ${B}/dotenv/. "$HOME/workspace/"; [ -d "$HOME/base" ] && cp -R ${B}/dotenv/. "$HOME/base/"; fi
|
|
194
|
+
true`;
|
|
158
195
|
const r = await sh(b, "bash -s", script);
|
|
159
196
|
if (r.code !== 0)
|
|
160
197
|
throw new BsError("setup_failed", `setting up ${b.id} failed (exit ${r.code})`, "fix the cause in stderr, then try again", { stderr: tail(r.stderr), stdout: tail(r.stdout) });
|
|
198
|
+
if (ws && "tar" in ws)
|
|
199
|
+
await putTar(b, "workspace", ws.tar);
|
|
161
200
|
}
|
|
162
|
-
|
|
163
|
-
|
|
201
|
+
// unpacks a tar into a directory under the box's $HOME
|
|
202
|
+
export async function putTar(b, dir, tar) {
|
|
203
|
+
// GNU tar warns about the extended headers macOS tar writes
|
|
204
|
+
const t = await sh(b, `d="$HOME"/${shq(dir)}; mkdir -p "$d"; w=; tar --version 2>/dev/null | grep -q GNU && w=--warning=no-unknown-keyword; tar -x $w -C "$d"`, tar);
|
|
205
|
+
if (t.code !== 0)
|
|
206
|
+
throw new BsError("setup_failed", `copying files into ${b.id} failed (exit ${t.code})`, "fix the cause in stderr, then try again", { stderr: tail(t.stderr) });
|
|
164
207
|
}
|
|
165
|
-
//
|
|
166
|
-
export async function
|
|
167
|
-
|
|
168
|
-
if (
|
|
169
|
-
throw
|
|
170
|
-
const { missing } = boxEnv(cfg, opts.agent);
|
|
171
|
-
if (missing.length)
|
|
172
|
-
throw missingSecrets(missing.map((m) => m.name));
|
|
173
|
-
const provider = await getProvider(cfg, opts.provider);
|
|
174
|
-
const template = provider.template && currentTemplate(cfg, provider.name);
|
|
175
|
-
const { id: box } = await provider.create({ id: newBoxId(opts.prompt), ...(opts.source && { source: opts.source }), ...(template && { template }) });
|
|
176
|
-
const b = { id: `${provider.name}:${box}`, provider, box };
|
|
177
|
-
// a stale or missing template still works, as setup installs what the template would have held, only slower
|
|
178
|
-
if (provider.template && !template && provider.name === cfg.provider)
|
|
179
|
-
b.warning = `no current box template for ${provider.name}, so this box installs its tools at start, which is slower; run \`bottleship init\` to build one`;
|
|
180
|
-
try {
|
|
181
|
-
await setupAndStart(cfg, b, opts);
|
|
182
|
-
const s = await loginState(b, getAgent(cfg, opts.agent).login?.device?.setup);
|
|
183
|
-
if (s.agent === "waiting_for_login")
|
|
184
|
-
b.login = s.login;
|
|
185
|
-
}
|
|
186
|
-
catch (e) {
|
|
187
|
-
await provider.destroy(box).catch(() => { });
|
|
188
|
-
throw e;
|
|
189
|
-
}
|
|
190
|
-
return b;
|
|
208
|
+
// writes text files under the box's $HOME, by path
|
|
209
|
+
export async function putFiles(b, files) {
|
|
210
|
+
const r = await sh(b, "bash -s", `set -e\n${writeFiles(files)}`);
|
|
211
|
+
if (r.code !== 0)
|
|
212
|
+
throw new BsError("setup_failed", `writing files into ${b.id} failed (exit ${r.code})`, "fix the cause in stderr, then try again", { stderr: tail(r.stderr) });
|
|
191
213
|
}
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
const
|
|
197
|
-
if (
|
|
198
|
-
return
|
|
199
|
-
const
|
|
200
|
-
return {
|
|
214
|
+
// runs a script in the box and returns its result
|
|
215
|
+
export const runIn = (b, script) => sh(b, "bash -s", script);
|
|
216
|
+
// runs the repo's setup.sh and start.sh now and waits for its ports; on failure, the log of the step that failed
|
|
217
|
+
export async function prepare(b, seconds = 900) {
|
|
218
|
+
const r = await sh(b, `bash ${B}/prepare.sh ${Number(seconds)}`);
|
|
219
|
+
if (r.code === 0)
|
|
220
|
+
return { stage: "ready" };
|
|
221
|
+
const logs = await sh(b, `cd ${B}; for f in setup.log app.log; do [ -f $f ] && { echo "== $f"; tail -n 60 $f; }; done; true`);
|
|
222
|
+
return { stage: "failed", message: r.stdout.trim() || r.stderr.trim(), log: logs.stdout };
|
|
223
|
+
}
|
|
224
|
+
// starts the agent in a box made with an agent
|
|
225
|
+
export async function startAgent(b, prompt) {
|
|
226
|
+
const r = await sh(b, "bash -s", `set -e\n${writeFiles({ ".bottleship/prompt.txt": prompt })}\n${start("run")}`);
|
|
227
|
+
if (r.code !== 0)
|
|
228
|
+
throw new BsError("start_failed", `could not start the agent in ${b.id} (exit ${r.code})`, `bottleship agent exec ${b.id} -- cat ~/.bottleship/agent.log`, { stderr: tail(r.stderr) });
|
|
201
229
|
}
|
|
202
|
-
|
|
230
|
+
const ANSI = /\x1b\[[0-9;?]*[A-Za-z]/g;
|
|
203
231
|
export async function agentState(b, lines = 40) {
|
|
204
232
|
// liveness first: once launch.sh is dead its state files are final, so a finish between the reads cannot look like "stopped"
|
|
205
233
|
const r = await sh(b, `kill -0 "$(cat ${B}/pid 2>/dev/null)" 2>/dev/null && alive=1 || alive=
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
elif [ -f ${B}/exit ]; then echo "exited $(cat ${B}/exit)"
|
|
209
|
-
elif [ -n "$alive" ]; then
|
|
210
|
-
case "$L" in checking|pending) echo "waiting_for_login $(cat ${B}/agent-name 2>/dev/null)";; *) echo running;; esac
|
|
234
|
+
if [ -f ${B}/exit ]; then echo "exited $(cat ${B}/exit)"
|
|
235
|
+
elif [ -n "$alive" ]; then echo running
|
|
211
236
|
else echo stopped; fi
|
|
212
|
-
|
|
237
|
+
cat ${B}/stage 2>/dev/null || echo
|
|
238
|
+
tail -n ${Number(lines)} ${B}/agent.log 2>/dev/null
|
|
213
239
|
true`);
|
|
214
|
-
const
|
|
215
|
-
const [state,
|
|
216
|
-
const
|
|
217
|
-
const
|
|
240
|
+
const [head, stage, ...rest] = r.stdout.split("\n");
|
|
241
|
+
const [state, code] = head.split(" ");
|
|
242
|
+
const env = stage.trim() ? { env: stage.trim() } : {};
|
|
243
|
+
const log = rest.join("\n").replace(ANSI, "");
|
|
218
244
|
if (state === "exited")
|
|
219
|
-
return { agent: "exited", exitCode: Number(
|
|
220
|
-
|
|
221
|
-
return { agent: "login_failed", exitCode: Number(arg), log: last(out) };
|
|
222
|
-
if (state === "waiting_for_login") {
|
|
223
|
-
const login = parseLogin(out, arg);
|
|
224
|
-
return { agent: "waiting_for_login", ...(login && { login }), log: last(out) };
|
|
225
|
-
}
|
|
226
|
-
return { agent: state === "running" ? "running" : "stopped", log: out };
|
|
245
|
+
return { agent: "exited", exitCode: Number(code), ...env, log };
|
|
246
|
+
return { agent: state === "running" ? "running" : "stopped", ...env, log };
|
|
227
247
|
}
|
|
228
|
-
// right after a start: on the device route, waits until the login shows its URL or is not needed; a failed login throws
|
|
229
|
-
export async function loginState(b, setup, seconds = 60) {
|
|
230
|
-
const until = Date.now() + seconds * 1000;
|
|
231
|
-
let s = await agentState(b);
|
|
232
|
-
while (s.agent === "waiting_for_login" && !s.login && Date.now() < until) {
|
|
233
|
-
await new Promise((r) => setTimeout(r, 300));
|
|
234
|
-
s = await agentState(b);
|
|
235
|
-
}
|
|
236
|
-
if (s.agent === "login_failed" || (s.agent === "waiting_for_login" && !s.login))
|
|
237
|
-
throw new BsError("login_failed", `the agent's device login in ${b.id} ${s.agent === "login_failed" ? "failed" : "showed no URL within a minute"}`, `read log${setup ? `; check that the user did this once: ${setup}` : ""}`, { log: s.log });
|
|
238
|
-
return s;
|
|
239
|
-
}
|
|
240
|
-
const busy = (s) => s.agent === "running" || s.agent === "waiting_for_login";
|
|
241
248
|
// ponytail: polls every second; fine for one waiting agent, a blocking exec in the box if many wait
|
|
242
249
|
export async function waitAgent(b, seconds, lines) {
|
|
243
250
|
const until = Date.now() + seconds * 1000;
|
|
244
251
|
let s = await agentState(b, lines);
|
|
245
|
-
while (
|
|
252
|
+
while (s.agent === "running" && Date.now() < until) {
|
|
246
253
|
await new Promise((r) => setTimeout(r, 1000));
|
|
247
254
|
s = await agentState(b, lines);
|
|
248
255
|
}
|
|
249
256
|
return s;
|
|
250
257
|
}
|
|
251
|
-
// starts a follow-up
|
|
258
|
+
// starts a follow-up in the same conversation
|
|
252
259
|
export async function ask(b, prompt) {
|
|
253
|
-
const r = await sh(b,
|
|
254
|
-
${writeFiles({ ".bottleship/prompt.txt": prompt })}
|
|
255
|
-
${start("resume")}
|
|
256
|
-
echo started`);
|
|
260
|
+
const r = await sh(b, `bash ${B}/ask.sh`, prompt);
|
|
257
261
|
if (r.stdout.trim() === "running")
|
|
258
|
-
throw new BsError("agent_running", `the agent in ${b.id} is still working`, `wait for it: bottleship status ${b.id} --wait 600`);
|
|
262
|
+
throw new BsError("agent_running", `the agent in ${b.id} is still working`, `wait for it: bottleship agent status ${b.id} --wait 600`);
|
|
259
263
|
if (r.stdout.trim() !== "started")
|
|
260
|
-
throw new BsError("ask_failed", `could not start the agent in ${b.id} (exit ${r.code})`, `bottleship exec ${b.id} -- cat ~/.bottleship/agent.log`, { stderr: tail(r.stderr) });
|
|
261
|
-
const s = await loginState(b);
|
|
262
|
-
return s.agent === "waiting_for_login" ? s.login : undefined;
|
|
264
|
+
throw new BsError("ask_failed", `could not start the agent in ${b.id} (exit ${r.code})`, `bottleship agent exec ${b.id} -- cat ~/.bottleship/agent.log`, { stderr: tail(r.stderr) });
|
|
263
265
|
}
|
|
264
266
|
// the env and workspace the agent sees, for exec
|
|
265
267
|
export const inBox = (script) => `. ${B}/env.sh 2>/dev/null; cd "$HOME/workspace" 2>/dev/null; ${script}`;
|