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
@@ -1,12 +1,11 @@
1
1
  import { type UI } from "./ui.ts";
2
- export declare const INSTRUCTIONS = "## Remote agents with bottleship\n\nYou can run other coding agents in remote sandboxes (\"boxes\") with the `bottleship` CLI, like subagents. Every command prints JSON; errors carry a `fix`.\n\n- Start remote work with `bottleship new \"<prompt>\"` from inside the repo (or `--repo owner/name`). Write a complete prompt: the task, the context it needs, and what done means, including \"work on branch <name>, commit and push as you go, and open a PR when done\". The box agent sees nothing of this conversation.\n- Treat each box as a subagent you manage. `new` returns an id; keep track of your ids yourself, bottleship keeps no list.\n- If `new` or `ask` returns a `login`, show the user its URL and code; the box agent starts once they approve it in their browser.\n- `bottleship status <id>` shows whether the agent is still running and the tail of its output; `--wait <seconds>` blocks until it finishes.\n- `bottleship ask <id> \"<follow-up>\"` continues the same conversation once the agent has finished.\n- `bottleship exec <id> -- <cmd>` inspects the box (git log, test output, files).\n- `bottleship rm <id>` when the work is merged or abandoned. If a box is gone, start a new one on the pushed branch.\n- If the user wants to watch or steer a box from their IDE, find out how their IDE attaches to an agent over a command (ACP) and give it `bottleship exec -i <id> -- <the agent's ACP command>`.\n- Secrets: never ask the user to paste a secret to you. If bottleship reports one missing, ask them to run `bottleship init` in their terminal.";
2
+ export declare const INSTRUCTIONS = "## Remote agents with bottleship\n\nThe `bottleship` CLI runs coding agents in boxes, isolated dev environments for a repo (Docker containers by default), and builds guided reviews of PRs and of local changes, which the user opens in their browser. Your commands are under `bottleship agent`; run `bottleship agent help` before you first use them in a session, for what each does and how to use it.\n\n- Use a box for work that can run on its own while you do something else, and treat it as a subagent you manage.\n- When the user wants to review a PR, start the review for them and give them its url.\n- Reviews of your own work: after a big change you made on your own (many files, or behaviour that is hard to see in the diff), start a local review before you hand over and give the user its url, unless the user has said otherwise. Skip it for small changes.\n- If the user stops or interrupts you while you start a review, or turns one down, ask them once when they want reviews (after every big change, only when they ask, never, or something else). Then rewrite the line above in this section of your instructions file to say exactly what they chose, and follow it from then on.\n- Secrets: never ask the user to paste a secret to you. If bottleship reports one missing, ask them to run `bottleship init` in their terminal.";
3
3
  export interface InitFlags {
4
4
  provider?: string;
5
- host?: string;
6
- name?: string;
7
5
  agent?: string;
8
6
  gitName?: string;
9
7
  gitEmail?: string;
10
- noIsolation?: boolean;
8
+ reset?: boolean;
11
9
  }
12
10
  export declare function init(flags: InitFlags, ui?: UI): Promise<Record<string, unknown>>;
11
+ export declare function setUp(cwd: string, flags: InitFlags, ui: UI): Promise<void>;
package/dist/src/init.js CHANGED
@@ -1,69 +1,41 @@
1
- // One-time onboarding: provider, agent, git identity, secrets, then a live check.
2
- // Re-running it only asks for what is missing. Without a UI it never prompts.
1
+ // Setup: this machine (provider, agent, git identity, secrets, a live check), then on a terminal the repo it runs in.
2
+ // Re-running it only asks for what is missing. Without a UI it never prompts and does only the machine.
3
3
  import { execFileSync } from "node:child_process";
4
- import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
4
+ import { existsSync, renameSync } from "node:fs";
5
5
  import { join } from "node:path";
6
6
  import { CONFIG_FILE, HOME, ROOT } from "./paths.js";
7
- import { BsError, DEFAULTS, getAgent, getProvider, isSource, loadConfig, neededSecrets, noIsolationAccepted, noIsolationError, readUserConfig, unisolated, writeUserConfig } from "./config.js";
8
- import { describe, hasSecret, storeSecret } from "./secrets.js";
9
- import { agentState, loginState, missingSecrets, newBoxId, setupAndStart, templateSpec, waitAgent } from "./boxes.js";
7
+ import { BsError, DEFAULTS, getAgent, loadConfig, missingSecrets, neededSecrets, readUserConfig, writeUserConfig } from "./config.js";
8
+ import { describe, forgetSecret, hasSecret, storeSecret } from "./secrets.js";
9
+ import { createBox, startAgent, waitAgent } from "./boxes.js";
10
+ import { providerNames } from "./provider.js";
11
+ import { openLocalAgent } from "./local.js";
12
+ import { ONBOARD_PROMPT } from "./onboard.js";
13
+ import { checkout, readRepoEnv } from "./repo.js";
10
14
  import { c } from "./ui.js";
