bottleship 0.0.1 → 0.1.0-prototype.1
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 +15 -0
- package/LICENSE +21 -0
- package/README.md +187 -2
- package/bin/bottleship +2 -0
- package/dist/presets/providers/command.d.ts +3 -0
- package/dist/presets/providers/command.js +39 -0
- package/dist/presets/providers/daytona.d.ts +3 -0
- package/dist/presets/providers/daytona.js +173 -0
- package/dist/presets/providers/docker.d.ts +3 -0
- package/dist/presets/providers/docker.js +31 -0
- package/dist/presets/providers/e2b.d.ts +3 -0
- package/dist/presets/providers/e2b.js +124 -0
- package/dist/presets/providers/exe.d.ts +3 -0
- package/dist/presets/providers/exe.js +28 -0
- package/dist/presets/providers/http.example.d.ts +3 -0
- package/dist/presets/providers/http.example.js +47 -0
- package/dist/presets/providers/local.d.ts +3 -0
- package/dist/presets/providers/local.js +52 -0
- package/dist/presets/providers/modal.d.ts +3 -0
- package/dist/presets/providers/modal.js +117 -0
- package/dist/presets/providers/ssh.d.ts +3 -0
- package/dist/presets/providers/ssh.js +49 -0
- package/dist/presets/providers/util.d.ts +13 -0
- package/dist/presets/providers/util.js +102 -0
- package/dist/src/boxes.d.ts +64 -0
- package/dist/src/boxes.js +265 -0
- package/dist/src/cli.d.ts +1 -0
- package/dist/src/cli.js +202 -0
- package/dist/src/config.d.ts +27 -0
- package/dist/src/config.js +151 -0
- package/dist/src/init.d.ts +13 -0
- package/dist/src/init.js +396 -0
- package/dist/src/paths.d.ts +4 -0
- package/dist/src/paths.js +17 -0
- package/dist/src/secrets.d.ts +6 -0
- package/dist/src/secrets.js +68 -0
- package/dist/src/types.d.ts +120 -0
- package/dist/src/types.js +1 -0
- package/dist/src/ui.d.ts +31 -0
- package/dist/src/ui.js +127 -0
- package/package.json +50 -4
- package/presets/agents/claude.json +12 -0
- package/presets/agents/codex.json +15 -0
- package/presets/agents/cursor.json +14 -0
- package/presets/agents/opencode.json +19 -0
- package/presets/providers/command.ts +43 -0
- package/presets/providers/daytona.ts +181 -0
- package/presets/providers/docker.ts +38 -0
- package/presets/providers/e2b.ts +130 -0
- package/presets/providers/exe.ts +35 -0
- package/presets/providers/http.example.ts +64 -0
- package/presets/providers/local.ts +56 -0
- package/presets/providers/modal.ts +121 -0
- package/presets/providers/ssh.ts +54 -0
- package/presets/providers/util.ts +112 -0
|
@@ -0,0 +1,265 @@
|
|
|
1
|
+
// Boxes that run an agent in the background. All state lives in the box under ~/.bottleship:
|
|
2
|
+
// env.sh (env and secrets), run.sh/resume.sh (the agent command), prompt.txt, pid, exit, agent.log,
|
|
3
|
+
// and on the device login route login.sh, login-status.sh, login (checking, pending, ok or failed) and login.log.
|
|
4
|
+
import { createHash, randomBytes } from "node:crypto";
|
|
5
|
+
import { fill, shq } from "../presets/providers/util.js";
|
|
6
|
+
import { BsError, boxEnv, getAgent, getProvider, noIsolationAccepted, noIsolationError, unisolated } from "./config.js";
|
|
7
|
+
const B = '"$HOME/.bottleship"';
|
|
8
|
+
const MARK = "__BOTTLESHIP_PROMPT__";
|
|
9
|
+
const tail = (s, n = 2000) => (s.length > n ? "..." + s.slice(-n) : s);
|
|
10
|
+
export const goneFix = 'the box is gone. Start a new one with `bottleship new "<task>"` and tell its agent to continue on the branch the old box pushed';
|
|
11
|
+
// the prompt is read from a file at run time, so one script serves every turn
|
|
12
|
+
const agentScript = (template) => fill(template, { prompt: MARK }).split(shq(MARK)).join(`"$(cat ${B}/prompt.txt)"`);
|
|
13
|
+
// writes files under the box's $HOME from inside a script fed on stdin, so secrets never show up in argv
|
|
14
|
+
function writeFiles(files) {
|
|
15
|
+
const eof = `BOTTLESHIP_EOF_${randomBytes(8).toString("hex")}`;
|
|
16
|
+
return Object.entries(files)
|
|
17
|
+
.map(([path, content]) => {
|
|
18
|
+
const p = `"$HOME"/${shq(path)}`;
|
|
19
|
+
return `mkdir -p "$(dirname ${p})" && cat > ${p} <<'${eof}'\n${content}${content.endsWith("\n") ? "" : "\n"}${eof}`;
|
|
20
|
+
})
|
|
21
|
+
.join("\n");
|
|
22
|
+
}
|
|
23
|
+
// starts the agent detached; launch.sh records the exit code when it ends
|
|
24
|
+
const start = (mode) => `rm -f ${B}/exit
|
|
25
|
+
if [ -f ${B}/login.sh ]; then echo checking > ${B}/login.tmp && mv -f ${B}/login.tmp ${B}/login; rm -f ${B}/login.log; fi
|
|
26
|
+
nohup bash ${B}/launch.sh ${mode} </dev/null >/dev/null 2>&1 &
|
|
27
|
+
echo $! > ${B}/pid`;
|
|
28
|
+
const RUNNING = `[ ! -f ${B}/exit ] && kill -0 "$(cat ${B}/pid 2>/dev/null)" 2>/dev/null`;
|
|
29
|
+
// on the device route it logs in first when login-status.sh fails, giving up after login-timeout seconds; until a run started, a follow-up runs instead of resuming
|
|
30
|
+
const LAUNCH = `B="$HOME/.bottleship"
|
|
31
|
+
. "$B/env.sh"
|
|
32
|
+
cd "$HOME/workspace"
|
|
33
|
+
mode=$1
|
|
34
|
+
# state files are replaced whole, so status never reads one half-written
|
|
35
|
+
put() { echo "$2" > "$B/$1.tmp" && mv -f "$B/$1.tmp" "$B/$1"; }
|
|
36
|
+
killtree() { for c in $(ps -A -o pid=,ppid= | awk -v p="$1" '$2 == p { print $1 }'); do killtree "$c"; done; kill "$1" 2>/dev/null; }
|
|
37
|
+
[ -f "$B/started" ] || mode=run
|
|
38
|
+
if [ -f "$B/login.sh" ]; then
|
|
39
|
+
if bash "$B/login-status.sh" >/dev/null 2>&1; then put login ok; else
|
|
40
|
+
put login pending
|
|
41
|
+
# stays in the box's process group so rm can find it; a timeout ends its whole process tree
|
|
42
|
+
bash "$B/login.sh" > "$B/login.log" 2>&1 </dev/null &
|
|
43
|
+
lp=$!
|
|
44
|
+
end=$((SECONDS + $(cat "$B/login-timeout")))
|
|
45
|
+
while kill -0 $lp 2>/dev/null && [ $SECONDS -lt $end ]; do sleep 1; done
|
|
46
|
+
if kill -0 $lp 2>/dev/null; then killtree $lp; echo "bottleship: no approval within $(cat "$B/login-timeout") seconds" >> "$B/login.log"; fi
|
|
47
|
+
wait $lp
|
|
48
|
+
rc=$?
|
|
49
|
+
if [ $rc -ne 0 ]; then put login failed; put exit $rc; exit; fi
|
|
50
|
+
put login ok
|
|
51
|
+
fi
|
|
52
|
+
fi
|
|
53
|
+
touch "$B/started"
|
|
54
|
+
bash "$B/$mode.sh" > "$B/agent.log" 2>&1 </dev/null
|
|
55
|
+
put exit $?
|
|
56
|
+
`;
|
|
57
|
+
// node for agents installed with npm, without sudo, if the box has none
|
|
58
|
+
const NODE = `if ! command -v node >/dev/null; then
|
|
59
|
+
case "$(uname -m)" in aarch64|arm64) A=arm64;; *) A=x64;; esac
|
|
60
|
+
F=$(curl -fsSL https://nodejs.org/dist/latest-v22.x/SHASUMS256.txt | grep -o "node-v[0-9.]*-linux-$A.tar.xz" | head -1)
|
|
61
|
+
mkdir -p ${B}/node && curl -fsSL "https://nodejs.org/dist/latest-v22.x/$F" | tar -xJ -C ${B}/node --strip-components=1
|
|
62
|
+
fi`;
|
|
63
|
+
// gh and git both read GH_TOKEN, so pushes and PRs work with no further login
|
|
64
|
+
const GIT_HELPER = `!f() { test "$1" = get && echo username=x-access-token && echo "password=$GH_TOKEN"; }; f`;
|
|
65
|
+
// the box template: what every box set up at start would install, as the non-root user box; npm globals land where env.sh puts them
|
|
66
|
+
const DOCKERFILE = `FROM node:22-bookworm-slim
|
|
67
|
+
SHELL ["/bin/bash", "-o", "pipefail", "-c"]
|
|
68
|
+
RUN apt-get update && apt-get install -y --no-install-recommends git curl ca-certificates ripgrep \\
|
|
69
|
+
&& mkdir -p -m 755 /etc/apt/keyrings \\
|
|
70
|
+
&& curl -fsSL https://cli.github.com/packages/githubcli-archive-keyring.gpg -o /etc/apt/keyrings/githubcli-archive-keyring.gpg \\
|
|
71
|
+
&& echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/githubcli-archive-keyring.gpg] https://cli.github.com/packages stable main" > /etc/apt/sources.list.d/github-cli.list \\
|
|
72
|
+
&& apt-get update && apt-get install -y --no-install-recommends gh \\
|
|
73
|
+
&& rm -rf /var/lib/apt/lists/*
|
|
74
|
+
RUN userdel -r node && useradd -m -s /bin/bash -u 1000 box
|
|
75
|
+
USER box
|
|
76
|
+
WORKDIR /home/box
|
|
77
|
+
ENV NPM_CONFIG_PREFIX=/home/box/.bottleship/npm PATH=/home/box/.bottleship/npm/bin:/home/box/.local/bin:$PATH
|
|
78
|
+
`;
|
|
79
|
+
// bakes in only this agent's install: other agents install at start, and a changed agent gets a new template
|
|
80
|
+
export function templateSpec(cfg, agentName) {
|
|
81
|
+
const dockerfile = DOCKERFILE + (getAgent(cfg, agentName).install ?? []).map((line) => `RUN ${line}\n`).join("");
|
|
82
|
+
return { name: `bottleship-${createHash("sha256").update(dockerfile).digest("hex").slice(0, 8)}`, dockerfile };
|
|
83
|
+
}
|
|
84
|
+
// the template init recorded for this provider, if it was built from the current spec of the configured agent
|
|
85
|
+
export function currentTemplate(cfg, provider) {
|
|
86
|
+
const t = cfg.templates[provider];
|
|
87
|
+
return t && t.name === templateSpec(cfg, cfg.agent).name ? t.ref : undefined;
|
|
88
|
+
}
|
|
89
|
+
export function parseId(cfg, handle) {
|
|
90
|
+
const i = handle.indexOf(":");
|
|
91
|
+
return i < 0 ? { provider: cfg.provider, box: handle } : { provider: handle.slice(0, i), box: handle.slice(i + 1) };
|
|
92
|
+
}
|
|
93
|
+
export function newBoxId(prompt) {
|
|
94
|
+
const slug = prompt.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "").slice(0, 20).replace(/-$/, "");
|
|
95
|
+
return `bs-${slug ? slug + "-" : ""}${randomBytes(2).toString("hex")}`;
|
|
96
|
+
}
|
|
97
|
+
async function sh(b, script, stdin) {
|
|
98
|
+
try {
|
|
99
|
+
return await b.provider.exec(b.box, script, { stdin });
|
|
100
|
+
}
|
|
101
|
+
catch (e) {
|
|
102
|
+
throw new BsError("box_unreachable", `could not run a command in ${b.id}: ${e.message}`, `bottleship status ${b.id}`);
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
// a box that exists; throws box_gone if the provider says it does not
|
|
106
|
+
export async function openBox(cfg, handle) {
|
|
107
|
+
const { provider: name, box } = parseId(cfg, handle);
|
|
108
|
+
const provider = await getProvider(cfg, name);
|
|
109
|
+
if (provider.status && (await provider.status(box)) === "gone")
|
|
110
|
+
throw new BsError("box_gone", `box ${handle} is gone`, goneFix);
|
|
111
|
+
return { id: `${name}:${box}`, provider, box };
|
|
112
|
+
}
|
|
113
|
+
// one round trip: files, node, git, clone (unless the provider made ~/workspace a checkout), install, start
|
|
114
|
+
export async function setupAndStart(cfg, b, { agent: agentName, prompt, repo, dotenv = {} }) {
|
|
115
|
+
const agent = getAgent(cfg, agentName);
|
|
116
|
+
const { env, missing } = boxEnv(cfg, agentName);
|
|
117
|
+
if (missing.length)
|
|
118
|
+
throw missingSecrets(missing.map((m) => m.name));
|
|
119
|
+
const unset = (agent.unset ?? []).filter((k) => !(k in env));
|
|
120
|
+
const device = agent.login?.use === "device" ? agent.login.device : undefined;
|
|
121
|
+
if (agent.login?.use === "device" && !device)
|
|
122
|
+
throw new BsError("bad_agent", `agent ${agentName} uses the device login route but has no login.device`, `set agents.${agentName}.login.use to another route in ~/.bottleship/config.json`);
|
|
123
|
+
const envSh = [
|
|
124
|
+
...(unset.length ? [`unset ${unset.join(" ")}`] : []),
|
|
125
|
+
'export PATH="$HOME/.bottleship/node/bin:$HOME/.bottleship/npm/bin:$HOME/.local/bin:$PATH"',
|
|
126
|
+
'export NPM_CONFIG_PREFIX="$HOME/.bottleship/npm"',
|
|
127
|
+
...Object.entries(env).map(([k, v]) => `export ${k}=${shq(v)}`),
|
|
128
|
+
].join("\n");
|
|
129
|
+
const files = {
|
|
130
|
+
// the agent needs these secrets, so it can read them; README says to scope them narrowly
|
|
131
|
+
".bottleship/env.sh": envSh,
|
|
132
|
+
".bottleship/launch.sh": LAUNCH,
|
|
133
|
+
".bottleship/run.sh": agentScript(agent.run),
|
|
134
|
+
".bottleship/resume.sh": agentScript(agent.resume),
|
|
135
|
+
".bottleship/prompt.txt": prompt,
|
|
136
|
+
...(device && {
|
|
137
|
+
".bottleship/login.sh": device.command,
|
|
138
|
+
".bottleship/login-status.sh": device.status,
|
|
139
|
+
".bottleship/login-timeout": String(device.timeout ?? 900),
|
|
140
|
+
".bottleship/agent-name": agentName,
|
|
141
|
+
}),
|
|
142
|
+
...Object.fromEntries(Object.entries(dotenv).map(([name, content]) => [`.bottleship/dotenv/${name}`, content])),
|
|
143
|
+
};
|
|
144
|
+
const script = `set -e
|
|
145
|
+
${writeFiles(files)}
|
|
146
|
+
chmod 600 ${B}/env.sh
|
|
147
|
+
. ${B}/env.sh
|
|
148
|
+
${NODE}
|
|
149
|
+
${cfg.git.name ? `git config --global user.name ${shq(cfg.git.name)}` : ""}
|
|
150
|
+
${cfg.git.email ? `git config --global user.email ${shq(cfg.git.email)}` : ""}
|
|
151
|
+
git config --global credential.https://github.com.helper ${shq(GIT_HELPER)}
|
|
152
|
+
${repo ? `[ -e "$HOME/workspace/.git" ] || git clone --quiet ${shq(repo.url)} "$HOME/workspace"` : ""}
|
|
153
|
+
mkdir -p "$HOME/workspace"
|
|
154
|
+
if [ -d ${B}/dotenv ]; then cp -R ${B}/dotenv/. "$HOME/workspace/"; fi
|
|
155
|
+
cd "$HOME/workspace"
|
|
156
|
+
${(agent.install ?? []).join("\n")}
|
|
157
|
+
${start("run")}`;
|
|
158
|
+
const r = await sh(b, "bash -s", script);
|
|
159
|
+
if (r.code !== 0)
|
|
160
|
+
throw new BsError("setup_failed", `setting up ${b.id} failed (exit ${r.code})`, "fix the cause in stderr, then try again", { stderr: tail(r.stderr), stdout: tail(r.stdout) });
|
|
161
|
+
}
|
|
162
|
+
export function missingSecrets(names) {
|
|
163
|
+
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 });
|
|
164
|
+
}
|
|
165
|
+
// source: the local checkout of the task's repo, for providers that can use it (local makes a worktree)
|
|
166
|
+
export async function newBox(cfg, opts) {
|
|
167
|
+
// fails before a box exists when a secret is missing
|
|
168
|
+
if (unisolated(cfg, opts.provider) && !noIsolationAccepted(cfg, opts.provider))
|
|
169
|
+
throw noIsolationError(opts.provider);
|
|
170
|
+
const { missing } = boxEnv(cfg, opts.agent);
|
|
171
|
+
if (missing.length)
|
|
172
|
+
throw missingSecrets(missing.map((m) => m.name));
|
|
173
|
+
const provider = await getProvider(cfg, opts.provider);
|
|
174
|
+
const template = provider.template && currentTemplate(cfg, provider.name);
|
|
175
|
+
const { id: box } = await provider.create({ id: newBoxId(opts.prompt), ...(opts.source && { source: opts.source }), ...(template && { template }) });
|
|
176
|
+
const b = { id: `${provider.name}:${box}`, provider, box };
|
|
177
|
+
// a stale or missing template still works, as setup installs what the template would have held, only slower
|
|
178
|
+
if (provider.template && !template && provider.name === cfg.provider)
|
|
179
|
+
b.warning = `no current box template for ${provider.name}, so this box installs its tools at start, which is slower; run \`bottleship init\` to build one`;
|
|
180
|
+
try {
|
|
181
|
+
await setupAndStart(cfg, b, opts);
|
|
182
|
+
const s = await loginState(b, getAgent(cfg, opts.agent).login?.device?.setup);
|
|
183
|
+
if (s.agent === "waiting_for_login")
|
|
184
|
+
b.login = s.login;
|
|
185
|
+
}
|
|
186
|
+
catch (e) {
|
|
187
|
+
await provider.destroy(box).catch(() => { });
|
|
188
|
+
throw e;
|
|
189
|
+
}
|
|
190
|
+
return b;
|
|
191
|
+
}
|
|
192
|
+
const ANSI = /\x1b\[[0-9;?]*[A-Za-z]/g;
|
|
193
|
+
// the first URL in a login's output, and a code like ABCD-12345 outside of URLs
|
|
194
|
+
export function parseLogin(output, agent) {
|
|
195
|
+
const text = output.replace(ANSI, "");
|
|
196
|
+
const url = text.match(/https?:\/\/[^\s"'<>]+/)?.[0];
|
|
197
|
+
if (!url)
|
|
198
|
+
return undefined;
|
|
199
|
+
const code = text.replace(/https?:\/\/\S+/g, " ").match(/\b[A-Z0-9]{4,}-[A-Z0-9]{4,}\b/)?.[0];
|
|
200
|
+
return { url, ...(code && { code }), agent };
|
|
201
|
+
}
|
|
202
|
+
// the login's output (all of it, for the URL) while it is the current step, else the agent's last lines
|
|
203
|
+
export async function agentState(b, lines = 40) {
|
|
204
|
+
// liveness first: once launch.sh is dead its state files are final, so a finish between the reads cannot look like "stopped"
|
|
205
|
+
const r = await sh(b, `kill -0 "$(cat ${B}/pid 2>/dev/null)" 2>/dev/null && alive=1 || alive=
|
|
206
|
+
L=$(cat ${B}/login 2>/dev/null)
|
|
207
|
+
if [ "$L" = failed ]; then echo "login_failed $(cat ${B}/exit 2>/dev/null || echo 1)"
|
|
208
|
+
elif [ -f ${B}/exit ]; then echo "exited $(cat ${B}/exit)"
|
|
209
|
+
elif [ -n "$alive" ]; then
|
|
210
|
+
case "$L" in checking|pending) echo "waiting_for_login $(cat ${B}/agent-name 2>/dev/null)";; *) echo running;; esac
|
|
211
|
+
else echo stopped; fi
|
|
212
|
+
case "$L" in checking|pending|failed) head -c 20000 ${B}/login.log 2>/dev/null;; *) tail -n ${lines} ${B}/agent.log 2>/dev/null;; esac
|
|
213
|
+
true`);
|
|
214
|
+
const nl = r.stdout.indexOf("\n");
|
|
215
|
+
const [state, arg] = r.stdout.slice(0, nl).split(" ");
|
|
216
|
+
const out = r.stdout.slice(nl + 1);
|
|
217
|
+
const last = (s) => s.replace(ANSI, "").split("\n").slice(-lines - 1).join("\n");
|
|
218
|
+
if (state === "exited")
|
|
219
|
+
return { agent: "exited", exitCode: Number(arg), log: out };
|
|
220
|
+
if (state === "login_failed")
|
|
221
|
+
return { agent: "login_failed", exitCode: Number(arg), log: last(out) };
|
|
222
|
+
if (state === "waiting_for_login") {
|
|
223
|
+
const login = parseLogin(out, arg);
|
|
224
|
+
return { agent: "waiting_for_login", ...(login && { login }), log: last(out) };
|
|
225
|
+
}
|
|
226
|
+
return { agent: state === "running" ? "running" : "stopped", log: out };
|
|
227
|
+
}
|
|
228
|
+
// right after a start: on the device route, waits until the login shows its URL or is not needed; a failed login throws
|
|
229
|
+
export async function loginState(b, setup, seconds = 60) {
|
|
230
|
+
const until = Date.now() + seconds * 1000;
|
|
231
|
+
let s = await agentState(b);
|
|
232
|
+
while (s.agent === "waiting_for_login" && !s.login && Date.now() < until) {
|
|
233
|
+
await new Promise((r) => setTimeout(r, 300));
|
|
234
|
+
s = await agentState(b);
|
|
235
|
+
}
|
|
236
|
+
if (s.agent === "login_failed" || (s.agent === "waiting_for_login" && !s.login))
|
|
237
|
+
throw new BsError("login_failed", `the agent's device login in ${b.id} ${s.agent === "login_failed" ? "failed" : "showed no URL within a minute"}`, `read log${setup ? `; check that the user did this once: ${setup}` : ""}`, { log: s.log });
|
|
238
|
+
return s;
|
|
239
|
+
}
|
|
240
|
+
const busy = (s) => s.agent === "running" || s.agent === "waiting_for_login";
|
|
241
|
+
// ponytail: polls every second; fine for one waiting agent, a blocking exec in the box if many wait
|
|
242
|
+
export async function waitAgent(b, seconds, lines) {
|
|
243
|
+
const until = Date.now() + seconds * 1000;
|
|
244
|
+
let s = await agentState(b, lines);
|
|
245
|
+
while (busy(s) && Date.now() < until) {
|
|
246
|
+
await new Promise((r) => setTimeout(r, 1000));
|
|
247
|
+
s = await agentState(b, lines);
|
|
248
|
+
}
|
|
249
|
+
return s;
|
|
250
|
+
}
|
|
251
|
+
// starts a follow-up; on the device route it returns the login the user has to approve first, if the box's login expired
|
|
252
|
+
export async function ask(b, prompt) {
|
|
253
|
+
const r = await sh(b, "bash -s", `if ${RUNNING}; then echo running; exit 0; fi
|
|
254
|
+
${writeFiles({ ".bottleship/prompt.txt": prompt })}
|
|
255
|
+
${start("resume")}
|
|
256
|
+
echo started`);
|
|
257
|
+
if (r.stdout.trim() === "running")
|
|
258
|
+
throw new BsError("agent_running", `the agent in ${b.id} is still working`, `wait for it: bottleship status ${b.id} --wait 600`);
|
|
259
|
+
if (r.stdout.trim() !== "started")
|
|
260
|
+
throw new BsError("ask_failed", `could not start the agent in ${b.id} (exit ${r.code})`, `bottleship exec ${b.id} -- cat ~/.bottleship/agent.log`, { stderr: tail(r.stderr) });
|
|
261
|
+
const s = await loginState(b);
|
|
262
|
+
return s.agent === "waiting_for_login" ? s.login : undefined;
|
|
263
|
+
}
|
|
264
|
+
// the env and workspace the agent sees, for exec
|
|
265
|
+
export const inBox = (script) => `. ${B}/env.sh 2>/dev/null; cd "$HOME/workspace" 2>/dev/null; ${script}`;
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
package/dist/src/cli.js
ADDED
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
// bottleship CLI. Built for agents: JSON on stdout, errors are {error, message, fix} with exit 1.
|
|
2
|
+
// Only `init` on a terminal talks to a human.
|
|
3
|
+
import { execFileSync } from "node:child_process";
|
|
4
|
+
import { readdirSync, readFileSync, statSync } from "node:fs";
|
|
5
|
+
import { join } from "node:path";
|
|
6
|
+
import { parseArgs } from "node:util";
|
|
7
|
+
import { ROOT } from "./paths.js";
|
|
8
|
+
import { BsError, getProvider, loadConfig } from "./config.js";
|
|
9
|
+
import { ask, goneFix, inBox, newBox, openBox, parseId, waitAgent, agentState } from "./boxes.js";
|
|
10
|
+
import { init } from "./init.js";
|
|
11
|
+
import { c, terminalUI } from "./ui.js";
|
|
12
|
+
import { shq } from "../presets/providers/util.js";
|
|
13
|
+
const HELP = `bottleship: run coding agents in remote sandboxes ("boxes"), like subagents. Output is JSON.
|
|
14
|
+
|
|
15
|
+
init [--yes] [--provider P] [--agent A] [--git-name N] [--git-email E]
|
|
16
|
+
[--host H] (ssh) the host boxes run on
|
|
17
|
+
[--i-understand-no-isolation] (local, ssh) the user accepts agents running unsandboxed
|
|
18
|
+
[--provider custom --name N] scaffolds ~/.bottleship/providers/N.ts for your own sandboxes
|
|
19
|
+
[--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
|
|
21
|
+
new "<task>" [--repo owner/name | --no-repo] [--provider P] [--agent A]
|
|
22
|
+
new box: clones the repo (default: the cwd's origin; local: a worktree), starts the agent
|
|
23
|
+
status <id> [--wait S] [--lines N]
|
|
24
|
+
is the agent running, its exit code and last output lines
|
|
25
|
+
ask <id> "<message>" follow-up in the same conversation, once the agent has finished
|
|
26
|
+
exec <id> [-i] -- <cmd...> run a command in the box's workspace; -i streams stdin/stdout
|
|
27
|
+
rm <id> destroy the box
|
|
28
|
+
help
|
|
29
|
+
|
|
30
|
+
README: ${join(ROOT, "README.md")}`;
|
|
31
|
+
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
|
+
function args(argv, options = {}) {
|
|
51
|
+
return parseArgs({ args: argv, options, allowPositionals: true, strict: true });
|
|
52
|
+
}
|
|
53
|
+
const need = (v, usage) => {
|
|
54
|
+
if (!v)
|
|
55
|
+
throw new BsError("bad_request", `usage: bottleship ${usage}`, "bottleship help");
|
|
56
|
+
return v;
|
|
57
|
+
};
|
|
58
|
+
async function cmdNew(argv) {
|
|
59
|
+
const { values, positionals } = args(argv, { repo: { type: "string" }, "no-repo": { type: "boolean" }, provider: { type: "string" }, agent: { type: "string" } });
|
|
60
|
+
const prompt = need(positionals.join(" ").trim(), 'new "<task>" [--repo owner/name | --no-repo]');
|
|
61
|
+
const cfg = loadConfig();
|
|
62
|
+
const origin = git(process.cwd(), "remote", "get-url", "origin");
|
|
63
|
+
const here = origin ? toRepo(origin) : undefined;
|
|
64
|
+
let repo;
|
|
65
|
+
let dotenv = {};
|
|
66
|
+
// the local checkout, when the task's repo is the cwd's repo
|
|
67
|
+
let source;
|
|
68
|
+
if (values.repo)
|
|
69
|
+
repo = toRepo(values.repo);
|
|
70
|
+
else if (!values["no-repo"]) {
|
|
71
|
+
if (!here)
|
|
72
|
+
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);
|
|
78
|
+
}
|
|
79
|
+
const agent = values.agent ?? cfg.agent;
|
|
80
|
+
const b = await newBox(cfg, { provider: values.provider ?? cfg.provider, agent, prompt, repo, dotenv, source });
|
|
81
|
+
return { id: b.id, provider: b.provider.name, agent, repo: repo?.label ?? null, ...(b.warning && { warning: b.warning }), ...(b.login && { login: b.login }), next: b.login ? loginNext(b.id) : `bottleship status ${b.id} --wait 600` };
|
|
82
|
+
}
|
|
83
|
+
const loginNext = (id) => `show the user this URL and code; the task starts once they approve. Then bottleship status ${id} --wait 600`;
|
|
84
|
+
function statusNext(id, s) {
|
|
85
|
+
if (s.agent === "running")
|
|
86
|
+
return `bottleship status ${id} --wait 600`;
|
|
87
|
+
if (s.agent === "waiting_for_login")
|
|
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`;
|
|
92
|
+
}
|
|
93
|
+
async function cmdStatus(argv) {
|
|
94
|
+
const { values, positionals } = args(argv, { wait: { type: "string" }, lines: { type: "string" } });
|
|
95
|
+
const id = need(positionals[0], "status <id> [--wait S]");
|
|
96
|
+
const cfg = loadConfig();
|
|
97
|
+
let b;
|
|
98
|
+
try {
|
|
99
|
+
b = await openBox(cfg, id);
|
|
100
|
+
}
|
|
101
|
+
catch (e) {
|
|
102
|
+
if (e instanceof BsError && e.code === "box_gone") {
|
|
103
|
+
const { provider, box } = parseId(cfg, id);
|
|
104
|
+
return { id: `${provider}:${box}`, box: "gone", next: goneFix };
|
|
105
|
+
}
|
|
106
|
+
throw e;
|
|
107
|
+
}
|
|
108
|
+
const lines = Number(values.lines ?? 40);
|
|
109
|
+
const s = values.wait ? await waitAgent(b, Number(values.wait), lines) : await agentState(b, lines);
|
|
110
|
+
return { id: b.id, box: "running", ...s, next: statusNext(b.id, s) };
|
|
111
|
+
}
|
|
112
|
+
async function cmdAsk(argv) {
|
|
113
|
+
const { positionals } = args(argv);
|
|
114
|
+
const [id, ...words] = positionals;
|
|
115
|
+
const message = need(words.join(" ").trim(), 'ask <id> "<message>"');
|
|
116
|
+
const b = await openBox(loadConfig(), need(id, 'ask <id> "<message>"'));
|
|
117
|
+
const login = await ask(b, message);
|
|
118
|
+
if (login)
|
|
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` };
|
|
121
|
+
}
|
|
122
|
+
// one argument is a script, several are a command whose arguments are passed literally
|
|
123
|
+
async function cmdExec(argv) {
|
|
124
|
+
const { values, positionals } = args(argv, { i: { type: "boolean", short: "i" } });
|
|
125
|
+
const [id, ...cmd] = positionals;
|
|
126
|
+
const usage = "exec <id> [-i] -- <cmd...>";
|
|
127
|
+
need(cmd[0], usage);
|
|
128
|
+
const script = inBox(cmd.length === 1 ? cmd[0] : cmd.map(shq).join(" "));
|
|
129
|
+
const b = await openBox(loadConfig(), need(id, usage));
|
|
130
|
+
if (!values.i) {
|
|
131
|
+
const r = await b.provider.exec(b.box, script);
|
|
132
|
+
process.stdout.write(r.stdout);
|
|
133
|
+
process.stderr.write(r.stderr);
|
|
134
|
+
process.exitCode = r.code ?? 1;
|
|
135
|
+
return;
|
|
136
|
+
}
|
|
137
|
+
if (!b.provider.openStream)
|
|
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);
|
|
140
|
+
process.stdin.pipe(s.stdin);
|
|
141
|
+
s.stdout.pipe(process.stdout);
|
|
142
|
+
s.stderr.pipe(process.stderr);
|
|
143
|
+
await new Promise((res) => s.onClose((code) => {
|
|
144
|
+
process.exitCode = code ?? 0;
|
|
145
|
+
process.stdin.unpipe(s.stdin);
|
|
146
|
+
process.stdin.destroy();
|
|
147
|
+
res();
|
|
148
|
+
}));
|
|
149
|
+
}
|
|
150
|
+
async function cmdRm(argv) {
|
|
151
|
+
const id = need(args(argv).positionals[0], "rm <id>");
|
|
152
|
+
const cfg = loadConfig();
|
|
153
|
+
const { provider, box } = parseId(cfg, id);
|
|
154
|
+
await (await getProvider(cfg, provider)).destroy(box);
|
|
155
|
+
return { removed: `${provider}:${box}` };
|
|
156
|
+
}
|
|
157
|
+
async function cmdInit(argv, ui) {
|
|
158
|
+
const { values } = args(argv, { yes: { type: "boolean", short: "y" }, provider: { type: "string" }, host: { type: "string" }, name: { type: "string" }, agent: { type: "string" }, "git-name": { type: "string" }, "git-email": { type: "string" }, "i-understand-no-isolation": { type: "boolean" }, reset: { type: "boolean" } });
|
|
159
|
+
const result = await init({ provider: values.provider, host: values.host, name: values.name, agent: values.agent, gitName: values["git-name"], gitEmail: values["git-email"], noIsolation: values["i-understand-no-isolation"], reset: values.reset }, ui);
|
|
160
|
+
if (!result.ok)
|
|
161
|
+
process.exitCode = 1;
|
|
162
|
+
return result;
|
|
163
|
+
}
|
|
164
|
+
async function main() {
|
|
165
|
+
const [cmd, ...argv] = process.argv.slice(2);
|
|
166
|
+
// init talks to a human on a terminal, unless told --yes
|
|
167
|
+
const ui = cmd === "init" && process.stdin.isTTY && process.stdout.isTTY && !argv.includes("--yes") && !argv.includes("-y") ? terminalUI() : undefined;
|
|
168
|
+
try {
|
|
169
|
+
switch (cmd) {
|
|
170
|
+
case "new": return out(await cmdNew(argv));
|
|
171
|
+
case "status": return out(await cmdStatus(argv));
|
|
172
|
+
case "ask": return out(await cmdAsk(argv));
|
|
173
|
+
case "exec": return await cmdExec(argv);
|
|
174
|
+
case "rm": return out(await cmdRm(argv));
|
|
175
|
+
case "init": {
|
|
176
|
+
const r = await cmdInit(argv, ui);
|
|
177
|
+
return ui ? undefined : out(r);
|
|
178
|
+
}
|
|
179
|
+
case "help":
|
|
180
|
+
case "--help":
|
|
181
|
+
case "-h":
|
|
182
|
+
case undefined:
|
|
183
|
+
return void process.stdout.write(HELP + "\n");
|
|
184
|
+
default:
|
|
185
|
+
throw new BsError("bad_request", `unknown command "${cmd}"`, "bottleship help");
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
catch (e) {
|
|
189
|
+
const err = e instanceof BsError ? e
|
|
190
|
+
: e.code?.startsWith("ERR_PARSE_ARGS") ? new BsError("bad_request", e.message, "bottleship help")
|
|
191
|
+
: new BsError("internal", e.message);
|
|
192
|
+
process.exitCode = 1;
|
|
193
|
+
if (!ui)
|
|
194
|
+
return out(err.toJSON());
|
|
195
|
+
const fix = err.code === "secret_missing" ? "run `bottleship init` again once you have the value" : err.fix;
|
|
196
|
+
ui.fail(err.message, fix);
|
|
197
|
+
}
|
|
198
|
+
finally {
|
|
199
|
+
ui?.close();
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
await main();
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import type { AgentSpec, BsErrorJSON, Config, LoadedProvider, SecretSource } from "./types.ts";
|
|
2
|
+
export declare class BsError extends Error {
|
|
3
|
+
code: string;
|
|
4
|
+
fix: string | undefined;
|
|
5
|
+
extra: Record<string, unknown>;
|
|
6
|
+
constructor(code: string, message: string, fix?: string, extra?: Record<string, unknown>);
|
|
7
|
+
toJSON(): BsErrorJSON;
|
|
8
|
+
}
|
|
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
|
+
export declare function readUserConfig(): Partial<Config>;
|
|
14
|
+
export declare function writeUserConfig(raw: Partial<Config>): void;
|
|
15
|
+
export declare function loadConfig(raw?: Partial<Config>): Config;
|
|
16
|
+
export declare function getAgent(cfg: Config, name: string): AgentSpec;
|
|
17
|
+
export declare const isSource: (v: unknown) => v is SecretSource;
|
|
18
|
+
export interface NamedSecret {
|
|
19
|
+
name: string;
|
|
20
|
+
source: SecretSource;
|
|
21
|
+
}
|
|
22
|
+
export declare function boxEnv(cfg: Config, agentName: string): {
|
|
23
|
+
env: Record<string, string>;
|
|
24
|
+
missing: NamedSecret[];
|
|
25
|
+
};
|
|
26
|
+
export declare function neededSecrets(cfg: Config, providerName: string, agentName: string): NamedSecret[];
|
|
27
|
+
export declare function getProvider(cfg: Config, name: string): Promise<LoadedProvider>;
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
var __rewriteRelativeImportExtension = (this && this.__rewriteRelativeImportExtension) || function (path, preserveJsx) {
|
|
2
|
+
if (typeof path === "string" && /^\.\.?\//.test(path)) {
|
|
3
|
+
return path.replace(/\.(tsx)$|((?:\.d)?)((?:\.[^./]+?)?)\.([cm]?)ts$/i, function (m, tsx, d, ext, cm) {
|
|
4
|
+
return tsx ? preserveJsx ? ".jsx" : ".js" : d && (!ext || !cm) ? m : (d + ext + "." + cm.toLowerCase() + "js");
|
|
5
|
+
});
|
|
6
|
+
}
|
|
7
|
+
return path;
|
|
8
|
+
};
|
|
9
|
+
// ~/.bottleship/config.json: personal, never from a repo. Decides providers, agents,
|
|
10
|
+
// the git identity, and which env vars (and secrets) every box gets.
|
|
11
|
+
import { existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
12
|
+
import { isAbsolute, join, resolve } from "node:path";
|
|
13
|
+
import { pathToFileURL } from "node:url";
|
|
14
|
+
import { CONFIG_FILE, DIST, HOME, ROOT } from "./paths.js";
|
|
15
|
+
import { readSecret } from "./secrets.js";
|
|
16
|
+
import * as kit from "../presets/providers/util.js";
|
|
17
|
+
// errors agents can act on: a code, a message, and the next step
|
|
18
|
+
export class BsError extends Error {
|
|
19
|
+
code;
|
|
20
|
+
fix;
|
|
21
|
+
extra;
|
|
22
|
+
constructor(code, message, fix, extra = {}) {
|
|
23
|
+
super(message);
|
|
24
|
+
this.code = code;
|
|
25
|
+
this.fix = fix;
|
|
26
|
+
this.extra = extra;
|
|
27
|
+
}
|
|
28
|
+
toJSON() {
|
|
29
|
+
return { error: this.code, message: this.message, ...(this.fix ? { fix: this.fix } : {}), ...this.extra };
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
export const DEFAULTS = {
|
|
33
|
+
provider: "docker",
|
|
34
|
+
agent: "claude",
|
|
35
|
+
git: { name: "", email: "" },
|
|
36
|
+
// a fine-grained token for the repos the user picks; init asks for it
|
|
37
|
+
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
|
+
agents: {},
|
|
48
|
+
templates: {},
|
|
49
|
+
};
|
|
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
|
+
function builtinAgents() {
|
|
57
|
+
const dir = join(ROOT, "presets/agents");
|
|
58
|
+
const files = readdirSync(dir).filter((f) => f.endsWith(".json"));
|
|
59
|
+
return Object.fromEntries(files.map((f) => [f.slice(0, -5), JSON.parse(readFileSync(join(dir, f), "utf8"))]));
|
|
60
|
+
}
|
|
61
|
+
export function readUserConfig() {
|
|
62
|
+
return existsSync(CONFIG_FILE) ? JSON.parse(readFileSync(CONFIG_FILE, "utf8")) : {};
|
|
63
|
+
}
|
|
64
|
+
export function writeUserConfig(raw) {
|
|
65
|
+
mkdirSync(HOME, { recursive: true });
|
|
66
|
+
writeFileSync(CONFIG_FILE, JSON.stringify(raw, null, 2) + "\n");
|
|
67
|
+
}
|
|
68
|
+
// providers merge over the builtins, and each agent over its builtin; env replaces the default so entries can be dropped
|
|
69
|
+
export function loadConfig(raw = readUserConfig()) {
|
|
70
|
+
return {
|
|
71
|
+
...DEFAULTS,
|
|
72
|
+
...raw,
|
|
73
|
+
git: { ...DEFAULTS.git, ...raw.git },
|
|
74
|
+
providers: { ...DEFAULTS.providers, ...raw.providers },
|
|
75
|
+
agents: mergeAgents(builtinAgents(), raw.agents ?? {}),
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
// a user entry for a builtin agent overrides its fields, and login's, so { "login": { "use": "token" } } is enough
|
|
79
|
+
function mergeAgents(builtin, user) {
|
|
80
|
+
const agents = { ...builtin };
|
|
81
|
+
for (const [name, spec] of Object.entries(user)) {
|
|
82
|
+
const login = builtin[name]?.login || spec.login ? { ...builtin[name]?.login, ...spec.login } : undefined;
|
|
83
|
+
agents[name] = { ...builtin[name], ...spec, ...(login && { login }) };
|
|
84
|
+
}
|
|
85
|
+
return agents;
|
|
86
|
+
}
|
|
87
|
+
// the env var that logs a box's agent in on the token route; the device route logs in inside the box
|
|
88
|
+
function loginEnv(cfg, agentName) {
|
|
89
|
+
const l = getAgent(cfg, agentName).login;
|
|
90
|
+
return l?.use === "token" && l.token ? { [l.token.env]: { store: l.token.store } } : {};
|
|
91
|
+
}
|
|
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
|
+
export function getAgent(cfg, name) {
|
|
95
|
+
const a = cfg.agents[name];
|
|
96
|
+
if (!a)
|
|
97
|
+
throw new BsError("unknown_agent", `no agent "${name}"`, `known: ${Object.keys(cfg.agents).join(", ")}. Add one under "agents" in ${CONFIG_FILE}`);
|
|
98
|
+
return a;
|
|
99
|
+
}
|
|
100
|
+
export const isSource = (v) => typeof v === "object" && v !== null && ["store", "command", "env"].some((k) => typeof v[k] === "string");
|
|
101
|
+
// the env every box of this agent gets, read now; missing secrets are listed, not thrown
|
|
102
|
+
export function boxEnv(cfg, agentName) {
|
|
103
|
+
const env = {};
|
|
104
|
+
const missing = [];
|
|
105
|
+
for (const [name, v] of Object.entries(allEnv(cfg, agentName))) {
|
|
106
|
+
if (!isSource(v))
|
|
107
|
+
env[name] = v;
|
|
108
|
+
else
|
|
109
|
+
try {
|
|
110
|
+
env[name] = readSecret(v);
|
|
111
|
+
}
|
|
112
|
+
catch {
|
|
113
|
+
missing.push({ name, source: v });
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
return { env, missing };
|
|
117
|
+
}
|
|
118
|
+
// every secret a box of this provider and agent needs: the provider's own API keys, the box env and the agent's login
|
|
119
|
+
export function neededSecrets(cfg, providerName, agentName) {
|
|
120
|
+
const fromProvider = Object.entries(cfg.providers[providerName] ?? {}).filter(([, v]) => isSource(v)).map(([k, v]) => ({ name: `providers.${providerName}.${k}`, source: v }));
|
|
121
|
+
const fromEnv = Object.entries(allEnv(cfg, agentName)).filter(([, v]) => isSource(v)).map(([name, v]) => ({ name, source: v }));
|
|
122
|
+
return [...fromProvider, ...fromEnv];
|
|
123
|
+
}
|
|
124
|
+
// providers.<name> = { module, ...options }; module is "builtin:<preset>", an absolute path, or relative to HOME (.mjs or .ts)
|
|
125
|
+
export async function getProvider(cfg, name) {
|
|
126
|
+
const spec = cfg.providers[name];
|
|
127
|
+
if (!spec)
|
|
128
|
+
throw new BsError("unknown_provider", `no provider "${name}"`, `known: ${Object.keys(cfg.providers).join(", ")}. Add one under "providers" in ${CONFIG_FILE}`);
|
|
129
|
+
const { module, ...options } = spec;
|
|
130
|
+
const file = module.startsWith("builtin:")
|
|
131
|
+
? join(DIST, "presets/providers", `${module.slice("builtin:".length)}.js`)
|
|
132
|
+
: isAbsolute(module) ? module : resolve(HOME, module);
|
|
133
|
+
const bad = (msg) => new BsError("bad_provider", `provider "${name}" (${file}) ${msg}`, `fix providers.${name}.module in ${CONFIG_FILE}`);
|
|
134
|
+
if (module.startsWith("builtin:") && !existsSync(file))
|
|
135
|
+
throw new BsError("bad_provider", `${module} is not in this version of bottleship`, "update bottleship, or pick another provider with `bottleship init --provider <name>`");
|
|
136
|
+
let factory;
|
|
137
|
+
try {
|
|
138
|
+
factory = (await import(__rewriteRelativeImportExtension(pathToFileURL(file).href))).default;
|
|
139
|
+
}
|
|
140
|
+
catch (e) {
|
|
141
|
+
throw bad(`failed to load: ${e.message}`);
|
|
142
|
+
}
|
|
143
|
+
if (typeof factory !== "function")
|
|
144
|
+
throw bad("has no default export function");
|
|
145
|
+
const ctx = { name, options, secret: readSecret, log: (...a) => console.error(`[${name}]`, ...a), kit };
|
|
146
|
+
const provider = { name, ...(await factory(ctx)) };
|
|
147
|
+
for (const m of ["create", "exec", "destroy"])
|
|
148
|
+
if (typeof provider[m] !== "function")
|
|
149
|
+
throw bad(`has no ${m}()`);
|
|
150
|
+
return provider;
|
|
151
|
+
}
|