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
@@ -0,0 +1,140 @@
1
+ // `bottleship review <pr>`: a box with the PR's head and base, the review server running, and an agent building a guided tour of the PR.
2
+ import { ROOT } from "../paths.js";
3
+ import { BsError } from "../config.js";
4
+ import { createBox, putFiles, putTar, runIn, startAgent } from "../boxes.js";
5
+ import { dotenvFiles, parseRepoEnv, readEnvFiles, tarFiles } from "../repo.js";
6
+ import { fetchPr, prEnvFiles } from "./pr.js";
7
+ import { reviewPrompt } from "./prompt.js";
8
+ import { localChanges } from "./local.js";
9
+ import { shq } from "../util.js";
10
+ import { templateFiles, templatePackage } from "./template.js";
11
+ // the review server's port in the box, and ports for instances besides the app's own, e.g. base; all published
12
+ export const REVIEW_PORT = Number(process.env.BOTTLESHIP_REVIEW_PORT ?? 7777);
13
+ export const INSTANCE_PORTS = [7781, 7782, 7783, 7784];
14
+ // containers publish what listens on all interfaces; the provider decides who can reach it (docker: this machine only)
15
+ const REVIEW_HOST = process.env.BOTTLESHIP_REVIEW_HOST ?? "0.0.0.0";
16
+ // a headless browser the agent drives to see and check the UI; require("playwright") resolves through NODE_PATH
17
+ const PLAYWRIGHT = "1.64.1";
18
+ const BROWSER = `USER root
19
+ ENV PLAYWRIGHT_BROWSERS_PATH=/opt/ms-playwright NODE_PATH=/opt/playwright/lib/node_modules
20
+ RUN npm install -g --prefix /opt/playwright playwright@${PLAYWRIGHT} && /opt/playwright/bin/playwright install --with-deps chromium && rm -rf /var/lib/apt/lists/* /root/.npm
21
+ USER box
22
+ `;
23
+ // the review app's dependencies, installed once into the image; a review links them into its copy of the template
24
+ const REVIEW_APP = () => `USER root
25
+ RUN mkdir -p /opt/review-app && echo ${Buffer.from(templatePackage()).toString("base64")} | base64 -d > /opt/review-app/package.json \\
26
+ && cd /opt/review-app && npm install --no-audit --no-fund && rm -rf /root/.npm && chown -R box:box /opt/review-app
27
+ USER box
28
+ `;
29
+ // what the box needs to run the review server, and the package.json that marks it as ES modules
30
+ const SERVER_FILES = ["package.json", "dist/src/review/server.js", "dist/src/review/contract.js", "dist/src/review/serve.js", "dist/src/review/github.js", "dist/src/review/comments.js", "dist/src/review/setting-up.js", "dist/src/util.js"];
31
+ // onPhase: what is happening now, for a person watching in a terminal
32
+ // local: the user's checkout of the same repo, for its .env files and its .bottleship/ when the PR has none
33
+ export async function startReview(cfg, opts) {
34
+ const phase = opts.onPhase ?? (() => { });
35
+ const src = opts.source;
36
+ const localEnv = opts.local ? readEnvFiles(opts.local) : undefined;
37
+ const dotenv = opts.local ? dotenvFiles(opts.local) : {};
38
+ let subject;
39
+ let workspace, pr, prEnv, baseTree, baseSha;
40
+ if ("pr" in src) {
41
+ phase(`Reading ${src.pr.repo.label}#${src.pr.number} from GitHub`);
42
+ const p = await fetchPr(src.pr.repo, src.pr.number);
43
+ prEnv = await prEnvFiles(src.pr.repo, p.headRefOid);
44
+ subject = { raw: p.raw, title: p.title, url: p.url, diff: p.diff, reviewComments: p.reviewComments, base: p.baseRefName, label: String(src.pr.number), prompt: { repo: src.pr.repo.label, number: p.number, title: p.title, base: p.baseRefName } };
45
+ workspace = { clone: { url: src.pr.repo.clone, label: src.pr.repo.label } };
46
+ pr = { number: src.pr.number, base: p.baseRefName };
47
+ }
48
+ else {
49
+ phase("Reading the local changes");
50
+ const c = await localChanges(src.changes.root, { ...src.changes, exclude: Object.keys(dotenv) });
51
+ const base = c.raw.baseRefName;
52
+ subject = { raw: c.raw, title: c.branch, url: c.branch, diff: c.diff, reviewComments: [], base, label: "local", prompt: { repo: c.raw.repo, title: c.branch, base, changes: { branch: c.branch, note: src.changes.note } } };
53
+ workspace = { tar: c.head };
54
+ baseTree = c.baseTree;
55
+ baseSha = c.base;
56
+ }
57
+ const env = prEnv ? "pr" : localEnv ? "local" : "none";
58
+ const repoEnv = parseRepoEnv((prEnv ?? localEnv)?.Dockerfile);
59
+ const reviewUrl = `http://127.0.0.1:${REVIEW_PORT}`;
60
+ phase(`Creating the box for "${subject.title}" (the first box builds the image, which takes minutes)`);
61
+ const box = await createBox(cfg, {
62
+ provider: opts.provider,
63
+ label: `review-${subject.label}`,
64
+ agent: opts.agent,
65
+ workspace,
66
+ pr,
67
+ repoEnv,
68
+ dotenv,
69
+ env: { BOTTLESHIP_REVIEW_URL: reviewUrl },
70
+ dockerfile: `${BROWSER}${REVIEW_APP()}EXPOSE ${REVIEW_PORT} ${INSTANCE_PORTS.join(" ")}\n`,
71
+ });
72
+ try {
73
+ phase("Copying the changes and the review app into the box");
74
+ if (baseTree) {
75
+ // local changes: base from git, head from the working tree, each its own repo so the agent can diff its hooks;
76
+ // .env files stay out of both
77
+ await putTar(box, "base", baseTree);
78
+ const exclude = Object.keys(dotenv).map((n) => `/${n}`).join("\n");
79
+ const id = "-c user.name=bottleship -c user.email=review@bottleship.local";
80
+ const repos = await runIn(box, `set -e
81
+ cd "$HOME/base" && git init -q && printf '%s\\n' ${shq(exclude)} >> .git/info/exclude && git add -A && git ${id} commit -qm base
82
+ cp -R "$HOME/base/.git" "$HOME/workspace/.git" && cd "$HOME/workspace" && git add -A && git ${id} commit -qm ${shq(`local changes on ${subject.title}`)} --allow-empty
83
+ [ -d "$HOME/.bottleship/dotenv" ] && cp -R "$HOME/.bottleship/dotenv/." "$HOME/base/" || true`);
84
+ if (repos.code !== 0)
85
+ throw new BsError("setup_failed", `could not set up the checkouts in ${box.id}`, "see stderr", { stderr: repos.stderr.slice(-2000) });
86
+ }
87
+ // the review app: the template, the changes in src/data, and the dependencies from the image
88
+ await putTar(box, ".bottleship/review-template", await tarFiles(ROOT, templateFiles()));
89
+ const shas = await runIn(box, 'git -C "$HOME/base" rev-parse HEAD && git -C "$HOME/workspace" rev-parse HEAD');
90
+ const [base, head] = baseSha ? [baseSha, "working tree"] : shas.stdout.trim().split("\n");
91
+ const files = {
92
+ "review/app/src/data/pr.json": JSON.stringify(subject.raw, null, 2),
93
+ "review/app/src/data/review-comments.json": JSON.stringify(subject.reviewComments, null, 2),
94
+ "review/app/src/data/pr.diff": subject.diff,
95
+ "review/app/src/data/box.json": JSON.stringify({ base, head }),
96
+ };
97
+ // the reviewer's local, uncommitted environment, when the change itself has none
98
+ if (env === "local")
99
+ for (const [name, content] of Object.entries(localEnv))
100
+ for (const dir of ["workspace", "base"])
101
+ files[`${dir}/.bottleship/${name}`] = content;
102
+ const app = await runIn(box, `set -e
103
+ mkdir -p "$HOME/review/steps" "$HOME/review/shots"
104
+ cp -R "$HOME/.bottleship/review-template/review-app/." "$HOME/review/app"
105
+ rm -f "$HOME/review/app/src/data/"*
106
+ ln -sfn /opt/review-app/node_modules "$HOME/review/app/node_modules"`);
107
+ if (app.code !== 0)
108
+ throw new BsError("review_app_failed", `could not set up the review app in ${box.id}`, "see stderr", { stderr: app.stderr.slice(-2000) });
109
+ await putFiles(box, files);
110
+ await putTar(box, ".bottleship/review-server", await tarFiles(ROOT, SERVER_FILES));
111
+ // where this machine reaches each port the box publishes, for the review UI's links and frames
112
+ const urls = {};
113
+ if (box.provider.url)
114
+ for (const port of [REVIEW_PORT, ...INSTANCE_PORTS, ...repoEnv.ports]) {
115
+ try {
116
+ urls[port] = await box.provider.url(box.box, port);
117
+ }
118
+ catch { }
119
+ }
120
+ await putFiles(box, { ".bottleship/urls.json": JSON.stringify(urls) });
121
+ phase("Starting the review server");
122
+ // with the box's env, so posting a review uses its GH_TOKEN
123
+ const server = `. "$HOME/.bottleship/env.sh"
124
+ S="$HOME/.bottleship/review-server/dist/src/review/serve.js"
125
+ nohup node "$S" --dir "$HOME/review" --port ${REVIEW_PORT} --host ${shq(REVIEW_HOST)} --urls "$HOME/.bottleship/urls.json" --ask "$HOME/.bottleship/ask.sh" --state "$HOME/.bottleship/agent-state.sh" > "$HOME/.bottleship/review-server.log" 2>&1 < /dev/null &
126
+ echo $! > "$HOME/.bottleship/review-server.pid"
127
+ for i in $(seq 1 50); do curl -s -o /dev/null ${shq(`${reviewUrl}/api/status`)} && exit 0; sleep 0.2; done
128
+ cat "$HOME/.bottleship/review-server.log" >&2; exit 1`;
129
+ const r = await runIn(box, server);
130
+ if (r.code !== 0)
131
+ throw new BsError("review_server_failed", `the review server did not start in ${box.id}`, "see stderr", { stderr: r.stderr.slice(-2000) });
132
+ phase("Starting the agent");
133
+ await startAgent(box, reviewPrompt({ ...subject.prompt, reviewUrl, instancePorts: INSTANCE_PORTS, appPorts: repoEnv.ports, envSource: env }));
134
+ return { box, url: urls[REVIEW_PORT] ?? reviewUrl, pr: subject.url, title: subject.title, env };
135
+ }
136
+ catch (e) {
137
+ await box.provider.destroy(box.box).catch(() => { });
138
+ throw e;
139
+ }
140
+ }
@@ -0,0 +1,20 @@
1
+ export interface GhRepo {
2
+ host: string;
3
+ owner: string;
4
+ name: string;
5
+ }
6
+ export interface PrRepo {
7
+ label: string;
8
+ api: string;
9
+ hostname?: string;
10
+ clone: string;
11
+ }
12
+ export declare function parseRemote(s: string): GhRepo | undefined;
13
+ export declare function parseRepoArg(s: string): GhRepo;
14
+ export declare function parsePrRef(s: string): {
15
+ number: number;
16
+ repo?: GhRepo;
17
+ };
18
+ export declare const sameRepo: (a: GhRepo, b: GhRepo) => boolean;
19
+ export declare function prRepo(r: GhRepo): PrRepo;
20
+ export declare function prRepoFromRemote(remote: string): PrRepo;
@@ -0,0 +1,66 @@
1
+ // Which PR to review: a number, a shorthand, or a link, on github.com or a GitHub Enterprise host.
2
+ import { BsError } from "../config.js";
3
+ const FORMS = "17, #17, owner/name#17, host/owner/name#17, or a PR link like https://github.com/owner/name/pull/17";
4
+ const bad = (what) => new BsError("bad_request", `"${what}" is not a PR`, `use ${FORMS}`);
5
+ const strip = (n) => n.replace(/\.git$/, "");
6
+ const SEG = "[A-Za-z0-9_.-]+";
7
+ // a git remote or repo link: https://host/owner/name(.git), git@host:owner/name(.git), ssh://git@host[:port]/owner/name(.git)
8
+ export function parseRemote(s) {
9
+ const scp = s.match(new RegExp(`^[\\w.-]+@([^:/]+):(${SEG})/(${SEG})/?$`));
10
+ if (scp)
11
+ return { host: scp[1].toLowerCase(), owner: scp[2], name: strip(scp[3]) };
12
+ let u;
13
+ try {
14
+ u = new URL(s);
15
+ }
16
+ catch {
17
+ return undefined;
18
+ }
19
+ if (!["https:", "http:", "ssh:", "git:"].includes(u.protocol))
20
+ return undefined;
21
+ const parts = u.pathname.replace(/^\/+|\/+$/g, "").split("/");
22
+ if (parts.length !== 2 || !parts.every((p) => new RegExp(`^${SEG}$`).test(p)))
23
+ return undefined;
24
+ return { host: u.hostname.toLowerCase(), owner: parts[0], name: strip(parts[1]) };
25
+ }
26
+ // --repo: owner/name (github.com), host/owner/name, or a link or remote
27
+ export function parseRepoArg(s) {
28
+ const short = s.match(new RegExp(`^(?:(${SEG}\\.${SEG})/)?(${SEG})/(${SEG})$`));
29
+ if (short)
30
+ return { host: (short[1] ?? "github.com").toLowerCase(), owner: short[2], name: strip(short[3]) };
31
+ const r = parseRemote(s);
32
+ if (!r)
33
+ throw new BsError("bad_request", `"${s}" is not a repo`, "use owner/name, host/owner/name for GitHub Enterprise, or the repo's URL");
34
+ return r;
35
+ }
36
+ export function parsePrRef(s) {
37
+ const arg = s.trim();
38
+ const n = arg.match(/^#?(\d+)$/);
39
+ if (n)
40
+ return { number: Number(n[1]) };
41
+ const short = arg.match(new RegExp(`^(?:(${SEG}\\.${SEG})/)?(${SEG})/(${SEG})#(\\d+)$`));
42
+ if (short)
43
+ return { number: Number(short[4]), repo: { host: (short[1] ?? "github.com").toLowerCase(), owner: short[2], name: strip(short[3]) } };
44
+ // a link to the PR or any page under it: /files, /commits, #comment anchors, query strings
45
+ let u;
46
+ try {
47
+ u = new URL(/^[a-z]+:\/\//i.test(arg) ? arg : `https://${arg}`);
48
+ }
49
+ catch {
50
+ throw bad(s);
51
+ }
52
+ const p = u.pathname.split("/").filter(Boolean);
53
+ if (!u.hostname.includes(".") || p.length < 4 || p[2] !== "pull" || !/^\d+$/.test(p[3]))
54
+ throw bad(s);
55
+ return { number: Number(p[3]), repo: { host: u.hostname.toLowerCase(), owner: p[0], name: strip(p[1]) } };
56
+ }
57
+ export const sameRepo = (a, b) => a.host === b.host && a.owner.toLowerCase() === b.owner.toLowerCase() && a.name.toLowerCase() === b.name.toLowerCase();
58
+ export function prRepo(r) {
59
+ const github = r.host === "github.com";
60
+ return { label: github ? `${r.owner}/${r.name}` : `${r.host}/${r.owner}/${r.name}`, api: `repos/${r.owner}/${r.name}`, ...(!github && { hostname: r.host }), clone: `https://${r.host}/${r.owner}/${r.name}.git` };
61
+ }
62
+ // a remote that is no GitHub repo link, e.g. a path, goes to gh and git as it is
63
+ export function prRepoFromRemote(remote) {
64
+ const r = parseRemote(remote);
65
+ return r ? prRepo(r) : { label: remote, api: `repos/${remote}`, clone: remote };
66
+ }
@@ -0,0 +1,3 @@
1
+ export declare const TEMPLATE: string;
2
+ export declare function templateFiles(dir?: string): string[];
3
+ export declare const templatePackage: () => string;
@@ -0,0 +1,13 @@
1
+ // The review app template: a Vite and React project with the review kit, which the box agent fills in for each PR.
2
+ import { readdirSync, readFileSync } from "node:fs";
3
+ import { join, relative } from "node:path";
4
+ import { ROOT } from "../paths.js";
5
+ export const TEMPLATE = join(ROOT, "review-app");
6
+ // the playground is for working on the kit, not part of a review
7
+ const SKIP = new Set(["node_modules", "dist", ".DS_Store", "playground"]);
8
+ // every template file, relative to the package root
9
+ export function templateFiles(dir = TEMPLATE) {
10
+ return readdirSync(dir, { withFileTypes: true }).flatMap((e) => SKIP.has(e.name) ? [] : e.isDirectory() ? templateFiles(join(dir, e.name)) : [relative(ROOT, join(dir, e.name))]);
11
+ }
12
+ // the template's package.json, installed once into the box image so a review builds without a network
13
+ export const templatePackage = () => readFileSync(join(TEMPLATE, "package.json"), "utf8");
@@ -0,0 +1,4 @@
1
+ import { startReview } from "./start.ts";
2
+ import { type UI } from "../ui.ts";
3
+ import type { Config } from "../types.ts";
4
+ export declare function reviewInTerminal(ui: UI, cfg: Config, opts: Parameters<typeof startReview>[1]): Promise<void>;
@@ -0,0 +1,91 @@
1
+ // `bottleship review` for a person in a terminal: each phase as it happens, the review UI opened in the browser,
2
+ // then the box watched until the agent is done or the person stops watching. The review keeps going either way.
3
+ import { spawn } from "node:child_process";
4
+ import { agentState } from "../boxes.js";
5
+ import { startReview } from "./start.js";
6
+ import { c } from "../ui.js";
7
+ const ENV = {
8
+ pr: "the PR's own .bottleship/",
9
+ local: "your local .bottleship/",
10
+ none: "nothing: neither the PR nor your checkout has a .bottleship/, so the agent works out how to run the app",
11
+ };
12
+ function openInBrowser(url) {
13
+ const cmd = process.platform === "darwin" ? "open" : process.platform === "linux" ? "xdg-open" : undefined;
14
+ if (!cmd)
15
+ return false;
16
+ try {
17
+ spawn(cmd, [url], { stdio: "ignore", detached: true }).on("error", () => { }).unref();
18
+ return true;
19
+ }
20
+ catch {
21
+ return false;
22
+ }
23
+ }
24
+ const elapsed = (since) => {
25
+ const s = Math.round((Date.now() - since) / 1000);
26
+ return s < 60 ? `${s}s` : `${Math.floor(s / 60)}m ${s % 60}s`;
27
+ };
28
+ export async function reviewInTerminal(ui, cfg, opts) {
29
+ const pr = "pr" in opts.source ? opts.source.pr : undefined;
30
+ ui.intro("review");
31
+ const setup = ui.spin(pr ? `Reading ${pr.repo.label}#${pr.number} from GitHub` : "Reading the local changes");
32
+ let r;
33
+ try {
34
+ r = await startReview(cfg, { ...opts, onPhase: (t) => setup.message(t) });
35
+ }
36
+ catch (e) {
37
+ setup.stop("Could not start the review", true);
38
+ throw e;
39
+ }
40
+ setup.stop(pr ? `${c.bold(`#${pr.number}`)} ${r.title}` : `${c.bold("Local changes")} on ${r.title}`);
41
+ const opened = openInBrowser(r.url);
42
+ ui.note("Review", `${r.url}${opened ? " (opened in your browser)" : ""}\n\nIt shows the tour as soon as the agent has built it. Box ${r.box.id}, environment from ${ENV[r.env]}.`);
43
+ // watch until the agent is done; Ctrl-C only stops watching
44
+ const started = Date.now();
45
+ let watch = ui.spin("Setting up the environment");
46
+ let stopped = false;
47
+ const stop = () => (stopped = true);
48
+ process.once("SIGINT", stop);
49
+ let envSeen = "";
50
+ let built = false;
51
+ let last = { agent: "running", log: "" };
52
+ try {
53
+ while (!stopped) {
54
+ last = await agentState(r.box, 12);
55
+ if (last.env && last.env !== envSeen && ["ready", "failed"].includes(last.env)) {
56
+ envSeen = last.env;
57
+ watch.stop(last.env === "ready" ? "The app's environment is up" : "The app's environment failed to set up; the agent will work around it", last.env === "failed");
58
+ if (last.env === "failed")
59
+ ui.say(c.dim(` see it: bottleship agent exec ${r.box.id} -- tail -40 ~/.bottleship/setup.log ~/.bottleship/app.log`));
60
+ watch = ui.spin("Agent is building the tour");
61
+ }
62
+ if (!built) {
63
+ built = await fetch(`${r.url}/api/status`).then((x) => x.json()).then((s) => !!s.app, () => false);
64
+ if (built) {
65
+ watch.stop("The tour is built and open in the review; the agent is checking it");
66
+ watch = ui.spin("Agent is checking the tour");
67
+ }
68
+ }
69
+ if (last.agent !== "running")
70
+ break;
71
+ watch.message(`${built ? "Agent is checking the tour" : envSeen ? "Agent is building the tour" : "Setting up the environment"} ${c.dim(elapsed(started))}`);
72
+ await new Promise((res) => setTimeout(res, 2000));
73
+ }
74
+ }
75
+ finally {
76
+ process.off("SIGINT", stop);
77
+ }
78
+ if (stopped) {
79
+ watch.stop("Stopped watching; the review keeps going in the box");
80
+ ui.say(" The tour shows how far it got.");
81
+ }
82
+ else {
83
+ const failed = last.agent !== "exited" || ("exitCode" in last && last.exitCode !== 0);
84
+ watch.stop(failed ? `The agent stopped after ${elapsed(started)}` : `The agent is done after ${elapsed(started)}`, failed);
85
+ const summary = last.log.trim().split("\n").slice(-3).join("\n");
86
+ if (summary)
87
+ ui.note(failed ? "Last output" : "Agent's summary", summary);
88
+ }
89
+ ui.say(` ${c.dim(`bottleship box rm ${r.box.id}`)} when the review is over`);
90
+ ui.close(built ? `Review at ${r.url}` : "Done");
91
+ }
@@ -3,3 +3,4 @@ export declare function describe(source: SecretSource): string;
3
3
  export declare function readSecret(source: SecretSource): string;
4
4
  export declare function storeSecret(name: string, value: string): string;
5
5
  export declare function hasSecret(source: SecretSource): boolean;
6
+ export declare function forgetSecret(name: string): void;
@@ -1,6 +1,6 @@
1
1
  // Reads and stores secret values on this machine. Sources are described in types.ts (SecretSource).
2
2
  import { execFileSync } from "node:child_process";
3
- import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
3
+ import { mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
4
4
  import { join } from "node:path";
5
5
  import { HOME } from "./paths.js";
6
6
  const SERVICE = "bottleship";
@@ -56,3 +56,13 @@ export function hasSecret(source) {
56
56
  return false;
57
57
  }
58
58
  }
59
+ export function forgetSecret(name) {
60
+ if (useKeychain()) {
61
+ try {
62
+ sh("security", ["delete-generic-password", "-s", SERVICE, "-a", name]);
63
+ }
64
+ catch { }
65
+ }
66
+ else
67
+ rmSync(join(FILE_DIR, name), { force: true });
68
+ }
@@ -13,33 +13,12 @@ export interface AgentSpec {
13
13
  resume: string;
14
14
  env?: EnvMap;
15
15
  unset?: string[];
16
- login?: AgentLogin;
17
- }
18
- export type LoginRoute = "token" | "device" | "none";
19
- export interface AgentLogin {
20
- routes: LoginRoute[];
21
- use: LoginRoute;
22
- token?: {
16
+ login?: {
23
17
  env: string;
24
18
  store: string;
25
19
  command?: string;
26
20
  hint?: string;
27
21
  };
28
- tokens?: {
29
- env: string;
30
- store: string;
31
- hint?: string;
32
- }[];
33
- device?: {
34
- command: string;
35
- status: string;
36
- setup?: string;
37
- timeout?: number;
38
- };
39
- }
40
- export interface ProviderSpec {
41
- module: string;
42
- [option: string]: unknown;
43
22
  }
44
23
  export interface Config {
45
24
  provider: string;
@@ -49,12 +28,7 @@ export interface Config {
49
28
  email: string;
50
29
  };
51
30
  env: EnvMap;
52
- providers: Record<string, ProviderSpec>;
53
31
  agents: Record<string, AgentSpec>;
54
- templates: Record<string, {
55
- name: string;
56
- ref: string;
57
- }>;
58
32
  }
59
33
  export interface ExecResult {
60
34
  code: number | null;
@@ -68,50 +42,6 @@ export interface BoxStream {
68
42
  close(): void;
69
43
  onClose(cb: (code?: number | null) => void): void;
70
44
  }
71
- export type BoxStatus = "running" | "gone";
72
- export interface TemplateSpec {
73
- name: string;
74
- dockerfile: string;
75
- }
76
- export interface Provider {
77
- template?(spec: TemplateSpec): Promise<string>;
78
- create(opts: {
79
- id: string;
80
- source?: string;
81
- template?: string;
82
- }): Promise<{
83
- id: string;
84
- }>;
85
- exec(id: string, script: string, opts?: {
86
- stdin?: string | Buffer;
87
- }): Promise<ExecResult>;
88
- destroy(id: string): Promise<unknown>;
89
- openStream?(id: string, script: string): BoxStream | Promise<BoxStream>;
90
- status?(id: string): Promise<BoxStatus>;
91
- }
92
- export interface LoadedProvider extends Provider {
93
- name: string;
94
- }
95
- export interface RunOptions {
96
- stdin?: string | Buffer;
97
- check?: boolean;
98
- env?: Record<string, string>;
99
- }
100
- export interface ProviderKit {
101
- run(cmd: string, args: string[], opts?: RunOptions): Promise<ExecResult>;
102
- shq(s: unknown): string;
103
- fill(template: string, values: Record<string, unknown>): string;
104
- spawnStream(cmd: string, args: string[]): BoxStream;
105
- wsStream(url: string, headers?: Record<string, string>): BoxStream;
106
- }
107
- export interface ProviderCtx {
108
- name: string;
109
- options: Record<string, any>;
110
- secret(source: SecretSource): string;
111
- log(...args: unknown[]): void;
112
- kit: ProviderKit;
113
- }
114
- export type ProviderFactory = (ctx: ProviderCtx) => Provider | Promise<Provider>;
115
45
  export interface BsErrorJSON {
116
46
  error: string;
117
47
  message: string;
package/dist/src/ui.d.ts CHANGED
@@ -9,8 +9,12 @@ export declare const c: {
9
9
  white: (s: string) => string;
10
10
  };
11
11
  export declare function header(): string;
12
+ export interface Spinner {
13
+ message(text: string): void;
14
+ stop(text: string, failed?: boolean): void;
15
+ }
12
16
  export interface UI {
13
- intro(): void;
17
+ intro(title?: string): void;
14
18
  say(line?: string): void;
15
19
  section(title: string): void;
16
20
  ok(label: string, detail?: string): void;
@@ -25,6 +29,7 @@ export interface UI {
25
29
  confirm(question: string): Promise<boolean>;
26
30
  handOver(command: string): Promise<number | null>;
27
31
  step<T>(label: string, fn: () => Promise<T>): Promise<T>;
32
+ spin(label: string): Spinner;
28
33
  fail(message: string, fix?: string): void;
29
34
  close(message?: string): void;
30
35
  }
package/dist/src/ui.js CHANGED
@@ -13,35 +13,29 @@ export const c = {
13
13
  blue: sgr("34"),
14
14
  white: sgr("97"),
15
15
  };
16
- // a ship in a bottle, with a color mask per line: c glass, y cork and stars, w sails, r hull and flags, b sea
16
+ // the sail on two waves, as on the review's pages, with a mask per line: w the sail and the near wave, d the far one
17
17
  const ART = [
18
- " .-----------------------------------------------.",
19
- " ccccccccccccccccccccccccccccccccccccccccccccccccc",
20
- " / \\",
21
- " c c",
22
- " _________/ * |> |> \\",
23
- " cccccccccc y wr wr c",
24
- "[####| /|\\ /|\\ . |",
25
- "yyyyyc www www y c",
26
- "[####| . / | \\ / | \\ |",
27
- "yyyyyc y w w w w w w c",
28
- "[####|____ /__|__\\ /__|__\\ * |",
29
- "yyyyyccccc wwwwwww wwwwwww y c",
30
- " \\ ~~~~~~\\__o___o___o___o___o__/~~~~~~ /",
31
- " c bbbbbbrrryrrryrrryrrryrrryrrrbbbbbb c",
32
- " \\ ~ ~ ~ ~ ~ ~ ~ ~ /",
33
- " c b b b b b b b b c",
34
- " '-----------------------------------------------'",
35
- " ccccccccccccccccccccccccccccccccccccccccccccccccc",
18
+ " │╲",
19
+ " ww",
20
+ " │ ╲",
21
+ " w ww",
22
+ " │ ╲",
23
+ " w w",
24
+ " │___╲",
25
+ " wwwww",
26
+ " ∿∿∿∿∿∿∿∿∿∿∿∿∿∿∿",
27
+ " ddddddddddddddd",
28
+ " ∿∿∿∿∿∿∿∿∿∿∿∿∿∿∿∿∿",
29
+ " wwwwwwwwwwwwwwwww",
36
30
  ];
37
- const paint = { c: c.cyan, y: c.yellow, w: c.white, r: c.red, b: c.blue };
31
+ const paint = { w: c.white, d: c.dim };
38
32
  export function header() {
39
33
  const rows = [];
40
34
  for (let i = 0; i < ART.length; i += 2) {
41
35
  const [text, mask] = [ART[i], ART[i + 1]];
42
36
  rows.push([...text].map((ch, j) => (paint[mask[j]] ?? String)(ch)).join(""));
43
37
  }
44
- return `\n${rows.join("\n")}\n\n ${c.bold("bottleship")} ${c.dim("coding agents in little boxes, run by your agent")}\n`;
38
+ return `\n${rows.join("\n")}\n\n ${c.bold("bottleship")}\n`;
45
39
  }
46
40
  // soft-wraps each line to width, keeping a list item's hanging indent
47
41
  function wrap(text, width) {
@@ -71,9 +65,9 @@ function answered(v) {
71
65
  export function terminalUI() {
72
66
  let closed = false;
73
67
  return {
74
- intro() {
68
+ intro(title = "setup") {
75
69
  process.stdout.write(header() + "\n");
76
- p.intro(c.bold(" setup "));
70
+ p.intro(c.bold(` ${title} `));
77
71
  },
78
72
  say: (line = "") => p.log.message(line.replace(/^ +/gm, "")),
79
73
  section: (title) => p.log.step(c.bold(title)),
@@ -112,6 +106,11 @@ export function terminalUI() {
112
106
  throw e;
113
107
  }
114
108
  },
109
+ spin(label) {
110
+ const s = p.spinner();
111
+ s.start(label);
112
+ return { message: (t) => s.message(t), stop: (t, failed) => s.stop(failed ? c.red(t) : t, failed ? 1 : 0) };
113
+ },
115
114
  fail(message, fix) {
116
115
  p.log.error(`${message}${fix ? "\n" + c.dim(fix) : ""}`);
117
116
  p.outro(c.red("Setup did not finish."));
@@ -0,0 +1,11 @@
1
+ import type { BoxStream, ExecResult } from "./types.ts";
2
+ export interface RunOptions {
3
+ stdin?: string | Buffer;
4
+ check?: boolean;
5
+ env?: Record<string, string>;
6
+ cwd?: string;
7
+ }
8
+ export declare function run(cmd: string, args: string[], opts?: RunOptions): Promise<ExecResult>;
9
+ export declare function spawnStream(cmd: string, args: string[]): BoxStream;
10
+ export declare const shq: (s: unknown) => string;
11
+ export declare function fill(template: string, values: Record<string, unknown>): string;
@@ -0,0 +1,40 @@
1
+ // Process helpers: run a command to completion, or keep one open as a stream.
2
+ import { spawn } from "node:child_process";
3
+ function spawnPiped(cmd, args, { env, cwd } = {}) {
4
+ return spawn(cmd, args, { stdio: ["pipe", "pipe", "pipe"], cwd, env: env ? { ...process.env, ...env } : process.env });
5
+ }
6
+ // resolves { code, stdout, stderr }; with check (default true) a non-zero exit rejects
7
+ export function run(cmd, args, opts = {}) {
8
+ const { stdin, check = true } = opts;
9
+ return new Promise((resolve, reject) => {
10
+ const child = spawnPiped(cmd, args, opts);
11
+ let stdout = "";
12
+ let stderr = "";
13
+ child.stdout.on("data", (d) => (stdout += d));
14
+ child.stderr.on("data", (d) => (stderr += d));
15
+ child.on("error", reject);
16
+ child.on("close", (code) => {
17
+ if (check && code !== 0)
18
+ reject(new Error(`${cmd} ${args.slice(0, 3).join(" ")}... exited ${code}: ${stderr.trim().slice(-500)}`));
19
+ else
20
+ resolve({ code, stdout, stderr });
21
+ });
22
+ child.stdin.on("error", () => { });
23
+ child.stdin.end(stdin ?? "");
24
+ });
25
+ }
26
+ // a long-lived child process as a BoxStream
27
+ export function spawnStream(cmd, args) {
28
+ const child = spawnPiped(cmd, args);
29
+ child.stdin.on("error", () => { });
30
+ return { stdin: child.stdin, stdout: child.stdout, stderr: child.stderr, close: () => child.kill(), onClose: (cb) => child.on("close", (code) => cb(code)) };
31
+ }
32
+ // single-quote for sh
33
+ export const shq = (s) => `'${String(s).replace(/'/g, `'\\''`)}'`;
34
+ // fills {name} with the shell-quoted value, missing values with ""
35
+ export function fill(template, values) {
36
+ return template.replace(/\{(\w+)\}/g, (_, k) => {
37
+ const v = values[k];
38
+ return v === undefined || v === null ? "" : shq(v);
39
+ });
40
+ }