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/cli.js CHANGED
@@ -1,93 +1,137 @@
1
- // bottleship CLI. Built for agents: JSON on stdout, errors are {error, message, fix} with exit 1.
2
- // Only `init` on a terminal talks to a human.
3
- import { execFileSync } from "node:child_process";
4
- import { readdirSync, readFileSync, statSync } from "node:fs";
1
+ // bottleship CLI. The top-level commands are for a person at a terminal; `bottleship agent <cmd>` is for coding agents:
2
+ // JSON on stdout, errors are {error, message, fix} with exit 1.
5
3
  import { join } from "node:path";
6
4
  import { parseArgs } from "node:util";
7
5
  import { ROOT } from "./paths.js";
8
- import { BsError, getProvider, loadConfig } from "./config.js";
9
- import { ask, goneFix, inBox, newBox, openBox, parseId, waitAgent, agentState } from "./boxes.js";
10
- import { init } from "./init.js";
6
+ import { BsError, loadConfig } from "./config.js";
7
+ import { getProvider, providerNames } from "./provider.js";
8
+ import { agentState, ask, createBox, goneFix, inBox, openBox, parseId, prepare, startAgent, waitAgent } from "./boxes.js";
9
+ import { checkout, dotenvFiles, readRepoEnv, toRepo, workingTree } from "./repo.js";
10
+ import { init, setUp } from "./init.js";
11
+ import { onboardPrompt } from "./onboard.js";
12
+ import { REVIEW_PORT, startReview } from "./review/start.js";
13
+ import { updateReview, waitForComments } from "./review/loop.js";
14
+ import { reviewInTerminal } from "./review/terminal.js";
15
+ import { parsePrRef, parseRepoArg, prRepo, prRepoFromRemote, sameRepo } from "./review/target.js";
11
16
  import { c, terminalUI } from "./ui.js";
