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