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.
Files changed (167) hide show
  1. package/CHANGELOG.md +17 -0
  2. package/README.md +118 -113
  3. package/dist/src/boxes.d.ts +41 -39
  4. package/dist/src/boxes.js +181 -179
  5. package/dist/src/cli.js +298 -83
  6. package/dist/src/config.d.ts +3 -6
  7. package/dist/src/config.js +19 -85
  8. package/dist/src/init.d.ts +3 -4
  9. package/dist/src/init.js +120 -205
  10. package/dist/src/local.d.ts +8 -0
  11. package/dist/src/local.js +24 -0
  12. package/dist/src/onboard.d.ts +5 -0
  13. package/dist/src/onboard.js +25 -0
  14. package/dist/src/paths.d.ts +0 -1
  15. package/dist/src/paths.js +0 -1
  16. package/dist/src/provider.d.ts +16 -0
  17. package/dist/src/provider.js +81 -0
  18. package/dist/src/repo.d.ts +21 -0
  19. package/dist/src/repo.js +75 -0
  20. package/dist/src/review/comments.d.ts +66 -0
  21. package/dist/src/review/comments.js +344 -0
  22. package/dist/src/review/contract.d.ts +82 -0
  23. package/dist/src/review/contract.js +189 -0
  24. package/dist/src/review/github.d.ts +120 -0
  25. package/dist/src/review/github.js +160 -0
  26. package/dist/src/review/local.d.ts +14 -0
  27. package/dist/src/review/local.js +70 -0
  28. package/dist/src/review/loop.d.ts +12 -0
  29. package/dist/src/review/loop.js +119 -0
  30. package/dist/src/review/pr.d.ts +14 -0
  31. package/dist/src/review/pr.js +68 -0
  32. package/dist/src/review/prompt.d.ts +15 -0
  33. package/dist/src/review/prompt.js +48 -0
  34. package/dist/src/review/serve.d.ts +1 -0
  35. package/dist/src/review/serve.js +8 -0
  36. package/dist/src/review/server.d.ts +14 -0
  37. package/dist/src/review/server.js +484 -0
  38. package/dist/src/review/setting-up.d.ts +4 -0
  39. package/dist/src/review/setting-up.js +74 -0
  40. package/dist/src/review/start.d.ts +32 -0
  41. package/dist/src/review/start.js +140 -0
  42. package/dist/src/review/target.d.ts +20 -0
  43. package/dist/src/review/target.js +66 -0
  44. package/dist/src/review/template.d.ts +3 -0
  45. package/dist/src/review/template.js +13 -0
  46. package/dist/src/review/terminal.d.ts +4 -0
  47. package/dist/src/review/terminal.js +91 -0
  48. package/dist/src/secrets.d.ts +1 -0
  49. package/dist/src/secrets.js +11 -1
  50. package/dist/src/types.d.ts +1 -71
  51. package/dist/src/ui.d.ts +6 -1
  52. package/dist/src/ui.js +22 -23
  53. package/dist/src/util.d.ts +11 -0
  54. package/dist/src/util.js +40 -0
  55. package/package.json +7 -14
  56. package/presets/agents/claude.json +1 -5
  57. package/presets/agents/codex.json +1 -10
  58. package/presets/agents/cursor.json +1 -9
  59. package/presets/agents/opencode.json +1 -14
  60. package/presets/providers/docker/create +12 -0
  61. package/presets/providers/docker/destroy +2 -0
  62. package/presets/providers/docker/exec +2 -0
  63. package/presets/providers/docker/list +4 -0
  64. package/presets/providers/docker/status +2 -0
  65. package/presets/providers/docker/url +4 -0
  66. package/review-app/KIT.md +106 -0
  67. package/review-app/WRITING.md +93 -0
  68. package/review-app/examples/rply-16.tsx +98 -0
  69. package/review-app/index.html +15 -0
  70. package/review-app/package-lock.json +2539 -0
  71. package/review-app/package.json +24 -0
  72. package/review-app/playground/fixtures/excalidraw-12602/data/box.json +1 -0
  73. package/review-app/playground/fixtures/excalidraw-12602/data/pr.diff +91 -0
  74. package/review-app/playground/fixtures/excalidraw-12602/data/pr.json +62 -0
  75. package/review-app/playground/fixtures/excalidraw-12602/data/review-comments.json +44 -0
  76. package/review-app/playground/fixtures/excalidraw-12602/events.json +1 -0
  77. package/review-app/playground/fixtures/excalidraw-12602/files/shots/base-ar-zoom.png +0 -0
  78. package/review-app/playground/fixtures/excalidraw-12602/files/shots/base-en-US-zoom.png +0 -0
  79. package/review-app/playground/fixtures/excalidraw-12602/files/shots/base-he-IL-zoom.png +0 -0
  80. package/review-app/playground/fixtures/excalidraw-12602/files/shots/base-he-dark.png +0 -0
  81. package/review-app/playground/fixtures/excalidraw-12602/files/shots/base-he-hover.png +0 -0
  82. package/review-app/playground/fixtures/excalidraw-12602/files/shots/base-he-page.png +0 -0
  83. package/review-app/playground/fixtures/excalidraw-12602/files/shots/base-he-tip.png +0 -0
  84. package/review-app/playground/fixtures/excalidraw-12602/files/shots/base-he-zen.png +0 -0
  85. package/review-app/playground/fixtures/excalidraw-12602/files/shots/head-ar-zoom.png +0 -0
  86. package/review-app/playground/fixtures/excalidraw-12602/files/shots/head-en-US-zoom.png +0 -0
  87. package/review-app/playground/fixtures/excalidraw-12602/files/shots/head-he-IL-zoom.png +0 -0
  88. package/review-app/playground/fixtures/excalidraw-12602/files/shots/head-he-dark.png +0 -0
  89. package/review-app/playground/fixtures/excalidraw-12602/files/shots/head-he-hover.png +0 -0
  90. package/review-app/playground/fixtures/excalidraw-12602/files/shots/head-he-page.png +0 -0
  91. package/review-app/playground/fixtures/excalidraw-12602/files/shots/head-he-tip.png +0 -0
  92. package/review-app/playground/fixtures/excalidraw-12602/files/shots/head-he-zen.png +0 -0
  93. package/review-app/playground/fixtures/excalidraw-12602/instances.json +1 -0
  94. package/review-app/playground/fixtures/excalidraw-12602/review.tsx +103 -0
  95. package/review-app/playground/fixtures/rply-16/data/box.json +1 -0
  96. package/review-app/playground/fixtures/rply-16/data/hooks.diff +28 -0
  97. package/review-app/playground/fixtures/rply-16/data/pr.diff +114 -0
  98. package/review-app/playground/fixtures/rply-16/data/pr.json +68 -0
  99. package/review-app/playground/fixtures/rply-16/data/review-comments.json +1 -0
  100. package/review-app/playground/fixtures/rply-16/events.json +156 -0
  101. package/review-app/playground/fixtures/rply-16/instances.json +1 -0
  102. package/review-app/playground/fixtures/rply-16/review.tsx +98 -0
  103. package/review-app/playground/gallery.tsx +127 -0
  104. package/review-app/playground/index.html +15 -0
  105. package/review-app/playground/main.tsx +26 -0
  106. package/review-app/playground/mock.ts +215 -0
  107. package/review-app/playground/playground.css +18 -0
  108. package/review-app/playground/vite.config.ts +32 -0
  109. package/review-app/scripts/check.cjs +47 -0
  110. package/review-app/scripts/clip.cjs +104 -0
  111. package/review-app/scripts/shot.cjs +66 -0
  112. package/review-app/src/App.tsx +132 -0
  113. package/review-app/src/data/box.json +1 -0
  114. package/review-app/src/data/hooks.diff +28 -0
  115. package/review-app/src/data/pr.diff +114 -0
  116. package/review-app/src/data/pr.json +68 -0
  117. package/review-app/src/data/review-comments.json +1 -0
  118. package/review-app/src/kit/agent.tsx +287 -0
  119. package/review-app/src/kit/chapter.tsx +178 -0
  120. package/review-app/src/kit/code.tsx +217 -0
  121. package/review-app/src/kit/comments.tsx +377 -0
  122. package/review-app/src/kit/finish.tsx +274 -0
  123. package/review-app/src/kit/flow.tsx +184 -0
  124. package/review-app/src/kit/index.ts +10 -0
  125. package/review-app/src/kit/kit.css +696 -0
  126. package/review-app/src/kit/live.ts +91 -0
  127. package/review-app/src/kit/media.tsx +348 -0
  128. package/review-app/src/kit/overview.tsx +161 -0
  129. package/review-app/src/kit/pr.ts +100 -0
  130. package/review-app/src/kit/pullrequest.tsx +298 -0
  131. package/review-app/src/kit/review.ts +74 -0
  132. package/review-app/src/kit/theme.ts +30 -0
  133. package/review-app/src/kit/ui.tsx +127 -0
  134. package/review-app/src/main.tsx +6 -0
  135. package/review-app/src/review.tsx +22 -0
  136. package/review-app/tsconfig.json +17 -0
  137. package/review-app/vite.config.ts +12 -0
  138. package/dist/presets/providers/command.d.ts +0 -3
  139. package/dist/presets/providers/command.js +0 -39
  140. package/dist/presets/providers/daytona.d.ts +0 -3
  141. package/dist/presets/providers/daytona.js +0 -173
  142. package/dist/presets/providers/docker.d.ts +0 -3
  143. package/dist/presets/providers/docker.js +0 -31
  144. package/dist/presets/providers/e2b.d.ts +0 -3
  145. package/dist/presets/providers/e2b.js +0 -124
  146. package/dist/presets/providers/exe.d.ts +0 -3
  147. package/dist/presets/providers/exe.js +0 -28
  148. package/dist/presets/providers/http.example.d.ts +0 -3
  149. package/dist/presets/providers/http.example.js +0 -47
  150. package/dist/presets/providers/local.d.ts +0 -3
  151. package/dist/presets/providers/local.js +0 -52
  152. package/dist/presets/providers/modal.d.ts +0 -3
  153. package/dist/presets/providers/modal.js +0 -117
  154. package/dist/presets/providers/ssh.d.ts +0 -3
  155. package/dist/presets/providers/ssh.js +0 -49
  156. package/dist/presets/providers/util.d.ts +0 -13
  157. package/dist/presets/providers/util.js +0 -102
  158. package/presets/providers/command.ts +0 -43
  159. package/presets/providers/daytona.ts +0 -181
  160. package/presets/providers/docker.ts +0 -38
  161. package/presets/providers/e2b.ts +0 -130
  162. package/presets/providers/exe.ts +0 -35
  163. package/presets/providers/http.example.ts +0 -64
  164. package/presets/providers/local.ts +0 -56
  165. package/presets/providers/modal.ts +0 -121
  166. package/presets/providers/ssh.ts +0 -54
  167. package/presets/providers/util.ts +0 -112