11
15
  export const INSTRUCTIONS = `## Remote agents with bottleship
12
16
 
13
- You can run other coding agents in remote sandboxes ("boxes") with the \`bottleship\` CLI, like subagents. Every command prints JSON; errors carry a \`fix\`.
17
+ The \`bottleship\` CLI runs coding agents in boxes, isolated dev environments for a repo (Docker containers by default), and builds guided reviews of PRs and of local changes, which the user opens in their browser. Your commands are under \`bottleship agent\`; run \`bottleship agent help\` before you first use them in a session, for what each does and how to use it.
14
18
 
15
- - Start remote work with \`bottleship new "<prompt>"\` from inside the repo (or \`--repo owner/name\`). Write a complete prompt: the task, the context it needs, and what done means, including "work on branch <name>, commit and push as you go, and open a PR when done". The box agent sees nothing of this conversation.
16
- - Treat each box as a subagent you manage. \`new\` returns an id; keep track of your ids yourself, bottleship keeps no list.
17
- - If \`new\` or \`ask\` returns a \`login\`, show the user its URL and code; the box agent starts once they approve it in their browser.
18
- - \`bottleship status <id>\` shows whether the agent is still running and the tail of its output; \`--wait <seconds>\` blocks until it finishes.
19
- - \`bottleship ask <id> "<follow-up>"\` continues the same conversation once the agent has finished.
20
- - \`bottleship exec <id> -- <cmd>\` inspects the box (git log, test output, files).
21
- - \`bottleship rm <id>\` when the work is merged or abandoned. If a box is gone, start a new one on the pushed branch.
22
- - If the user wants to watch or steer a box from their IDE, find out how their IDE attaches to an agent over a command (ACP) and give it \`bottleship exec -i <id> -- <the agent's ACP command>\`.
19
+ - Use a box for work that can run on its own while you do something else, and treat it as a subagent you manage.
20
+ - When the user wants to review a PR, start the review for them and give them its url.
21
+ - Reviews of your own work: after a big change you made on your own (many files, or behaviour that is hard to see in the diff), start a local review before you hand over and give the user its url, unless the user has said otherwise. Skip it for small changes.
22
+ - If the user stops or interrupts you while you start a review, or turns one down, ask them once when they want reviews (after every big change, only when they ask, never, or something else). Then rewrite the line above in this section of your instructions file to say exactly what they chose, and follow it from then on.
23
23
  - Secrets: never ask the user to paste a secret to you. If bottleship reports one missing, ask them to run \`bottleship init\` in their terminal.`;
24
24
  const CHECK_PROMPT = "This is a connection check. Reply with exactly: bottleship-ok";
25
- // by secret name, else by store name; agent tokens carry their own hint in the preset
25
+ // by secret name; agent tokens carry their own hint in the preset
26
26
  const HINTS = {
27
27
  GH_TOKEN: "create a fine-grained token at https://github.com/settings/personal-access-tokens/new: Repository access \"Only select repositories\" (the repos boxes work on), " +
28
28
  "Repository permissions Contents and Pull requests \"Read and write\" (Metadata read-only comes with them). Boxes get only what it allows, so a runaway agent cannot touch your other repos. Paste it below",
29
- e2b: "create an API key at https://e2b.dev/dashboard and paste it",
30
- daytona: "create an API key at https://app.daytona.io/dashboard/keys and paste it",
31
- "modal-token-id": "run `modal token new` or create a token at https://modal.com/settings/tokens, and paste its id",
32
- "modal-token-secret": "paste the secret of the same Modal token (from `modal token new` or https://modal.com/settings/tokens)",
33
- };
34
- const PROVIDER_HINTS = {
35
- docker: "containers on this machine",
36
- exe: "exe.dev VMs",
37
- e2b: "e2b.dev cloud sandboxes",
38
- daytona: "daytona.io cloud sandboxes",
39
- modal: "modal.com cloud sandboxes",
40
- ssh: "a machine you can ssh into, no isolation",
41
- local: "git worktrees on this machine, no isolation, stops when the laptop sleeps",
42
- custom: "your company's sandboxes, via their API or CLI",
43
29
  };
30
+ const PROVIDER_HINTS = { docker: "containers on this machine" };
44
31
  // also the order of the agent menu; agents from config.json come after these
45
32
  const AGENT_HINTS = {
46
- claude: "Claude Code",
47
- codex: "OpenAI Codex CLI",
48
- opencode: "opencode, runs its free models without a login",
49
- cursor: "Cursor CLI (cursor-agent)",
33
+ claude: "Claude Code, with your subscription's setup token",
34
+ codex: "OpenAI Codex CLI, with an API key",
35
+ opencode: "opencode, with an OpenCode Zen API key",
36
+ cursor: "Cursor CLI (cursor-agent), with an API key",
50
37
  };
51
38
  const README = join(ROOT, "README.md");
