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.
- package/CHANGELOG.md +17 -0
- package/README.md +118 -113
- package/dist/src/boxes.d.ts +41 -39
- package/dist/src/boxes.js +181 -179
- package/dist/src/cli.js +298 -83
- package/dist/src/config.d.ts +3 -6
- package/dist/src/config.js +19 -85
- package/dist/src/init.d.ts +3 -4
- package/dist/src/init.js +120 -205
- package/dist/src/local.d.ts +8 -0
- package/dist/src/local.js +24 -0
- package/dist/src/onboard.d.ts +5 -0
- package/dist/src/onboard.js +25 -0
- package/dist/src/paths.d.ts +0 -1
- package/dist/src/paths.js +0 -1
- package/dist/src/provider.d.ts +16 -0
- package/dist/src/provider.js +81 -0
- package/dist/src/repo.d.ts +21 -0
- package/dist/src/repo.js +75 -0
- package/dist/src/review/comments.d.ts +66 -0
- package/dist/src/review/comments.js +344 -0
- package/dist/src/review/contract.d.ts +82 -0
- package/dist/src/review/contract.js +189 -0
- package/dist/src/review/github.d.ts +120 -0
- package/dist/src/review/github.js +160 -0
- package/dist/src/review/local.d.ts +14 -0
- package/dist/src/review/local.js +70 -0
- package/dist/src/review/loop.d.ts +12 -0
- package/dist/src/review/loop.js +119 -0
- package/dist/src/review/pr.d.ts +14 -0
- package/dist/src/review/pr.js +68 -0
- package/dist/src/review/prompt.d.ts +15 -0
- package/dist/src/review/prompt.js +48 -0
- package/dist/src/review/serve.d.ts +1 -0
- package/dist/src/review/serve.js +8 -0
- package/dist/src/review/server.d.ts +14 -0
- package/dist/src/review/server.js +484 -0
- package/dist/src/review/setting-up.d.ts +4 -0
- package/dist/src/review/setting-up.js +74 -0
- package/dist/src/review/start.d.ts +32 -0
- package/dist/src/review/start.js +140 -0
- package/dist/src/review/target.d.ts +20 -0
- package/dist/src/review/target.js +66 -0
- package/dist/src/review/template.d.ts +3 -0
- package/dist/src/review/template.js +13 -0
- package/dist/src/review/terminal.d.ts +4 -0
- package/dist/src/review/terminal.js +91 -0
- package/dist/src/secrets.d.ts +1 -0
- package/dist/src/secrets.js +11 -1
- package/dist/src/types.d.ts +1 -71
- package/dist/src/ui.d.ts +6 -1
- package/dist/src/ui.js +22 -23
- package/dist/src/util.d.ts +11 -0
- package/dist/src/util.js +40 -0
- package/package.json +7 -14
- package/presets/agents/claude.json +1 -5
- package/presets/agents/codex.json +1 -10
- package/presets/agents/cursor.json +1 -9
- package/presets/agents/opencode.json +1 -14
- package/presets/providers/docker/create +12 -0
- package/presets/providers/docker/destroy +2 -0
- package/presets/providers/docker/exec +2 -0
- package/presets/providers/docker/list +4 -0
- package/presets/providers/docker/status +2 -0
- package/presets/providers/docker/url +4 -0
- package/review-app/KIT.md +106 -0
- package/review-app/WRITING.md +93 -0
- package/review-app/examples/rply-16.tsx +98 -0
- package/review-app/index.html +15 -0
- package/review-app/package-lock.json +2539 -0
- package/review-app/package.json +24 -0
- package/review-app/playground/fixtures/excalidraw-12602/data/box.json +1 -0
- package/review-app/playground/fixtures/excalidraw-12602/data/pr.diff +91 -0
- package/review-app/playground/fixtures/excalidraw-12602/data/pr.json +62 -0
- package/review-app/playground/fixtures/excalidraw-12602/data/review-comments.json +44 -0
- package/review-app/playground/fixtures/excalidraw-12602/events.json +1 -0
- package/review-app/playground/fixtures/excalidraw-12602/files/shots/base-ar-zoom.png +0 -0
- package/review-app/playground/fixtures/excalidraw-12602/files/shots/base-en-US-zoom.png +0 -0
- package/review-app/playground/fixtures/excalidraw-12602/files/shots/base-he-IL-zoom.png +0 -0
- package/review-app/playground/fixtures/excalidraw-12602/files/shots/base-he-dark.png +0 -0
- package/review-app/playground/fixtures/excalidraw-12602/files/shots/base-he-hover.png +0 -0
- package/review-app/playground/fixtures/excalidraw-12602/files/shots/base-he-page.png +0 -0
- package/review-app/playground/fixtures/excalidraw-12602/files/shots/base-he-tip.png +0 -0
- package/review-app/playground/fixtures/excalidraw-12602/files/shots/base-he-zen.png +0 -0
- package/review-app/playground/fixtures/excalidraw-12602/files/shots/head-ar-zoom.png +0 -0
- package/review-app/playground/fixtures/excalidraw-12602/files/shots/head-en-US-zoom.png +0 -0
- package/review-app/playground/fixtures/excalidraw-12602/files/shots/head-he-IL-zoom.png +0 -0
- package/review-app/playground/fixtures/excalidraw-12602/files/shots/head-he-dark.png +0 -0
- package/review-app/playground/fixtures/excalidraw-12602/files/shots/head-he-hover.png +0 -0
- package/review-app/playground/fixtures/excalidraw-12602/files/shots/head-he-page.png +0 -0
- package/review-app/playground/fixtures/excalidraw-12602/files/shots/head-he-tip.png +0 -0
- package/review-app/playground/fixtures/excalidraw-12602/files/shots/head-he-zen.png +0 -0
- package/review-app/playground/fixtures/excalidraw-12602/instances.json +1 -0
- package/review-app/playground/fixtures/excalidraw-12602/review.tsx +103 -0
- package/review-app/playground/fixtures/rply-16/data/box.json +1 -0
- package/review-app/playground/fixtures/rply-16/data/hooks.diff +28 -0
- package/review-app/playground/fixtures/rply-16/data/pr.diff +114 -0
- package/review-app/playground/fixtures/rply-16/data/pr.json +68 -0
- package/review-app/playground/fixtures/rply-16/data/review-comments.json +1 -0
- package/review-app/playground/fixtures/rply-16/events.json +156 -0
- package/review-app/playground/fixtures/rply-16/instances.json +1 -0
- package/review-app/playground/fixtures/rply-16/review.tsx +98 -0
- package/review-app/playground/gallery.tsx +127 -0
- package/review-app/playground/index.html +15 -0
- package/review-app/playground/main.tsx +26 -0
- package/review-app/playground/mock.ts +215 -0
- package/review-app/playground/playground.css +18 -0
- package/review-app/playground/vite.config.ts +32 -0
- package/review-app/scripts/check.cjs +47 -0
- package/review-app/scripts/clip.cjs +104 -0
- package/review-app/scripts/shot.cjs +66 -0
- package/review-app/src/App.tsx +132 -0
- package/review-app/src/data/box.json +1 -0
- package/review-app/src/data/hooks.diff +28 -0
- package/review-app/src/data/pr.diff +114 -0
- package/review-app/src/data/pr.json +68 -0
- package/review-app/src/data/review-comments.json +1 -0
- package/review-app/src/kit/agent.tsx +287 -0
- package/review-app/src/kit/chapter.tsx +178 -0
- package/review-app/src/kit/code.tsx +217 -0
- package/review-app/src/kit/comments.tsx +377 -0
- package/review-app/src/kit/finish.tsx +274 -0
- package/review-app/src/kit/flow.tsx +184 -0
- package/review-app/src/kit/index.ts +10 -0
- package/review-app/src/kit/kit.css +696 -0
- package/review-app/src/kit/live.ts +91 -0
- package/review-app/src/kit/media.tsx +348 -0
- package/review-app/src/kit/overview.tsx +161 -0
- package/review-app/src/kit/pr.ts +100 -0
- package/review-app/src/kit/pullrequest.tsx +298 -0
- package/review-app/src/kit/review.ts +74 -0
- package/review-app/src/kit/theme.ts +30 -0
- package/review-app/src/kit/ui.tsx +127 -0
- package/review-app/src/main.tsx +6 -0
- package/review-app/src/review.tsx +22 -0
- package/review-app/tsconfig.json +17 -0
- package/review-app/vite.config.ts +12 -0
- package/dist/presets/providers/command.d.ts +0 -3
- package/dist/presets/providers/command.js +0 -39
- package/dist/presets/providers/daytona.d.ts +0 -3
- package/dist/presets/providers/daytona.js +0 -173
- package/dist/presets/providers/docker.d.ts +0 -3
- package/dist/presets/providers/docker.js +0 -31
- package/dist/presets/providers/e2b.d.ts +0 -3
- package/dist/presets/providers/e2b.js +0 -124
- package/dist/presets/providers/exe.d.ts +0 -3
- package/dist/presets/providers/exe.js +0 -28
- package/dist/presets/providers/http.example.d.ts +0 -3
- package/dist/presets/providers/http.example.js +0 -47
- package/dist/presets/providers/local.d.ts +0 -3
- package/dist/presets/providers/local.js +0 -52
- package/dist/presets/providers/modal.d.ts +0 -3
- package/dist/presets/providers/modal.js +0 -117
- package/dist/presets/providers/ssh.d.ts +0 -3
- package/dist/presets/providers/ssh.js +0 -49
- package/dist/presets/providers/util.d.ts +0 -13
- package/dist/presets/providers/util.js +0 -102
- package/presets/providers/command.ts +0 -43
- package/presets/providers/daytona.ts +0 -181
- package/presets/providers/docker.ts +0 -38
- package/presets/providers/e2b.ts +0 -130
- package/presets/providers/exe.ts +0 -35
- package/presets/providers/http.example.ts +0 -64
- package/presets/providers/local.ts +0 -56
- package/presets/providers/modal.ts +0 -121
- package/presets/providers/ssh.ts +0 -54
- package/presets/providers/util.ts +0 -112
package/dist/src/init.d.ts
CHANGED
|
@@ -1,12 +1,11 @@
|
|
|
1
1
|
import { type UI } from "./ui.ts";
|
|
2
|
-
export declare const INSTRUCTIONS = "## Remote agents with bottleship\n\
|
|
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
|
-
|
|
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
|
-
//
|
|
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,
|
|
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,
|
|
8
|
-
import { describe, hasSecret, storeSecret } from "./secrets.js";
|
|
9
|
-
import {
|
|
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
|
-
|
|
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
|
-
-
|
|
16
|
-
-
|
|
17
|
-
-
|
|
18
|
-
-
|
|
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
|
|
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,
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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,
|
|
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
|
|
175
|
-
ui.say(` ${hint
|
|
176
|
-
if (
|
|
177
|
-
await ui.handOver(
|
|
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(
|
|
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
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
221
|
-
|
|
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
|
-
|
|
284
|
-
|
|
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",
|
|
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
|
+
}
|
package/dist/src/paths.d.ts
CHANGED
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;
|