package/dist/src/boxes.js CHANGED
@@ -1,15 +1,34 @@
1
- // Boxes that run an agent in the background. All state lives in the box under ~/.bottleship:
2
- // env.sh (env and secrets), run.sh/resume.sh (the agent command), prompt.txt, pid, exit, agent.log,
3
- // and on the device login route login.sh, login-status.sh, login (checking, pending, ok or failed) and login.log.
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 { fill, shq } from "../presets/providers/util.js";
6
- import { BsError, boxEnv, getAgent, getProvider, noIsolationAccepted, noIsolationError, unisolated } from "./config.js";
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 prompt is read from a file at run time, so one script serves every turn
12
- const agentScript = (template) => fill(template, { prompt: MARK }).split(shq(MARK)).join(`"$(cat ${B}/prompt.txt)"`);
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
- // starts the agent detached; launch.sh records the exit code when it ends
24
- const start = (mode) => `rm -f ${B}/exit
25
- if [ -f ${B}/login.sh ]; then echo checking > ${B}/login.tmp && mv -f ${B}/login.tmp ${B}/login; rm -f ${B}/login.log; fi
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
- mode=$1
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 "$B/started" ] || mode=run
38
- if [ -f "$B/login.sh" ]; then
39
- if bash "$B/login-status.sh" >/dev/null 2>&1; then put login ok; else
40
- put login pending
41
- # stays in the box's process group so rm can find it; a timeout ends its whole process tree
42
- bash "$B/login.sh" > "$B/login.log" 2>&1 </dev/null &
43
- lp=$!
44
- end=$((SECONDS + $(cat "$B/login-timeout")))
45
- while kill -0 $lp 2>/dev/null && [ $SECONDS -lt $end ]; do sleep 1; done
46
- if kill -0 $lp 2>/dev/null; then killtree $lp; echo "bottleship: no approval within $(cat "$B/login-timeout") seconds" >> "$B/login.log"; fi
47
- wait $lp
48
- rc=$?
49
- if [ $rc -ne 0 ]; then put login failed; put exit $rc; exit; fi
50
- put login ok
51
- fi
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
- // node for agents installed with npm, without sudo, if the box has none
58
- const NODE = `if ! command -v node >/dev/null; then
59
- case "$(uname -m)" in aarch64|arm64) A=arm64;; *) A=x64;; esac
60
- F=$(curl -fsSL https://nodejs.org/dist/latest-v22.x/SHASUMS256.txt | grep -o "node-v[0-9.]*-linux-$A.tar.xz" | head -1)
61
- mkdir -p ${B}/node && curl -fsSL "https://nodejs.org/dist/latest-v22.x/$F" | tar -xJ -C ${B}/node --strip-components=1
62
- fi`;
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 box template: what every box set up at start would install, as the non-root user box; npm globals land where env.sh puts them
66
- const DOCKERFILE = `FROM node:22-bookworm-slim
67
- SHELL ["/bin/bash", "-o", "pipefail", "-c"]
68
- RUN apt-get update && apt-get install -y --no-install-recommends git curl ca-certificates ripgrep \\
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(prompt) {
94
- const slug = prompt.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "").slice(0, 20).replace(/-$/, "");
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, { stdin });
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 = await getProvider(cfg, name);
109
- if (provider.status && (await provider.status(box)) === "gone")
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
- // one round trip: files, node, git, clone (unless the provider made ~/workspace a checkout), install, start
114
- export async function setupAndStart(cfg, b, { agent: agentName, prompt, repo, dotenv = {} }) {
115
- const agent = getAgent(cfg, agentName);
116
- const { env, missing } = boxEnv(cfg, agentName);
117
- if (missing.length)
118
- throw missingSecrets(missing.map((m) => m.name));
119
- const unset = (agent.unset ?? []).filter((k) => !(k in env));
120
- const device = agent.login?.use === "device" ? agent.login.device : undefined;
121
- if (agent.login?.use === "device" && !device)
122
- throw new BsError("bad_agent", `agent ${agentName} uses the device login route but has no login.device`, `set agents.${agentName}.login.use to another route in ~/.bottleship/config.json`);
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/launch.sh": LAUNCH,
133
- ".bottleship/run.sh": agentScript(agent.run),
134
- ".bottleship/resume.sh": agentScript(agent.resume),
135
- ".bottleship/prompt.txt": prompt,
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://github.com.helper ${shq(GIT_HELPER)}
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
- if [ -d ${B}/dotenv ]; then cp -R ${B}/dotenv/. "$HOME/workspace/"; fi
155
- cd "$HOME/workspace"
156
- ${(agent.install ?? []).join("\n")}
157
- ${start("run")}`;
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
- export function missingSecrets(names) {
163
- return new BsError("secret_missing", `no value for ${names.join(", ")}`, "ask the user to run `bottleship init` in their terminal; it asks for missing secrets with a hidden prompt. Never ask the user to paste a secret to you", { missing: names });
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
- // source: the local checkout of the task's repo, for providers that can use it (local makes a worktree)
166
- export async function newBox(cfg, opts) {
167
- // fails before a box exists when a secret is missing
168
- if (unisolated(cfg, opts.provider) && !noIsolationAccepted(cfg, opts.provider))
169
- throw noIsolationError(opts.provider);
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
- const ANSI = /\x1b\[[0-9;?]*[A-Za-z]/g;
193
- // the first URL in a login's output, and a code like ABCD-12345 outside of URLs
194
- export function parseLogin(output, agent) {
195
- const text = output.replace(ANSI, "");
196
- const url = text.match(/https?:\/\/[^\s"'<>]+/)?.[0];
197
- if (!url)
198
- return undefined;
199
- const code = text.replace(/https?:\/\/\S+/g, " ").match(/\b[A-Z0-9]{4,}-[A-Z0-9]{4,}\b/)?.[0];
200
- return { url, ...(code && { code }), agent };
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
- // the login's output (all of it, for the URL) while it is the current step, else the agent's last lines
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
- L=$(cat ${B}/login 2>/dev/null)
207
- if [ "$L" = failed ]; then echo "login_failed $(cat ${B}/exit 2>/dev/null || echo 1)"
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
- case "$L" in checking|pending|failed) head -c 20000 ${B}/login.log 2>/dev/null;; *) tail -n ${lines} ${B}/agent.log 2>/dev/null;; esac
237
+ cat ${B}/stage 2>/dev/null || echo
238
+ tail -n ${Number(lines)} ${B}/agent.log 2>/dev/null
213
239
  true`);
214
- const nl = r.stdout.indexOf("\n");
215
- const [state, arg] = r.stdout.slice(0, nl).split(" ");
216
- const out = r.stdout.slice(nl + 1);
217
- const last = (s) => s.replace(ANSI, "").split("\n").slice(-lines - 1).join("\n");
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(arg), log: out };
220
- if (state === "login_failed")
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 (busy(s) && Date.now() < until) {
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; on the device route it returns the login the user has to approve first, if the box's login expired
258
+ // starts a follow-up in the same conversation
252
259
  export async function ask(b, prompt) {
253
- const r = await sh(b, "bash -s", `if ${RUNNING}; then echo running; exit 0; fi
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}`;