52
- // copies the HTTP template to ~/.bottleship/providers/<name>.ts, never over an existing file
53
- function scaffoldProvider(raw, name) {
54
- if (!/^[a-z][a-z0-9-]*$/.test(name))
55
- throw new BsError("bad_name", `"${name}" is not a provider name`, "use lowercase letters, digits and dashes, e.g. --name corp");
56
- if (name === "custom" || name in DEFAULTS.providers)
57
- throw new BsError("bad_name", `"${name}" is a builtin provider`, "pick another --name, e.g. your company's name");
58
- const file = join(HOME, "providers", `${name}.ts`);
59
- if (!existsSync(file)) {
60
- const template = readFileSync(join(ROOT, "presets/providers/http.example.ts"), "utf8");
61
- mkdirSync(join(HOME, "providers"), { recursive: true });
62
- writeFileSync(file, template.replace(/^import type \{ ProviderFactory \} from .*$/m, 'import type { ProviderFactory } from "bottleship";'));
63
- }
64
- raw.providers = { ...raw.providers, [name]: { ...raw.providers?.[name], module: `providers/${name}.ts` } };
65
- return file;
66
- }
67
39
  const localGit = (key) => {
68
40
  try {
69
41
  return execFileSync("git", ["config", key], { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }).trim();
@@ -76,60 +48,57 @@ const localGit = (key) => {
76
48
  function storeAndPoint(raw, secret, value) {
77
49
  const source = "store" in secret.source ? secret.source : { store: secret.name.toLowerCase().replace(/[^a-z0-9-]+/g, "-") };
78
50
  storeSecret(source.store, value);
79
- if ("store" in secret.source)
80
- return source;
81
- const m = secret.name.match(/^providers\.([^.]+)\.(.+)$/);
82
- if (m) {
83
- const spec = { ...loadConfig(raw).providers[m[1]], ...raw.providers?.[m[1]], [m[2]]: source };
84
- raw.providers = { ...raw.providers, [m[1]]: spec };
85
- }
86
- else
51
+ if (!("store" in secret.source))
87
52
  raw.env = { ...(raw.env ?? DEFAULTS.env), [secret.name]: source };
88
53
  return source;
89
54
  }
55
+ // local agents that keep a global instructions file get a prompt to add the section to it
56
+ const teachPrompt = (a) => a.name === "cursor-agent"
57
+ ? `Write the text below as the Cursor user rule file ${a.file}, so you can use bottleship in every project. Give it this frontmatter, then the text: "---" / "description: How to run coding agents in remote boxes with bottleship" / "alwaysApply: true" / "---". Replace the file if it exists. Show me the file before you write it.\n\n${INSTRUCTIONS}`
58
+ : `Add the section below to your global instructions file ${a.file}, so you can use bottleship in every project. If the file already has a "## Remote agents with bottleship" section, replace it; keep everything else as it is, and create the file if it does not exist. Show me the change before you write it.\n\n${INSTRUCTIONS}`;
59
+ const TEACH_YOURSELF = 'Add `instructions` to your own global instructions file (e.g. ~/.claude/CLAUDE.md for Claude Code, ~/.codex/AGENTS.md for Codex, a rule file ~/.cursor/rules/bottleship.mdc with `alwaysApply: true` for Cursor), replacing an older "## Remote agents with bottleship" section, and show the user the change.';
60
+ // the store names of saved logins the config refers to, for reset to offer forgetting
61
+ function savedLogins(cfg) {
62
+ const names = new Set();
63
+ const walk = (v) => {
64
+ if (!v || typeof v !== "object")
65
+ return;
66
+ if ("store" in v && typeof v.store === "string" && hasSecret({ store: v.store }))
67
+ names.add(v.store);
68
+ Object.values(v).forEach(walk);
69
+ };
70
+ walk(cfg);
71
+ return [...names].sort();
72
+ }
73
+ async function reset(ui) {
74
+ if (!existsSync(CONFIG_FILE))
75
+ return;
76
+ if (ui && !(await ui.confirm("Start over? Your current setup is set aside and every question comes back.")))
77
+ throw new BsError("reset_cancelled", "nothing changed", "bottleship init");
78
+ const saved = savedLogins(loadConfig(readUserConfig()));
79
+ renameSync(CONFIG_FILE, `${CONFIG_FILE}.bak`);
80
+ // only a human on the terminal can forget saved logins
81
+ if (ui && saved.length && (await ui.confirm(`Also forget the saved logins (${saved.join(", ")})?`)))
82
+ saved.forEach(forgetSecret);
83
+ }
90
84
  export async function init(flags, ui) {
85
+ if (flags.reset)
86
+ await reset(ui);
91
87
  const raw = readUserConfig();
92
88
  let cfg = loadConfig(raw);
93
- ui?.intro();
94
- const pick = async (key, question, options) => {
89
+ const pick = async (key, question, options, fix) => {
95
90
  const known = options.map((o) => o.value);
96
- const value = flags[key] ?? raw[key] ?? (ui ? await ui.choose(question, options, cfg[key]) : cfg[key]);
91
+ const value = flags[key] ?? raw[key] ?? (ui && known.length > 1 ? await ui.choose(question, options, cfg[key]) : cfg[key]);
97
92
  if (!known.includes(value))
98
- throw new BsError(`unknown_${key}`, `no ${key} "${value}"`, `known: ${known.join(", ")}`);
99
- if (ui && (flags[key] || raw[key]))
93
+ throw new BsError(`unknown_${key}`, `no ${key} "${value}"`, `known: ${known.join(", ")}. ${fix}`);
94
+ if (ui)
100
95
  ui.ok(`${key} ${c.bold(value)}`);
101
96
  return value;
102
97
  };
103
- const choices = [...Object.entries(cfg.providers).map(([value, s]) => ({ value, hint: PROVIDER_HINTS[value] ?? s.module })), { value: "custom", hint: PROVIDER_HINTS.custom }];
104
- let provider = await pick("provider", "Where should boxes run?", choices);
105
- let module;
106
- if (provider === "custom") {
107
- const name = flags.name ?? (ui ? await ui.text("A short name for your sandbox provider", "corp") : "");
108
- if (!name)
109
- throw new BsError("missing_name", "a custom provider needs a name", "pass --name <name>, e.g. --name corp");
110
- module = scaffoldProvider(raw, name);
111
- provider = name;
112
- }
113
- if (cfg.providers[provider]?.module === "builtin:ssh") {
114
- const host = flags.host ?? cfg.providers[provider].host ?? (ui ? await ui.text("SSH host for boxes (anything ssh accepts, e.g. a ~/.ssh/config alias)") : "");
115
- if (!host)
116
- throw new BsError("missing_host", "the ssh provider needs a host", "pass --host <host>, anything `ssh <host>` accepts");
117
- raw.providers = { ...raw.providers, [provider]: { ...cfg.providers[provider], host } };
118
- }
119
- if (unisolated(cfg, provider) && !noIsolationAccepted(cfg, provider)) {
120
- const warning = `${provider} runs box agents as you, on ${provider === "local" ? "this machine" : "that host"}, with no sandbox and with their permission prompts off. An agent misled by a prompt, a repo or a web page can read, change or send anything you can reach there, including your files, keys and logins.`;
121
- if (ui)
122
- ui.warn("No isolation", warning);
123
- const ok = flags.noIsolation || (ui ? await ui.confirm(`Run agents on ${provider} with no isolation anyway?`) : false);
124
- if (!ok)
125
- throw noIsolationError(provider);
126
- raw.providers = { ...raw.providers, [provider]: { ...cfg.providers[provider], ...raw.providers?.[provider], acceptNoIsolation: true } };
127
- }
98
+ const provider = await pick("provider", "Where should boxes run?", providerNames().map((value) => ({ value, hint: PROVIDER_HINTS[value] ?? join(HOME, "providers", value) })), `For another sandbox, an agent writes its scripts into ${join(HOME, "providers", "<name>")}, see "Providers" in ${README}`);
128
99
  const rank = (a) => (a in AGENT_HINTS ? Object.keys(AGENT_HINTS).indexOf(a) : Infinity);
129
100
  const agents = Object.keys(cfg.agents).sort((a, b) => rank(a) - rank(b));
130
- // a first choice from the menu, as opposed to a flag or an earlier run
131
- const picked = Boolean(ui && !flags.agent && !raw.agent);
132
- const agent = await pick("agent", "Which agent runs in a box?", agents.map((value) => ({ value, hint: AGENT_HINTS[value] })));
101
+ const agent = await pick("agent", "Which agent runs in a box?", agents.map((value) => ({ value, hint: AGENT_HINTS[value] })), `Add one under "agents" in ${CONFIG_FILE}`);
133
102
  const gitValue = async (key, flag) => {
134
103
  const v = flag ?? raw.git?.[key] ?? (ui ? await ui.text(`Git ${key} for commits from boxes`, localGit(`user.${key}`)) : localGit(`user.${key}`));
135
104
  if (ui && (flag || raw.git?.[key]))
@@ -142,23 +111,10 @@ export async function init(flags, ui) {
142
111
  Object.assign(raw, { provider, agent, git });
143
112
  writeUserConfig(raw);
144
113
  cfg = loadConfig(raw);
145
- // a new custom provider has only TODOs, so secrets and the live check wait for the next run
146
- if (module) {
147
- const next = `fill in the TODOs in ${module} following "Custom providers" in ${README}, then run \`bottleship init\` again to check it`;
148
- if (ui) {
149
- ui.note("Next", `Ask your agent to fill in the TODOs in ${module}, following the "Custom providers" section of ${README}. Then run \`bottleship init\` again to check it.`);
150
- ui.close(c.green(`Provider ${provider} is set up as a template.`));
151
- }
152
- return { ok: true, config: CONFIG_FILE, provider, agent, git, module, next };
153
- }
154
114
  ui?.section("Secrets");
155
- if (await agentLogin(raw, cfg, agent, picked, ui)) {
156
- writeUserConfig(raw);
157
- cfg = loadConfig(raw);
158
- }
159
- const token = getAgent(cfg, agent).login?.token;
115
+ const login = getAgent(cfg, agent).login;
160
116
  const secrets = [];
161
- for (const s of neededSecrets(cfg, provider, agent)) {
117
+ for (const s of neededSecrets(cfg, agent)) {
162
118
  let source = s.source;
163
119
  let ok = hasSecret(source);
164
120
  if (!ok && ui && s.name === "GH_TOKEN" && (await useGhAuthToken(ui))) {
@@ -171,10 +127,10 @@ export async function init(flags, ui) {
171
127
  }
172
128
  else if (!ok && ui) {
173
129
  ui.warn(`${s.name} is missing`, `looked in ${describe(source)}`);
174
- const hint = s.name === token?.env ? token.hint : undefined;
175
- ui.say(` ${hint ?? HINTS[s.name] ?? HINTS["store" in s.source ? s.source.store : ""] ?? "paste the value"}`);
176
- if (token?.command && s.name === token.env && (await ui.confirm(`Run \`${token.command}\` now? Paste the token it prints below.`)))
177
- await ui.handOver(token.command);
130
+ const isLogin = s.name === login?.env;
131
+ ui.say(` ${(isLogin ? login?.hint : undefined) ?? HINTS[s.name] ?? "paste the value"}`);
132
+ if (isLogin && login?.command && (await ui.confirm(`Run \`${login.command}\` now? Paste the token it prints below.`)))
133
+ await ui.handOver(login.command);
178
134
  const value = await ui.secret(`${s.name}`);
179
135
  if (value) {
180
136
  source = storeAndPoint(raw, s, value);
@@ -191,19 +147,62 @@ export async function init(flags, ui) {
191
147
  throw missingSecrets(missing);
192
148
  cfg = loadConfig(raw);
193
149
  ui?.section(`Live check: ${provider} + ${agent}`);
194
- const checks = await liveCheck(raw, cfg, provider, agent, ui);
150
+ const checks = await liveCheck(cfg, provider, agent, ui);
195
151
  const ok = checks.every((ch) => ch.ok);
196
152
  if (ui) {
197
153
  const failed = checks.find((ch) => !ch.ok);
198
154
  if (failed)
199
155
  ui.fail(`${failed.step} failed`, JSON.stringify(failed.error));
200
- else {
201
- ui.note("Add this to your agent's global instructions (e.g. ~/.claude/CLAUDE.md)", INSTRUCTIONS);
202
- ui.say(`config ${c.dim(CONFIG_FILE)}\nboxes ${provider} running ${agent}, committing as ${git.name} <${git.email}>`);
203
- ui.close(c.green("All set. Tell your agent to start something in a box."));
156
+ }
157
+ return { ok, config: CONFIG_FILE, provider, agent, git, secrets, checks, instructions: INSTRUCTIONS, ...(ok && { next: TEACH_YOURSELF }) };
158
+ }
159
+ // a machine is set up once its config names a provider, an agent and a git identity, and every secret they need is there
160
+ function machineReady() {
161
+ if (!existsSync(CONFIG_FILE))
162
+ return undefined;
163
+ const raw = readUserConfig();
164
+ if (!raw.provider || !raw.agent || !raw.git?.name || !raw.git?.email)
165
+ return undefined;
166
+ const cfg = loadConfig(raw);
167
+ try {
168
+ return neededSecrets(cfg, cfg.agent).every((s) => hasSecret(s.source)) ? cfg : undefined;
169
+ }
170
+ catch {
171
+ return undefined;
172
+ }
173
+ }
174
+ // a person's setup: this machine unless it is set up, then the repo at cwd if they want it set up for boxes.
175
+ // One session of their local agent adds bottleship to its instructions and writes .bottleship/, whichever is needed.
176
+ export async function setUp(cwd, flags, ui) {
177
+ ui.intro();
178
+ const ready = flags.reset ? undefined : machineReady();
179
+ if (ready)
180
+ ui.ok(`this machine is set up: ${ready.provider} running ${ready.agent}`, "bottleship init --reset starts over");
181
+ else if (!(await init(flags, ui)).ok)
182
+ return;
183
+ const learn = !ready;
184
+ const repo = checkout(cwd);
185
+ const onboard = !!repo && (readRepoEnv(repo.root)
186
+ ? await ui.confirm(`${repo.root} already has a box environment. Have your agent go over it again?`)
187
+ : await ui.confirm(`Set up ${repo.root} for boxes?`));
188
+ if (onboard) {
189
+ ui.say(`Your agent works out how ${repo.root} runs in a box and writes .bottleship/. It checks its work in real boxes and asks you before changing anything else in the repo.`);
190
+ // the agent starts where it is handed the terminal
191
+ process.chdir(repo.root);
192
+ }
193
+ if (learn || onboard) {
194
+ const hint = (a) => [learn && `adds bottleship to ${a.file}`, onboard && "sets up the repo with you"].filter(Boolean).join(", then ");
195
+ const prompt = (a) => [learn && teachPrompt(a), onboard && ONBOARD_PROMPT].filter(Boolean).join("\n\nWhen that is done, set up this repository:\n\n");
196
+ if (!(await openLocalAgent(ui, "Which agent on this machine should do this?", hint, prompt))) {
197
+ if (learn)
198
+ ui.note("Add this to your agent's global instructions (e.g. ~/.claude/CLAUDE.md)", INSTRUCTIONS);
199
+ if (onboard)
200
+ ui.note("Give this to your coding agent, from the repo root", ONBOARD_PROMPT);
204
201
  }
205
202
  }
206
- return { ok, config: CONFIG_FILE, provider, agent, git, secrets, checks, instructions: INSTRUCTIONS };
203
+ if (!repo)
204
+ ui.say(`Run ${c.bold("bottleship init")} in a repo to set it up for boxes, then ${c.bold("bottleship review")} there.`);
205
+ ui.close(c.green("All set."));
207
206
  }
208
207
  // boxes push and open PRs with GH_TOKEN: a fine-grained token by default, the gh CLI's token only after a warning
209
208
  async function useGhAuthToken(ui) {
@@ -217,50 +216,8 @@ async function useGhAuthToken(ui) {
217
216
  ui.warn("`gh auth token` is your full gh login", "every box can push to, and open PRs or delete branches in, every repo your account can reach, including other organizations'");
218
217
  return ui.confirm("Give boxes your gh login anyway?");
219
218
  }
220
- // a user entry for a builtin agent only needs the fields it overrides, see loadConfig
221
- const patchAgent = (raw, name, patch) => {
222
- const before = raw.agents?.[name];
223
- const login = patch.login && { ...before?.login, ...patch.login };
224
- raw.agents = { ...raw.agents, [name]: { ...before, ...patch, ...(login && { login }) } };
225
- };
226
- const tokenNames = (login) => (login.tokens ?? [login.token]).map((t) => t.env).join(", ");
227
- const routeHint = (login, route) => route === "token" ? `${login.tokens ? `an API key (${tokenNames(login)})` : login.token.env}${login.token.command ? ` from \`${login.token.command}\`` : ""}, kept in bottleship's store`
228
- : route === "device" ? "log in inside each box: bottleship shows you a URL and code to approve for every new box"
229
- : "no login, e.g. free models";
230
- // asks for the login route on a first choice or a missing token, and which key on the token route; returns whether raw changed
231
- async function agentLogin(raw, cfg, agentName, picked, ui) {
232
- const login = getAgent(cfg, agentName).login;
233
- if (!login || !ui)
234
- return false;
235
- const ready = (route) => {
236
- if (route !== "token" || !login.token)
237
- return true;
238
- const source = cfg.env[login.token.env] ?? { store: login.token.store };
239
- return !isSource(source) || hasSecret(source);
240
- };
241
- let use = login.use;
242
- const ask = picked || !ready(use);
243
- const patch = {};
244
- if (login.routes.length > 1 && ask) {
245
- use = (await ui.choose(`How does ${agentName} log in inside boxes?`, login.routes.map((r) => ({ value: r, hint: routeHint(login, r) })), use));
246
- if (use !== login.use)
247
- patch.use = use;
248
- }
249
- if (use === "token" && login.tokens && ask) {
250
- const env = await ui.choose(`Which key does ${agentName} use?`, login.tokens.map((t) => ({ value: t.env, hint: t.hint })), login.token?.env ?? login.tokens[0].env);
251
- const token = login.tokens.find((t) => t.env === env);
252
- if (token.env !== login.token?.env)
253
- patch.token = token;
254
- }
255
- if (use === "device" && login.device?.setup)
256
- ui.note(`Once, before boxes can log ${agentName} in`, login.device.setup);
257
- if (!Object.keys(patch).length)
258
- return false;
259
- patchAgent(raw, agentName, { login: patch });
260
- return true;
261
- }
262
- // the template (recorded in raw), create, exec, the agent's setup, its device login, one reply, destroy; stops at the first failure but always destroys
263
- async function liveCheck(raw, cfg, providerName, agent, ui) {
219
+ // create (which builds the base image the first time), exec, one reply from the agent, destroy; stops at the first failure but always destroys
220
+ async function liveCheck(cfg, providerName, agent, ui) {
264
221
  const checks = [];
265
222
  const step = async (name, fn) => {
266
223
  const t0 = Date.now();
@@ -275,23 +232,9 @@ async function liveCheck(raw, cfg, providerName, agent, ui) {
275
232
  }
276
233
  };
277
234
  let b;
278
- let template;
279
- const provider = await getProvider(cfg, providerName);
280
- const login = getAgent(cfg, agent).login;
281
- const device = login?.use === "device" ? login.device : undefined;
282
235
  const check = async () => {
283
- // the provider finds a template it already built, so a rerun with the same spec is quick
284
- if (provider.template && !(await step("build the box template", async () => {
285
- const spec = templateSpec(cfg, agent);
286
- template = await provider.template(spec);
287
- raw.templates = { ...raw.templates, [providerName]: { name: spec.name, ref: template } };
288
- writeUserConfig(raw);
289
- return template;
290
- })))
291
- return;
292
- if (!(await step("create a box", async () => {
293
- const { id } = await provider.create({ id: newBoxId("check"), ...(template && { template }) });
294
- b = { id: `${providerName}:${id}`, provider, box: id };
236
+ if (!(await step("create a box (builds the image the first time, minutes)", async () => {
237
+ b = await createBox(cfg, { provider: providerName, label: "check", agent });
295
238
  })))
296
239
  return;
297
240
  if (!(await step("run a command", async () => {
@@ -300,36 +243,8 @@ async function liveCheck(raw, cfg, providerName, agent, ui) {
300
243
  throw new Error(`expected bottleship-ok, got exit ${r.code}: ${r.stdout}${r.stderr}`);
301
244
  })))
302
245
  return;
303
- if (!(await step(`set up ${agent}`, () => setupAndStart(cfg, b, { agent, prompt: CHECK_PROMPT }))))
304
- return;
305
- if (device) {
306
- let pending;
307
- if (!(await step(`${agent} logs in`, async () => {
308
- const s = await loginState(b, device.setup);
309
- if (s.agent !== "waiting_for_login")
310
- return;
311
- if (!ui)
312
- throw new BsError("login_needs_human", `${agent} logs in inside each box with a device login, which a human has to approve`, "ask the user to run `bottleship init` in their terminal and approve the login it shows. Boxes from `bottleship new` return the same kind of login for you to show the user", { login: s.login });
313
- pending = s.login;
314
- return s.login.url;
315
- })))
316
- return;
317
- if (pending) {
318
- ui.note(`Approve the ${agent} login`, `Open ${pending.url}${pending.code ? `\nand enter the code ${pending.code}` : ""}\n\nEvery new box asks for this.`);
319
- if (!(await step("waiting for you to approve the login", async () => {
320
- const until = Date.now() + ((device.timeout ?? 900) + 10) * 1000;
321
- let s = await agentState(b);
322
- while (s.agent === "waiting_for_login" && Date.now() < until) {
323
- await new Promise((r) => setTimeout(r, 1000));
324
- s = await agentState(b);
325
- }
326
- if (s.agent === "waiting_for_login" || s.agent === "login_failed")
327
- throw new Error(`the login was not approved: ${s.log.slice(-500)}`);
328
- })))
329
- return;
330
- }
331
- }
332
246
  await step(`${agent} replies`, async () => {
247
+ await startAgent(b, CHECK_PROMPT);
333
248
  const s = await waitAgent(b, 300);
334
249
  if (s.agent === "running")
335
250
  throw new Error("no reply within 5 minutes");
@@ -340,6 +255,6 @@ async function liveCheck(raw, cfg, providerName, agent, ui) {
340
255
  };
341
256
  await check();
342
257
  if (b)
343
- await step("destroy the box", async () => void (await b.provider.destroy(b.box)));
258
+ await step("destroy the box", () => b.provider.destroy(b.box));
344
259
  return checks;
345
260
  }
@@ -0,0 +1,8 @@
1
+ import type { UI } from "./ui.ts";
2
+ export interface LocalAgent {
3
+ name: string;
4
+ file: string;
5
+ open(prompt: string): string;
6
+ }
7
+ export declare const LOCAL_AGENTS: LocalAgent[];
8
+ export declare function openLocalAgent(ui: UI, question: string, hint: (a: LocalAgent) => string, prompt: (a: LocalAgent) => string): Promise<boolean>;
@@ -0,0 +1,24 @@
1
+ // The coding agents on this machine, and opening one with a prompt in the terminal.
2
+ import { existsSync } from "node:fs";
3
+ import { join } from "node:path";
4
+ import { shq } from "./util.js";
5
+ export const LOCAL_AGENTS = [
6
+ { name: "claude", file: "~/.claude/CLAUDE.md", open: (prompt) => `claude ${shq(prompt)}` },
7
+ { name: "codex", file: "~/.codex/AGENTS.md", open: (prompt) => `codex ${shq(prompt)}` },
8
+ { name: "opencode", file: "~/.config/opencode/AGENTS.md", open: (prompt) => `opencode --prompt ${shq(prompt)}` },
9
+ // Cursor reads user rule files from ~/.cursor/rules (https://cursor.com/help/customization/rules)
10
+ { name: "cursor-agent", file: "~/.cursor/rules/bottleship.mdc", open: (prompt) => `cursor-agent ${shq(prompt)}` },
11
+ ];
12
+ const onPath = (bin) => (process.env.PATH ?? "").split(":").some((d) => d && existsSync(join(d, bin)));
13
+ // asks which installed agent to open and hands it the terminal with its prompt; false when there is none or the user skips
14
+ export async function openLocalAgent(ui, question, hint, prompt) {
15
+ const found = LOCAL_AGENTS.filter((a) => onPath(a.name));
16
+ if (!found.length)
17
+ return false;
18
+ const pick = await ui.choose(question, [...found.map((a) => ({ value: a.name, hint: hint(a) })), { value: "skip", hint: "show the text to copy instead" }], found[0].name);
19
+ const agent = found.find((a) => a.name === pick);
20
+ if (!agent)
21
+ return false;
22
+ await ui.handOver(agent.open(prompt(agent)));
23
+ return true;
24
+ }
@@ -0,0 +1,5 @@
1
+ export declare const ONBOARD_PROMPT = "Set up this repository to run in bottleship boxes, so agents and reviewers can run the app in a fresh, isolated box.\n\nA box is a Docker container from a Debian bookworm image with Node 22, git, gh, curl and ripgrep. It runs as a non-root user `box` without sudo. The repo's files are in ~/workspace. Describe this repo's box in three files under .bottleship/, committed with the repo:\n\n- .bottleship/Dockerfile (optional): lines appended to bottleship's base image, run as root. No FROM, and no COPY or ADD of local files, since the build has no context. Use it for system packages, language runtimes and services, e.g. `RUN apt-get update && apt-get install -y --no-install-recommends postgresql && rm -rf /var/lib/apt/lists/*`. Add `EXPOSE <port>` for each port a person should open, such as the web app; bottleship publishes those and waits for them.\n- .bottleship/setup.sh: runs once as `box` in ~/workspace, after the code is there: install dependencies, create and migrate databases, load seed data, build. It must not prompt, and running it twice must be fine.\n- .bottleship/start.sh: starts the services the app needs, then the app in the foreground, as `box`. Bottleship runs it in the background, logs it to ~/.bottleship/app.log, and waits until every EXPOSEd port accepts connections. The app must listen on 0.0.0.0, not only on localhost, or the published port cannot reach it.\n\nHow to go about it:\n\n1. Learn how the project runs today: README, CONTRIBUTING, package manifests and lockfiles, Makefile or justfile, docker-compose files, devcontainer config, CI workflows, .env.example. Prefer the commands CI uses; they already run on a fresh machine.\n2. Run services (databases, caches, queues) inside the box, not shared remote ones. For third-party APIs, use test credentials or the project's mock or offline mode.\n3. Secrets: never write secret values into .bottleship/. Boxes get the .env files at the root of this checkout (.env, .env.local and so on, not .env.example). If the app needs values that are not there, ask me which ones to use, and prefer dev or test credentials.\n4. Check your work with `bottleship agent onboard --check` from the repo root. It builds the box from this working tree (uncommitted changes included), runs setup.sh and start.sh, waits for the EXPOSEd ports, and prints JSON: the stage it reached (ready or failed), the log of what failed, and a URL on this machine for each port. Look inside a box with `bottleship agent exec <id> -- <command>` (logs are in ~/.bottleship/ there) and remove it with `bottleship agent rm <id>`. Repeat until check reports ready, then confirm the app really serves, e.g. `curl` its URL and look at the response. Remove every box you created.\n5. Then look for changes to the repo itself that would make it easier to run in a box or for a reviewer to try: one command for setup, a seed script with useful demo data, config read from env instead of hardcoded hosts, a mock mode for external services, a complete .env.example, faster installs. List each with why it helps and how much work it is, and only make the ones I agree to, separately from the .bottleship/ files.\n6. Finish with a short summary: what the box runs, what check reported, what does not work in a box yet and why. Suggest committing .bottleship/.";
2
+ export declare function onboardPrompt(cwd: string): {
3
+ root: string;
4
+ prompt: string;
5
+ };
@@ -0,0 +1,25 @@
1
+ // Onboarding a repo: the instructions for the agent that works out how it runs in a box and writes .bottleship/.
2
+ import { BsError } from "./config.js";
3
+ import { checkout } from "./repo.js";
4
+ export const ONBOARD_PROMPT = `Set up this repository to run in bottleship boxes, so agents and reviewers can run the app in a fresh, isolated box.
5
+
6
+ A box is a Docker container from a Debian bookworm image with Node 22, git, gh, curl and ripgrep. It runs as a non-root user \`box\` without sudo. The repo's files are in ~/workspace. Describe this repo's box in three files under .bottleship/, committed with the repo:
7
+
8
+ - .bottleship/Dockerfile (optional): lines appended to bottleship's base image, run as root. No FROM, and no COPY or ADD of local files, since the build has no context. Use it for system packages, language runtimes and services, e.g. \`RUN apt-get update && apt-get install -y --no-install-recommends postgresql && rm -rf /var/lib/apt/lists/*\`. Add \`EXPOSE <port>\` for each port a person should open, such as the web app; bottleship publishes those and waits for them.
9
+ - .bottleship/setup.sh: runs once as \`box\` in ~/workspace, after the code is there: install dependencies, create and migrate databases, load seed data, build. It must not prompt, and running it twice must be fine.
10
+ - .bottleship/start.sh: starts the services the app needs, then the app in the foreground, as \`box\`. Bottleship runs it in the background, logs it to ~/.bottleship/app.log, and waits until every EXPOSEd port accepts connections. The app must listen on 0.0.0.0, not only on localhost, or the published port cannot reach it.
11
+
12
+ How to go about it:
13
+
14
+ 1. Learn how the project runs today: README, CONTRIBUTING, package manifests and lockfiles, Makefile or justfile, docker-compose files, devcontainer config, CI workflows, .env.example. Prefer the commands CI uses; they already run on a fresh machine.
15
+ 2. Run services (databases, caches, queues) inside the box, not shared remote ones. For third-party APIs, use test credentials or the project's mock or offline mode.
16
+ 3. Secrets: never write secret values into .bottleship/. Boxes get the .env files at the root of this checkout (.env, .env.local and so on, not .env.example). If the app needs values that are not there, ask me which ones to use, and prefer dev or test credentials.
17
+ 4. Check your work with \`bottleship agent onboard --check\` from the repo root. It builds the box from this working tree (uncommitted changes included), runs setup.sh and start.sh, waits for the EXPOSEd ports, and prints JSON: the stage it reached (ready or failed), the log of what failed, and a URL on this machine for each port. Look inside a box with \`bottleship agent exec <id> -- <command>\` (logs are in ~/.bottleship/ there) and remove it with \`bottleship agent rm <id>\`. Repeat until check reports ready, then confirm the app really serves, e.g. \`curl\` its URL and look at the response. Remove every box you created.
18
+ 5. Then look for changes to the repo itself that would make it easier to run in a box or for a reviewer to try: one command for setup, a seed script with useful demo data, config read from env instead of hardcoded hosts, a mock mode for external services, a complete .env.example, faster installs. List each with why it helps and how much work it is, and only make the ones I agree to, separately from the .bottleship/ files.
19
+ 6. Finish with a short summary: what the box runs, what check reported, what does not work in a box yet and why. Suggest committing .bottleship/.`;
20
+ export function onboardPrompt(cwd) {
21
+ const repo = checkout(cwd);
22
+ if (!repo)
23
+ throw new BsError("no_repo", "this directory is not in a git repository", "run it from inside the repo");
24
+ return { root: repo.root, prompt: ONBOARD_PROMPT };
25
+ }
@@ -1,4 +1,3 @@
1
1
  export declare const ROOT: string;
2
- export declare const DIST: string;
3
2
  export declare const HOME: string;
4
3
  export declare const CONFIG_FILE: string;
package/dist/src/paths.js CHANGED
@@ -12,6 +12,5 @@ function packageRoot(from) {
12
12
  }
13
13
  }
14
14
  export const ROOT = packageRoot(import.meta.dirname);
15
- export const DIST = join(ROOT, "dist");
16
15
  export const HOME = process.env.BOTTLESHIP_HOME ?? join(homedir(), ".bottleship");
17
16
  export const CONFIG_FILE = join(HOME, "config.json");
@@ -0,0 +1,16 @@
1
+ import type { BoxStream, ExecResult } from "./types.ts";
2
+ export interface Provider {
3
+ name: string;
4
+ create(id: string, dockerfile: string, image: string): Promise<void>;
5
+ exec(id: string, script: string, stdin?: string | Buffer): Promise<ExecResult>;
6
+ stream(id: string, script: string): BoxStream;
7
+ status(id: string): Promise<"running" | "gone">;
8
+ destroy(id: string): Promise<void>;
9
+ url?(id: string, port: number): Promise<string>;
10
+ list?(): Promise<{
11
+ box: string;
12
+ created?: string;
13
+ }[]>;
14
+ }
15
+ export declare function providerNames(): string[];
16
+ export declare function getProvider(name: string): Provider;