12
- import { shq } from "../presets/providers/util.js";
13
- const HELP = `bottleship: run coding agents in remote sandboxes ("boxes"), like subagents. Output is JSON.
17
+ import { shq } from "./util.js";
18
+ const HELP = ` init [--reset] set up this machine for boxes, then the repo you are in
19
+ review [<pr>] a guided review of a PR (17, owner/name#17 or its link), or of this checkout's changes,
20
+ [--base <ref>] in your browser
21
+ box list the boxes on this machine, with their reviews
22
+ box rm [<id>] removes a box; without an id, asks which
23
+ help
24
+
25
+ Your coding agent uses the rest: bottleship agent help
26
+ README: ${join(ROOT, "README.md")}`;
27
+ const AGENT_HELP = `bottleship agent: the commands for coding agents. Each prints JSON; errors are {error, message, fix} with exit 1, and most results say in "next" what to do next.
14
28
 
15
- init [--yes] [--provider P] [--agent A] [--git-name N] [--git-email E]
16
- [--host H] (ssh) the host boxes run on
17
- [--i-understand-no-isolation] (local, ssh) the user accepts agents running unsandboxed
18
- [--provider custom --name N] scaffolds ~/.bottleship/providers/N.ts for your own sandboxes
19
- one-time setup; a human runs it in a terminal, or an agent with --yes
29
+ Boxes
20
30
  new "<task>" [--repo owner/name | --no-repo] [--provider P] [--agent A]
21
- new box: clones the repo (default: the cwd's origin; local: a worktree), starts the agent
31
+ a box with the repo cloned (default: the cwd's origin), its environment prepared, and an agent working on the task.
32
+ Write a complete prompt: the task, the context it needs, and what done means, including "work on branch <name>,
33
+ commit and push as you go, and open a PR when done". The box agent sees nothing of your conversation.
22
34
  status <id> [--wait S] [--lines N]
23
- is the agent running, its exit code and last output lines
24
- ask <id> "<message>" follow-up in the same conversation, once the agent has finished
25
- exec <id> [-i] -- <cmd...> run a command in the box's workspace; -i streams stdin/stdout
26
- rm <id> destroy the box
27
- help
35
+ whether the box agent is running, its exit code and last output; --wait blocks until it finishes
36
+ ask <id> "<message>"
37
+ a follow-up in the same conversation, once the agent has finished
38
+ exec <id> [-i] -- <cmd...>
39
+ runs a command in the box's workspace (git log, tests, logs in ~/.bottleship/); one argument is a script,
40
+ several are argv; -i streams stdin and stdout
41
+ rm <id>
42
+ destroys the box, when its work is merged or abandoned. If a box is gone, start a new one on the branch it pushed.
43
+ list
44
+ every box on this machine, with its review url if it is a review
45
+ Treat each box as a subagent you manage, and keep track of the ids you start.
46
+
47
+ Reviews
48
+ review <pr> [--repo owner/name] [--provider P] [--agent A]
49
+ pr: 17, #17, owner/name#17, or the PR's link; a bare number is in the cwd's repo. Returns a url at once: show it
50
+ to the user. The box agent builds a guided tour of the PR there; status <id> --wait 1800 returns when it is done.
51
+ The user's comments go into their pending review on GitHub, and the tour's chat goes to the box agent, which
52
+ commits fixes to the PR's branch when asked.
53
+ review --note "<what changed and why>" [--base <ref>] [--provider P] [--agent A]
54
+ the cwd checkout's local changes (commits, uncommitted and untracked files) against where the branch left the
55
+ default branch, or --base. The checkout stays untouched; the note is checked in the tour like a PR description.
56
+ Returns a url at once: show it to the user, then run review next <id> in the background.
57
+ review next <id> [--note "<what changed>" --reply <message id>="<your answer>"...] [--timeout S]
58
+ local reviews: waits for what the user sends you from the tour's chat, each message with an id, the page and
59
+ lines it is about, and the conversation so far. Address the messages in the checkout, then run it again with
60
+ --note and one --reply per message: it brings the checkout into the box, answers, has the box agent refresh the
61
+ tour, and waits for the next messages. When it returns done, the user has finished: do what their note says.
62
+
63
+ Setup
64
+ onboard
65
+ the instructions for writing this repo's box environment (.bottleship/) with the user
66
+ onboard --check [--timeout S] [--provider P]
67
+ builds a box from this working tree, runs .bottleship/setup.sh and start.sh, waits for the ports, returns their URLs
68
+ init --yes [--provider P] [--agent A] [--git-name N] [--git-email E]
69
+ sets up this machine without prompts. The user normally runs bottleship init in their own terminal, which also
70
+ sets up the repo they are in.
71
+ Secrets: never ask the user to paste one to you. If one is missing, ask them to run bottleship init in their terminal.
28
72
 
29
73
  README: ${join(ROOT, "README.md")}`;
30
74
  const out = (obj) => void process.stdout.write(JSON.stringify(obj, null, 2) + "\n");
31
- const git = (cwd, ...args) => {
32
- try {
33
- return execFileSync("git", args, { cwd, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }).trim();
34
- }
35
- catch {
36
- return "";
37
- }
38
- };
39
- // owner/name or a GitHub URL becomes an https clone URL, so the GH_TOKEN helper applies; anything else is cloned as is
40
- function toRepo(s) {
41
- const gh = s.match(/^([\w.-]+\/[\w.-]+?)(?:\.git)?$/) ?? s.match(/github\.com[:/]([^/]+\/[^/]+?)(?:\.git)?$/);
42
- return gh ? { url: `https://github.com/${gh[1]}.git`, label: gh[1] } : { url: s, label: s };
43
- }
44
- // .env files at the repo root, minus templates like .env.example
45
- function dotenvFiles(root) {
46
- const names = readdirSync(root).filter((f) => /^\.env/.test(f) && !/\.(example|sample|template)$/.test(f) && statSync(join(root, f)).isFile());
47
- return Object.fromEntries(names.map((f) => [f, readFileSync(join(root, f), "utf8")]));
48
- }
49
75
  function args(argv, options = {}) {
50
76
  return parseArgs({ args: argv, options, allowPositionals: true, strict: true });
51
77
  }
52
78
  const need = (v, usage) => {
53
79
  if (!v)
54
- throw new BsError("bad_request", `usage: bottleship ${usage}`, "bottleship help");
80
+ throw new BsError("bad_request", `usage: bottleship agent ${usage}`, "bottleship agent help");
55
81
  return v;
56
82
  };
57
83
  async function cmdNew(argv) {
58
84
  const { values, positionals } = args(argv, { repo: { type: "string" }, "no-repo": { type: "boolean" }, provider: { type: "string" }, agent: { type: "string" } });
59
85
  const prompt = need(positionals.join(" ").trim(), 'new "<task>" [--repo owner/name | --no-repo]');
60
86
  const cfg = loadConfig();
61
- const origin = git(process.cwd(), "remote", "get-url", "origin");
62
- const here = origin ? toRepo(origin) : undefined;
87
+ const here = checkout(process.cwd());
63
88
  let repo;
64
- let dotenv = {};
65
- // the local checkout, when the task's repo is the cwd's repo
66
- let source;
67
89
  if (values.repo)
68
90
  repo = toRepo(values.repo);
69
91
  else if (!values["no-repo"]) {
70
- if (!here)
92
+ if (!here?.origin)
71
93
  throw new BsError("no_repo", "this directory has no git origin to clone", "pass --repo owner/name, or --no-repo for an empty workspace");
72
- repo = here;
73
- }
74
- if (repo && repo.url === here?.url) {
75
- source = git(process.cwd(), "rev-parse", "--show-toplevel");
76
- dotenv = dotenvFiles(source);
94
+ repo = here.origin;
77
95
  }
96
+ // the local checkout of the task's repo gives the box its .env files and its image
97
+ const local = repo && here && repo.url === here.origin?.url ? here.root : undefined;
78
98
  const agent = values.agent ?? cfg.agent;
79
- const b = await newBox(cfg, { provider: values.provider ?? cfg.provider, agent, prompt, repo, dotenv, source });
80
- return { id: b.id, provider: b.provider.name, agent, repo: repo?.label ?? null, ...(b.warning && { warning: b.warning }), ...(b.login && { login: b.login }), next: b.login ? loginNext(b.id) : `bottleship status ${b.id} --wait 600` };
99
+ const b = await createBox(cfg, {
100
+ provider: values.provider ?? cfg.provider,
101
+ label: prompt,
102
+ agent,
103
+ ...(repo && { workspace: { clone: repo } }),
104
+ ...(local && { repoEnv: readRepoEnv(local), dotenv: dotenvFiles(local) }),
105
+ });
106
+ await startAgent(b, prompt);
107
+ return { id: b.id, provider: b.provider.name, agent, repo: repo?.label ?? null, next: `bottleship agent status ${b.id} --wait 600` };
108
+ }
109
+ async function cmdCheck(values) {
110
+ const cfg = loadConfig();
111
+ const repo = checkout(process.cwd());
112
+ if (!repo)
113
+ throw new BsError("no_repo", "this directory is not in a git repository", "run bottleship agent onboard --check from inside the repo");
114
+ const repoEnv = readRepoEnv(repo.root);
115
+ if (!repoEnv)
116
+ throw new BsError("no_repo_env", `${repo.root} has no .bottleship/ yet`, "set it up: bottleship agent onboard gives the instructions");
117
+ const b = await createBox(cfg, { provider: values.provider ?? cfg.provider, label: "check", workspace: { tar: await workingTree(repo.root) }, repoEnv, dotenv: dotenvFiles(repo.root) });
118
+ const r = await prepare(b, Number(values.timeout ?? 900));
119
+ const urls = r.stage === "ready" && b.provider.url ? Object.fromEntries(await Promise.all(repoEnv.ports.map(async (p) => [p, await b.provider.url(b.box, p)]))) : {};
120
+ return {
121
+ id: b.id,
122
+ stage: r.stage,
123
+ ...(r.message && { message: r.message }),
124
+ ...(r.log && { log: r.log }),
125
+ ...(r.stage === "ready" && { urls }),
126
+ next: r.stage === "ready"
127
+ ? `open the URLs to confirm the app serves; bottleship agent rm ${b.id} when done`
128
+ : `fix .bottleship/ and run bottleship agent onboard --check again; inspect with bottleship agent exec ${b.id} -- <cmd> (logs in ~/.bottleship/), then bottleship agent rm ${b.id}`,
129
+ };
81
130
  }
82
- const loginNext = (id) => `show the user this URL and code; the task starts once they approve. Then bottleship status ${id} --wait 600`;
83
131
  function statusNext(id, s) {
84
132
  if (s.agent === "running")
85
- return `bottleship status ${id} --wait 600`;
86
- if (s.agent === "waiting_for_login")
87
- return s.login ? loginNext(id) : `bottleship status ${id}`;
88
- if (s.agent === "login_failed")
89
- return `the agent's login failed or timed out, see log. bottleship ask ${id} "<prompt>" starts a new login; if the task never ran, send the whole task again`;
90
- return `bottleship ask ${id} "<follow-up>", or bottleship rm ${id} when done`;
133
+ return `bottleship agent status ${id} --wait 600`;
134
+ return `bottleship agent ask ${id} "<follow-up>", or bottleship agent rm ${id} when done`;
91
135
  }
92
136
  async function cmdStatus(argv) {
93
137
  const { values, positionals } = args(argv, { wait: { type: "string" }, lines: { type: "string" } });
@@ -113,10 +157,8 @@ async function cmdAsk(argv) {
113
157
  const [id, ...words] = positionals;
114
158
  const message = need(words.join(" ").trim(), 'ask <id> "<message>"');
115
159
  const b = await openBox(loadConfig(), need(id, 'ask <id> "<message>"'));
116
- const login = await ask(b, message);
117
- if (login)
118
- return { id: b.id, agent: "waiting_for_login", login, next: loginNext(b.id) };
119
- return { id: b.id, agent: "running", next: `bottleship status ${b.id} --wait 600` };
160
+ await ask(b, message);
161
+ return { id: b.id, agent: "running", next: `bottleship agent status ${b.id} --wait 600` };
120
162
  }
121
163
  // one argument is a script, several are a command whose arguments are passed literally
122
164
  async function cmdExec(argv) {
@@ -133,9 +175,7 @@ async function cmdExec(argv) {
133
175
  process.exitCode = r.code ?? 1;
134
176
  return;
135
177
  }
136
- if (!b.provider.openStream)
137
- throw new BsError("no_stream", `provider ${b.provider.name} has no openStream()`, "use exec without -i, or add openStream to the provider");
138
- const s = await b.provider.openStream(b.box, script);
178
+ const s = b.provider.stream(b.box, script);
139
179
  process.stdin.pipe(s.stdin);
140
180
  s.stdout.pipe(process.stdout);
141
181
  s.stderr.pipe(process.stderr);
@@ -148,32 +188,205 @@ async function cmdExec(argv) {
148
188
  }
149
189
  async function cmdRm(argv) {
150
190
  const id = need(args(argv).positionals[0], "rm <id>");
151
- const cfg = loadConfig();
152
- const { provider, box } = parseId(cfg, id);
153
- await (await getProvider(cfg, provider)).destroy(box);
191
+ const { provider, box } = parseId(loadConfig(), id);
192
+ await getProvider(provider).destroy(box);
154
193
  return { removed: `${provider}:${box}` };
155
194
  }
156
- async function cmdInit(argv, ui) {
157
- const { values } = args(argv, { yes: { type: "boolean", short: "y" }, provider: { type: "string" }, host: { type: "string" }, name: { type: "string" }, agent: { type: "string" }, "git-name": { type: "string" }, "git-email": { type: "string" }, "i-understand-no-isolation": { type: "boolean" } });
158
- const result = await init({ provider: values.provider, host: values.host, name: values.name, agent: values.agent, gitName: values["git-name"], gitEmail: values["git-email"], noIsolation: values["i-understand-no-isolation"] }, ui);
195
+ async function cmdInit(argv) {
196
+ const { values } = args(argv, { yes: { type: "boolean", short: "y" }, provider: { type: "string" }, agent: { type: "string" }, "git-name": { type: "string" }, "git-email": { type: "string" }, reset: { type: "boolean" } });
197
+ const result = await init({ provider: values.provider, agent: values.agent, gitName: values["git-name"], gitEmail: values["git-email"], reset: values.reset });
159
198
  if (!result.ok)
160
199
  process.exitCode = 1;
161
200
  return result;
162
201
  }
202
+ // the onboarding instructions, or with --check a box that tries them
203
+ async function cmdOnboard(argv) {
204
+ const { values } = args(argv, { check: { type: "boolean" }, timeout: { type: "string" }, provider: { type: "string" } });
205
+ if (values.check)
206
+ return cmdCheck(values);
207
+ const { root, prompt } = onboardPrompt(process.cwd());
208
+ return { repo: root, instructions: prompt, next: "follow instructions, working with the user" };
209
+ }
210
+ // the boxes every provider here knows of; a provider without a list script, or one that fails (docker not running), has none
211
+ async function listBoxes() {
212
+ const all = await Promise.all(providerNames().map(async (name) => {
213
+ try {
214
+ const p = getProvider(name);
215
+ if (!p.list)
216
+ return [];
217
+ return await Promise.all((await p.list()).map(async ({ box, created }) => {
218
+ const review = box.startsWith("bs-review-") && p.url ? await p.url(box, REVIEW_PORT).catch(() => undefined) : undefined;
219
+ return { id: `${name}:${box}`, ...(created && { created }), ...(review && { review }) };
220
+ }));
221
+ }
222
+ catch {
223
+ return [];
224
+ }
225
+ }));
226
+ return all.flat().sort((a, b) => (b.created ?? "").localeCompare(a.created ?? ""));
227
+ }
228
+ const ago = (iso) => {
229
+ if (!iso)
230
+ return "";
231
+ const m = Math.round((Date.now() - Date.parse(iso)) / 60000);
232
+ return m < 60 ? `${m}m ago` : m < 48 * 60 ? `${Math.round(m / 60)}h ago` : `${Math.round(m / 1440)}d ago`;
233
+ };
234
+ // a person's view of the boxes: a list to read, and removing one by id or picked from it
235
+ async function cmdBox(argv, ui) {
236
+ const [sub, ...rest] = argv;
237
+ const { positionals } = args(rest);
238
+ if (sub === "list") {
239
+ const boxes = await listBoxes();
240
+ if (!boxes.length)
241
+ return void process.stdout.write("No boxes.\n");
242
+ const w = Math.max(...boxes.map((b) => b.id.length));
243
+ for (const b of boxes)
244
+ process.stdout.write(`${b.id.padEnd(w)} ${c.dim(ago(b.created).padEnd(8))} ${b.review ?? ""}\n`);
245
+ return;
246
+ }
247
+ if (sub === "rm") {
248
+ let id = positionals[0];
249
+ if (!id) {
250
+ const boxes = await listBoxes();
251
+ if (!boxes.length)
252
+ return void process.stdout.write("No boxes.\n");
253
+ id = await ui.choose("Which box should go?", boxes.map((b) => ({ value: b.id, hint: [ago(b.created), b.review].filter(Boolean).join(" ") })), boxes[0].id);
254
+ }
255
+ const { provider, box } = parseId(loadConfig(), id);
256
+ await getProvider(provider).destroy(box);
257
+ return void process.stdout.write(`Removed ${provider}:${box}\n`);
258
+ }
259
+ throw new BsError("bad_request", sub ? `unknown command "box ${sub}"` : "usage: bottleship box list | box rm [<id>]", "bottleship help");
260
+ }
261
+ async function cmdReview(argv, ui) {
262
+ // a person picks the PR and, for local changes, the base; the rest is the config's, or an agent's to say
263
+ const { values, positionals } = ui
264
+ ? args(argv, { base: { type: "string" } })
265
+ : args(argv, { repo: { type: "string" }, provider: { type: "string" }, agent: { type: "string" }, base: { type: "string" }, note: { type: "string" }, timeout: { type: "string" }, reply: { type: "string", multiple: true } });
266
+ if (!ui && positionals[0] === "next")
267
+ return out(await reviewNext(need(positionals[1], "review next <id>"), values));
268
+ if (!positionals[0])
269
+ return reviewLocal(values, ui);
270
+ const ref = parsePrRef(positionals[0]);
271
+ const flag = values.repo ? parseRepoArg(values.repo) : undefined;
272
+ if (ref.repo && flag && !sameRepo(ref.repo, flag))
273
+ throw new BsError("bad_request", `the PR is in ${prRepo(ref.repo).label}, but --repo says ${prRepo(flag).label}`, "drop --repo; the PR link or shorthand already names the repo");
274
+ const cfg = loadConfig();
275
+ const here = checkout(process.cwd());
276
+ // the PR's own repo, else --repo, else the repo this command runs in
277
+ const named = ref.repo ?? flag;
278
+ const repo = named ? prRepo(named) : here?.remote ? prRepoFromRemote(here.remote) : undefined;
279
+ if (!repo)
280
+ throw new BsError("no_repo", "this directory is not in a git repo with an origin, so it is unclear which repo the PR is in", "run it inside the repo, pass --repo owner/name, or use a PR link or owner/name#17");
281
+ // the local checkout of the same repo supplies .env files, and .bottleship/ when the PR has none yet
282
+ const local = here?.remote && prRepoFromRemote(here.remote).label === repo.label ? here.root : undefined;
283
+ const opts = { source: { pr: { repo, number: ref.number } }, local, provider: values.provider ?? cfg.provider, agent: values.agent ?? cfg.agent };
284
+ if (ui)
285
+ return reviewInTerminal(ui, cfg, opts);
286
+ const r = await startReview(cfg, opts);
287
+ out({
288
+ id: r.box.id,
289
+ url: r.url,
290
+ pr: r.pr,
291
+ env: r.env,
292
+ next: `show the user the url: it says the review is being set up until the box agent has built the tour, then shows it. bottleship agent status ${r.box.id} --wait 1800 returns when the agent is done; bottleship agent ask ${r.box.id} "<follow-up>" for more; bottleship agent rm ${r.box.id} when the review is over`,
293
+ });
294
+ }
295
+ // the checkout's local changes: the working tree against where its branch left the default branch (or --base)
296
+ async function reviewLocal(values, ui) {
297
+ const cfg = loadConfig();
298
+ const here = checkout(process.cwd());
299
+ if (!here)
300
+ throw new BsError("no_repo", "this directory is not in a git repo, so there are no local changes to review", "run it inside the repo, or pass a PR: review <pr>");
301
+ const repo = here.remote ? prRepoFromRemote(here.remote).label : undefined;
302
+ const opts = { source: { changes: { root: here.root, base: values.base, note: values.note, repo } }, local: here.root, provider: values.provider ?? cfg.provider, agent: values.agent ?? cfg.agent };
303
+ if (ui)
304
+ return reviewInTerminal(ui, cfg, opts);
305
+ const r = await startReview(cfg, opts);
306
+ out({
307
+ id: r.box.id,
308
+ url: r.url,
309
+ pr: r.pr,
310
+ env: r.env,
311
+ next: `show the user the url: it says the review is being set up until the box agent has built the tour, then shows it. Then run bottleship agent review next ${r.box.id} in the background: it returns what the reviewer writes to you in the tour's chat, or that they are done`,
312
+ });
313
+ }
314
+ // a local review's loop: the agent's update and answers when it has them, then what the reviewer sends next
315
+ async function reviewNext(id, o) {
316
+ const usage = 'review next <id> [--note "<what changed>" --reply <message id>="<your answer>"...]';
317
+ const replies = o.reply ?? [];
318
+ if (replies.length && !o.note?.trim())
319
+ throw new BsError("bad_request", `usage: bottleship agent ${usage}`, "say in --note what you changed, or that nothing did, for the reviewer");
320
+ const answers = {};
321
+ for (const r of replies) {
322
+ const m = r.match(/^(c\d+)=([\s\S]+)$/);
323
+ if (!m)
324
+ throw new BsError("bad_request", `--reply "${r}" is not <message id>=<text>`, 'e.g. --reply c3="Moved the check before the query"');
325
+ answers[m[1]] = m[2];
326
+ }
327
+ const b = await openBox(loadConfig(), id);
328
+ let sent;
329
+ if (o.note?.trim()) {
330
+ const here = checkout(process.cwd());
331
+ if (!here)
332
+ throw new BsError("no_repo", "this directory is not in a git repo", "run it in the checkout the review is of");
333
+ sent = await updateReview(b, here.root, { note: o.note.trim(), answers });
334
+ }
335
+ const w = await waitForComments(b, o.timeout === undefined ? undefined : Number(o.timeout));
336
+ const base = { id: b.id, ...sent, comments: w.comments };
337
+ if (w.done)
338
+ return { ...base, done: true, ...(w.done.note && { note: w.done.note }), next: `the reviewer is done${w.comments.length ? ", with these last messages" : ""}: do what they say${w.done.note ? " and what the note says" : ""}, then bottleship agent rm ${b.id}` };
339
+ if (!w.comments.length)
340
+ return { ...base, next: `nothing yet: bottleship agent review next ${b.id} again, in the background` };
341
+ return {
342
+ ...base,
343
+ next: `answer each message, changing this checkout only where the reviewer asks you to, then run bottleship agent review next ${b.id} --note "<what changed, or that nothing did>" --reply <message id>="<your answer>" (one --reply per message) in the background: it sends your update and waits for the next messages`,
344
+ };
345
+ }
346
+ // commands that moved, and where to
347
+ const MOVED = {
348
+ new: "bottleship agent new", status: "bottleship agent status", ask: "bottleship agent ask", exec: "bottleship agent exec", rm: "bottleship box rm, or for agents bottleship agent rm",
349
+ check: "bottleship agent onboard --check", onboard: "bottleship init sets up the repo you are in; agents run bottleship agent onboard",
350
+ };
351
+ async function agentCmd(argv) {
352
+ const [cmd, ...rest] = argv;
353
+ switch (cmd) {
354
+ case "new": return out(await cmdNew(rest));
355
+ case "status": return out(await cmdStatus(rest));
356
+ case "ask": return out(await cmdAsk(rest));
357
+ case "exec": return await cmdExec(rest);
358
+ case "rm": return out(await cmdRm(rest));
359
+ case "list": return out({ boxes: await listBoxes() });
360
+ case "review": return await cmdReview(rest);
361
+ case "onboard": return out(await cmdOnboard(rest));
362
+ case "init": return out(await cmdInit(rest));
363
+ case "help":
364
+ case "--help":
365
+ case "-h":
366
+ case undefined:
367
+ return void process.stdout.write(AGENT_HELP + "\n");
368
+ default:
369
+ throw new BsError("bad_request", `unknown command "agent ${cmd}"`, "bottleship agent help");
370
+ }
371
+ }
372
+ // the top-level commands talk to a person; without a terminal they point the caller to the agent ones
373
+ function terminal(cmd, agentCmd = cmd) {
374
+ if (!(process.stdin.isTTY && process.stdout.isTTY))
375
+ throw new BsError("needs_terminal", `bottleship ${cmd} is for a person at a terminal`, `agents run bottleship agent ${agentCmd}; see bottleship agent help`);
376
+ return terminalUI();
377
+ }
163
378
  async function main() {
164
379
  const [cmd, ...argv] = process.argv.slice(2);
165
- // init talks to a human on a terminal, unless told --yes
166
- const ui = cmd === "init" && process.stdin.isTTY && process.stdout.isTTY && !argv.includes("--yes") && !argv.includes("-y") ? terminalUI() : undefined;
380
+ let ui;
167
381
  try {
168
382
  switch (cmd) {
169
- case "new": return out(await cmdNew(argv));
170
- case "status": return out(await cmdStatus(argv));
171
- case "ask": return out(await cmdAsk(argv));
172
- case "exec": return await cmdExec(argv);
173
- case "rm": return out(await cmdRm(argv));
383
+ case "agent": return await agentCmd(argv);
384
+ case "review": return await cmdReview(argv, (ui = terminal(cmd)));
385
+ // plain lines, not the framed setup look
386
+ case "box": return await cmdBox(argv, terminal(cmd, argv[0] === "rm" ? "rm" : "list"));
174
387
  case "init": {
175
- const r = await cmdInit(argv, ui);
176
- return ui ? undefined : out(r);
388
+ const { values } = args(argv, { reset: { type: "boolean" } });
389
+ return await setUp(process.cwd(), { reset: values.reset }, (ui = terminal(cmd)));
177
390
  }
178
391
  case "help":
179
392
  case "--help":
@@ -181,14 +394,16 @@ async function main() {
181
394
  case undefined:
182
395
  return void process.stdout.write(HELP + "\n");
183
396
  default:
184
- throw new BsError("bad_request", `unknown command "${cmd}"`, "bottleship help");
397
+ throw new BsError("bad_request", `unknown command "${cmd}"`, MOVED[cmd] ? `it moved: ${MOVED[cmd]}` : "bottleship help");
185
398
  }
186
399
  }
187
400
  catch (e) {
188
401
  const err = e instanceof BsError ? e
189
- : e.code?.startsWith("ERR_PARSE_ARGS") ? new BsError("bad_request", e.message, "bottleship help")
402
+ : e.code?.startsWith("ERR_PARSE_ARGS") ? new BsError("bad_request", e.message, cmd === "agent" ? "bottleship agent help" : "bottleship help")
190
403
  : new BsError("internal", e.message);
191
404
  process.exitCode = 1;
405
+ if (cmd === "box" && err.code !== "needs_terminal")
406
+ return void process.stderr.write(`${err.message}\n${err.fix ? c.dim(err.fix) + "\n" : ""}`);
192
407
  if (!ui)
193
408
  return out(err.toJSON());
194
409
  const fix = err.code === "secret_missing" ? "run `bottleship init` again once you have the value" : err.fix;
@@ -1,4 +1,4 @@
1
- import type { AgentSpec, BsErrorJSON, Config, LoadedProvider, SecretSource } from "./types.ts";
1
+ import type { AgentSpec, BsErrorJSON, Config, SecretSource } from "./types.ts";
2
2
  export declare class BsError extends Error {
3
3
  code: string;
4
4
  fix: string | undefined;
@@ -7,9 +7,6 @@ export declare class BsError extends Error {
7
7
  toJSON(): BsErrorJSON;
8
8
  }
9
9
  export declare const DEFAULTS: Config;
10
- export declare const unisolated: (cfg: Config, provider: string) => boolean;
11
- export declare const noIsolationAccepted: (cfg: Config, provider: string) => boolean;
12
- export declare function noIsolationError(provider: string): BsError;
13
10
  export declare function readUserConfig(): Partial<Config>;
14
11
  export declare function writeUserConfig(raw: Partial<Config>): void;
15
12
  export declare function loadConfig(raw?: Partial<Config>): Config;
@@ -23,5 +20,5 @@ export declare function boxEnv(cfg: Config, agentName: string): {
23
20
  env: Record<string, string>;
24
21
  missing: NamedSecret[];
25
22
  };
26
- export declare function neededSecrets(cfg: Config, providerName: string, agentName: string): NamedSecret[];
27
- export declare function getProvider(cfg: Config, name: string): Promise<LoadedProvider>;
23
+ export declare function neededSecrets(cfg: Config, agentName: string): NamedSecret[];
24
+ export declare function missingSecrets(names: string[]): BsError;
@@ -1,19 +1,9 @@
1
- var __rewriteRelativeImportExtension = (this && this.__rewriteRelativeImportExtension) || function (path, preserveJsx) {
2
- if (typeof path === "string" && /^\.\.?\//.test(path)) {
3
- return path.replace(/\.(tsx)$|((?:\.d)?)((?:\.[^./]+?)?)\.([cm]?)ts$/i, function (m, tsx, d, ext, cm) {
4
- return tsx ? preserveJsx ? ".jsx" : ".js" : d && (!ext || !cm) ? m : (d + ext + "." + cm.toLowerCase() + "js");
5
- });
6
- }
7
- return path;
8
- };
9
- // ~/.bottleship/config.json: personal, never from a repo. Decides providers, agents,
1
+ // ~/.bottleship/config.json: personal, never from a repo. Decides the provider, the agents,
10
2
  // the git identity, and which env vars (and secrets) every box gets.
11
3
  import { existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from "node:fs";
12
- import { isAbsolute, join, resolve } from "node:path";
13
- import { pathToFileURL } from "node:url";
14
- import { CONFIG_FILE, DIST, HOME, ROOT } from "./paths.js";
4
+ import { join } from "node:path";
5
+ import { CONFIG_FILE, HOME, ROOT } from "./paths.js";
15
6
  import { readSecret } from "./secrets.js";
16
- import * as kit from "../presets/providers/util.js";
17
7
  // errors agents can act on: a code, a message, and the next step
18
8
  export class BsError extends Error {
19
9
  code;
@@ -35,24 +25,8 @@ export const DEFAULTS = {
35
25
  git: { name: "", email: "" },
36
26
  // a fine-grained token for the repos the user picks; init asks for it
37
27
  env: { GH_TOKEN: { store: "github" } },
38
- providers: {
39
- docker: { module: "builtin:docker" },
40
- exe: { module: "builtin:exe" },
41
- e2b: { module: "builtin:e2b", apiKey: { store: "e2b" } },
42
- daytona: { module: "builtin:daytona", apiKey: { store: "daytona" } },
43
- modal: { module: "builtin:modal", tokenId: { store: "modal-token-id" }, tokenSecret: { store: "modal-token-secret" } },
44
- ssh: { module: "builtin:ssh" },
45
- local: { module: "builtin:local" },
46
- },
47
28
  agents: {},
48
- templates: {},
49
29
  };
50
- // providers whose boxes run agents unsandboxed, as the user, so the user has to accept that once in init
51
- export const unisolated = (cfg, provider) => ["builtin:local", "builtin:ssh"].includes(cfg.providers[provider]?.module ?? "");
52
- export const noIsolationAccepted = (cfg, provider) => cfg.providers[provider]?.acceptNoIsolation === true;
53
- export function noIsolationError(provider) {
54
- return new BsError("no_isolation_unconfirmed", `the ${provider} provider runs agents with no isolation and with permission prompts off, as the user, and the user has not accepted that`, `ask the user to run \`bottleship init --provider ${provider}\` in their terminal and confirm, or pick an isolated provider (docker, e2b, daytona, modal, exe)`);
55
- }
56
30
  function builtinAgents() {
57
31
  const dir = join(ROOT, "presets/agents");
58
32
  const files = readdirSync(dir).filter((f) => f.endsWith(".json"));
@@ -65,38 +39,25 @@ export function writeUserConfig(raw) {
65
39
  mkdirSync(HOME, { recursive: true });
66
40
  writeFileSync(CONFIG_FILE, JSON.stringify(raw, null, 2) + "\n");
67
41
  }
68
- // providers merge over the builtins, and each agent over its builtin; env replaces the default so entries can be dropped
42
+ // each agent merges over its builtin; env replaces the default so entries can be dropped
69
43
  export function loadConfig(raw = readUserConfig()) {
70
- return {
71
- ...DEFAULTS,
72
- ...raw,
73
- git: { ...DEFAULTS.git, ...raw.git },
74
- providers: { ...DEFAULTS.providers, ...raw.providers },
75
- agents: mergeAgents(builtinAgents(), raw.agents ?? {}),
76
- };
77
- }
78
- // a user entry for a builtin agent overrides its fields, and login's, so { "login": { "use": "token" } } is enough
79
- function mergeAgents(builtin, user) {
80
- const agents = { ...builtin };
81
- for (const [name, spec] of Object.entries(user)) {
82
- const login = builtin[name]?.login || spec.login ? { ...builtin[name]?.login, ...spec.login } : undefined;
83
- agents[name] = { ...builtin[name], ...spec, ...(login && { login }) };
84
- }
85
- return agents;
86
- }
87
- // the env var that logs a box's agent in on the token route; the device route logs in inside the box
88
- function loginEnv(cfg, agentName) {
89
- const l = getAgent(cfg, agentName).login;
90
- return l?.use === "token" && l.token ? { [l.token.env]: { store: l.token.store } } : {};
44
+ const agents = builtinAgents();
45
+ for (const [name, spec] of Object.entries(raw.agents ?? {}))
46
+ agents[name] = { ...agents[name], ...spec };
47
+ return { ...DEFAULTS, ...raw, git: { ...DEFAULTS.git, ...raw.git }, agents };
91
48
  }
92
- // the agent's env, its login token, then config env over both
93
- const allEnv = (cfg, agentName) => ({ ...getAgent(cfg, agentName).env, ...loginEnv(cfg, agentName), ...cfg.env });
94
49
  export function getAgent(cfg, name) {
95
50
  const a = cfg.agents[name];
96
51
  if (!a)
97
52
  throw new BsError("unknown_agent", `no agent "${name}"`, `known: ${Object.keys(cfg.agents).join(", ")}. Add one under "agents" in ${CONFIG_FILE}`);
98
53
  return a;
99
54
  }
55
+ // the agent's env, its login token, then config env over both
56
+ function allEnv(cfg, agentName) {
57
+ const agent = getAgent(cfg, agentName);
58
+ const login = agent.login ? { [agent.login.env]: { store: agent.login.store } } : {};
59
+ return { ...agent.env, ...login, ...cfg.env };
60
+ }
100
61
  export const isSource = (v) => typeof v === "object" && v !== null && ["store", "command", "env"].some((k) => typeof v[k] === "string");
101
62
  // the env every box of this agent gets, read now; missing secrets are listed, not thrown
102
63
  export function boxEnv(cfg, agentName) {
@@ -115,37 +76,10 @@ export function boxEnv(cfg, agentName) {
115
76
  }
116
77
  return { env, missing };
117
78
  }
118
- // every secret a box of this provider and agent needs: the provider's own API keys, the box env and the agent's login
119
- export function neededSecrets(cfg, providerName, agentName) {
120
- const fromProvider = Object.entries(cfg.providers[providerName] ?? {}).filter(([, v]) => isSource(v)).map(([k, v]) => ({ name: `providers.${providerName}.${k}`, source: v }));
121
- const fromEnv = Object.entries(allEnv(cfg, agentName)).filter(([, v]) => isSource(v)).map(([name, v]) => ({ name, source: v }));
122
- return [...fromProvider, ...fromEnv];
79
+ // every secret a box of this agent needs: the box env and the agent's login
80
+ export function neededSecrets(cfg, agentName) {
81
+ return Object.entries(allEnv(cfg, agentName)).filter(([, v]) => isSource(v)).map(([name, v]) => ({ name, source: v }));
123
82
  }
124
- // providers.<name> = { module, ...options }; module is "builtin:<preset>", an absolute path, or relative to HOME (.mjs or .ts)
125
- export async function getProvider(cfg, name) {
126
- const spec = cfg.providers[name];
127
- if (!spec)
128
- throw new BsError("unknown_provider", `no provider "${name}"`, `known: ${Object.keys(cfg.providers).join(", ")}. Add one under "providers" in ${CONFIG_FILE}`);
129
- const { module, ...options } = spec;
130
- const file = module.startsWith("builtin:")
131
- ? join(DIST, "presets/providers", `${module.slice("builtin:".length)}.js`)
132
- : isAbsolute(module) ? module : resolve(HOME, module);
133
- const bad = (msg) => new BsError("bad_provider", `provider "${name}" (${file}) ${msg}`, `fix providers.${name}.module in ${CONFIG_FILE}`);
134
- if (module.startsWith("builtin:") && !existsSync(file))
135
- throw new BsError("bad_provider", `${module} is not in this version of bottleship`, "update bottleship, or pick another provider with `bottleship init --provider <name>`");
136
- let factory;
137
- try {
138
- factory = (await import(__rewriteRelativeImportExtension(pathToFileURL(file).href))).default;
139
- }
140
- catch (e) {
141
- throw bad(`failed to load: ${e.message}`);
142
- }
143
- if (typeof factory !== "function")
144
- throw bad("has no default export function");
145
- const ctx = { name, options, secret: readSecret, log: (...a) => console.error(`[${name}]`, ...a), kit };
146
- const provider = { name, ...(await factory(ctx)) };
147
- for (const m of ["create", "exec", "destroy"])
148
- if (typeof provider[m] !== "function")
149
- throw bad(`has no ${m}()`);
150
- return provider;
83
+ export function missingSecrets(names) {
84
+ 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 });
151
85
  }