bottleship 0.0.1 → 0.1.0-prototype.0
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 +8 -0
- package/LICENSE +21 -0
- package/README.md +183 -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 +201 -0
- package/dist/src/config.d.ts +27 -0
- package/dist/src/config.js +151 -0
- package/dist/src/init.d.ts +12 -0
- package/dist/src/init.js +345 -0
- package/dist/src/paths.d.ts +4 -0
- package/dist/src/paths.js +17 -0
- package/dist/src/secrets.d.ts +5 -0
- package/dist/src/secrets.js +58 -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,12 @@
|
|
|
1
|
+
import { type UI } from "./ui.ts";
|
|
2
|
+
export declare const INSTRUCTIONS = "## Remote agents with bottleship\n\nYou can run other coding agents in remote sandboxes (\"boxes\") with the `bottleship` CLI, like subagents. Every command prints JSON; errors carry a `fix`.\n\n- Start remote work with `bottleship new \"<prompt>\"` from inside the repo (or `--repo owner/name`). Write a complete prompt: the task, the context it needs, and what done means, including \"work on branch <name>, commit and push as you go, and open a PR when done\". The box agent sees nothing of this conversation.\n- Treat each box as a subagent you manage. `new` returns an id; keep track of your ids yourself, bottleship keeps no list.\n- If `new` or `ask` returns a `login`, show the user its URL and code; the box agent starts once they approve it in their browser.\n- `bottleship status <id>` shows whether the agent is still running and the tail of its output; `--wait <seconds>` blocks until it finishes.\n- `bottleship ask <id> \"<follow-up>\"` continues the same conversation once the agent has finished.\n- `bottleship exec <id> -- <cmd>` inspects the box (git log, test output, files).\n- `bottleship rm <id>` when the work is merged or abandoned. If a box is gone, start a new one on the pushed branch.\n- If the user wants to watch or steer a box from their IDE, find out how their IDE attaches to an agent over a command (ACP) and give it `bottleship exec -i <id> -- <the agent's ACP command>`.\n- Secrets: never ask the user to paste a secret to you. If bottleship reports one missing, ask them to run `bottleship init` in their terminal.";
|
|
3
|
+
export interface InitFlags {
|
|
4
|
+
provider?: string;
|
|
5
|
+
host?: string;
|
|
6
|
+
name?: string;
|
|
7
|
+
agent?: string;
|
|
8
|
+
gitName?: string;
|
|
9
|
+
gitEmail?: string;
|
|
10
|
+
noIsolation?: boolean;
|
|
11
|
+
}
|
|
12
|
+
export declare function init(flags: InitFlags, ui?: UI): Promise<Record<string, unknown>>;
|
package/dist/src/init.js
ADDED
|
@@ -0,0 +1,345 @@
|
|
|
1
|
+
// One-time onboarding: provider, agent, git identity, secrets, then a live check.
|
|
2
|
+
// Re-running it only asks for what is missing. Without a UI it never prompts.
|
|
3
|
+
import { execFileSync } from "node:child_process";
|
|
4
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
5
|
+
import { join } from "node:path";
|
|
6
|
+
import { CONFIG_FILE, HOME, ROOT } from "./paths.js";
|
|
7
|
+
import { BsError, DEFAULTS, getAgent, getProvider, isSource, loadConfig, neededSecrets, noIsolationAccepted, noIsolationError, readUserConfig, unisolated, writeUserConfig } from "./config.js";
|
|
8
|
+
import { describe, hasSecret, storeSecret } from "./secrets.js";
|
|
9
|
+
import { agentState, loginState, missingSecrets, newBoxId, setupAndStart, templateSpec, waitAgent } from "./boxes.js";
|
|
10
|
+
import { c } from "./ui.js";
|
|
11
|
+
export const INSTRUCTIONS = `## Remote agents with bottleship
|
|
12
|
+
|
|
13
|
+
You can run other coding agents in remote sandboxes ("boxes") with the \`bottleship\` CLI, like subagents. Every command prints JSON; errors carry a \`fix\`.
|
|
14
|
+
|
|
15
|
+
- Start remote work with \`bottleship new "<prompt>"\` from inside the repo (or \`--repo owner/name\`). Write a complete prompt: the task, the context it needs, and what done means, including "work on branch <name>, commit and push as you go, and open a PR when done". The box agent sees nothing of this conversation.
|
|
16
|
+
- Treat each box as a subagent you manage. \`new\` returns an id; keep track of your ids yourself, bottleship keeps no list.
|
|
17
|
+
- If \`new\` or \`ask\` returns a \`login\`, show the user its URL and code; the box agent starts once they approve it in their browser.
|
|
18
|
+
- \`bottleship status <id>\` shows whether the agent is still running and the tail of its output; \`--wait <seconds>\` blocks until it finishes.
|
|
19
|
+
- \`bottleship ask <id> "<follow-up>"\` continues the same conversation once the agent has finished.
|
|
20
|
+
- \`bottleship exec <id> -- <cmd>\` inspects the box (git log, test output, files).
|
|
21
|
+
- \`bottleship rm <id>\` when the work is merged or abandoned. If a box is gone, start a new one on the pushed branch.
|
|
22
|
+
- If the user wants to watch or steer a box from their IDE, find out how their IDE attaches to an agent over a command (ACP) and give it \`bottleship exec -i <id> -- <the agent's ACP command>\`.
|
|
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
|
+
const CHECK_PROMPT = "This is a connection check. Reply with exactly: bottleship-ok";
|
|
25
|
+
// by secret name, else by store name; agent tokens carry their own hint in the preset
|
|
26
|
+
const HINTS = {
|
|
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
|
+
"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
|
+
};
|
|
44
|
+
// also the order of the agent menu; agents from config.json come after these
|
|
45
|
+
const AGENT_HINTS = {
|
|
46
|
+
claude: "Claude Code",
|
|
47
|
+
codex: "OpenAI Codex CLI",
|
|
48
|
+
opencode: "opencode, runs its free models without a login",
|
|
49
|
+
cursor: "Cursor CLI (cursor-agent)",
|
|
50
|
+
};
|
|
51
|
+
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
|
+
const localGit = (key) => {
|
|
68
|
+
try {
|
|
69
|
+
return execFileSync("git", ["config", key], { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }).trim();
|
|
70
|
+
}
|
|
71
|
+
catch {
|
|
72
|
+
return "";
|
|
73
|
+
}
|
|
74
|
+
};
|
|
75
|
+
// the value of a missing secret goes to the store, and config points at it from then on
|
|
76
|
+
function storeAndPoint(raw, secret, value) {
|
|
77
|
+
const source = "store" in secret.source ? secret.source : { store: secret.name.toLowerCase().replace(/[^a-z0-9-]+/g, "-") };
|
|
78
|
+
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
|
|
87
|
+
raw.env = { ...(raw.env ?? DEFAULTS.env), [secret.name]: source };
|
|
88
|
+
return source;
|
|
89
|
+
}
|
|
90
|
+
export async function init(flags, ui) {
|
|
91
|
+
const raw = readUserConfig();
|
|
92
|
+
let cfg = loadConfig(raw);
|
|
93
|
+
ui?.intro();
|
|
94
|
+
const pick = async (key, question, options) => {
|
|
95
|
+
const known = options.map((o) => o.value);
|
|
96
|
+
const value = flags[key] ?? raw[key] ?? (ui ? await ui.choose(question, options, cfg[key]) : cfg[key]);
|
|
97
|
+
if (!known.includes(value))
|
|
98
|
+
throw new BsError(`unknown_${key}`, `no ${key} "${value}"`, `known: ${known.join(", ")}`);
|
|
99
|
+
if (ui && (flags[key] || raw[key]))
|
|
100
|
+
ui.ok(`${key} ${c.bold(value)}`);
|
|
101
|
+
return value;
|
|
102
|
+
};
|
|
103
|
+
const choices = [...Object.entries(cfg.providers).map(([value, s]) => ({ value, hint: PROVIDER_HINTS[value] ?? s.module })), { value: "custom", hint: PROVIDER_HINTS.custom }];
|
|
104
|
+
let provider = await pick("provider", "Where should boxes run?", choices);
|
|
105
|
+
let module;
|
|
106
|
+
if (provider === "custom") {
|
|
107
|
+
const name = flags.name ?? (ui ? await ui.text("A short name for your sandbox provider", "corp") : "");
|
|
108
|
+
if (!name)
|
|
109
|
+
throw new BsError("missing_name", "a custom provider needs a name", "pass --name <name>, e.g. --name corp");
|
|
110
|
+
module = scaffoldProvider(raw, name);
|
|
111
|
+
provider = name;
|
|
112
|
+
}
|
|
113
|
+
if (cfg.providers[provider]?.module === "builtin:ssh") {
|
|
114
|
+
const host = flags.host ?? cfg.providers[provider].host ?? (ui ? await ui.text("SSH host for boxes (anything ssh accepts, e.g. a ~/.ssh/config alias)") : "");
|
|
115
|
+
if (!host)
|
|
116
|
+
throw new BsError("missing_host", "the ssh provider needs a host", "pass --host <host>, anything `ssh <host>` accepts");
|
|
117
|
+
raw.providers = { ...raw.providers, [provider]: { ...cfg.providers[provider], host } };
|
|
118
|
+
}
|
|
119
|
+
if (unisolated(cfg, provider) && !noIsolationAccepted(cfg, provider)) {
|
|
120
|
+
const warning = `${provider} runs box agents as you, on ${provider === "local" ? "this machine" : "that host"}, with no sandbox and with their permission prompts off. An agent misled by a prompt, a repo or a web page can read, change or send anything you can reach there, including your files, keys and logins.`;
|
|
121
|
+
if (ui)
|
|
122
|
+
ui.warn("No isolation", warning);
|
|
123
|
+
const ok = flags.noIsolation || (ui ? await ui.confirm(`Run agents on ${provider} with no isolation anyway?`) : false);
|
|
124
|
+
if (!ok)
|
|
125
|
+
throw noIsolationError(provider);
|
|
126
|
+
raw.providers = { ...raw.providers, [provider]: { ...cfg.providers[provider], ...raw.providers?.[provider], acceptNoIsolation: true } };
|
|
127
|
+
}
|
|
128
|
+
const rank = (a) => (a in AGENT_HINTS ? Object.keys(AGENT_HINTS).indexOf(a) : Infinity);
|
|
129
|
+
const agents = Object.keys(cfg.agents).sort((a, b) => rank(a) - rank(b));
|
|
130
|
+
// a first choice from the menu, as opposed to a flag or an earlier run
|
|
131
|
+
const picked = Boolean(ui && !flags.agent && !raw.agent);
|
|
132
|
+
const agent = await pick("agent", "Which agent runs in a box?", agents.map((value) => ({ value, hint: AGENT_HINTS[value] })));
|
|
133
|
+
const gitValue = async (key, flag) => {
|
|
134
|
+
const v = flag ?? raw.git?.[key] ?? (ui ? await ui.text(`Git ${key} for commits from boxes`, localGit(`user.${key}`)) : localGit(`user.${key}`));
|
|
135
|
+
if (ui && (flag || raw.git?.[key]))
|
|
136
|
+
ui.ok(`git ${key} ${c.bold(v)}`);
|
|
137
|
+
return v;
|
|
138
|
+
};
|
|
139
|
+
const git = { name: await gitValue("name", flags.gitName), email: await gitValue("email", flags.gitEmail) };
|
|
140
|
+
if (!git.name || !git.email)
|
|
141
|
+
throw new BsError("missing_git", "no git name or email for commits from boxes", "pass --git-name and --git-email, or set git config user.name and user.email");
|
|
142
|
+
Object.assign(raw, { provider, agent, git });
|
|
143
|
+
writeUserConfig(raw);
|
|
144
|
+
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
|
+
ui?.section("Secrets");
|
|
155
|
+
if (await agentLogin(raw, cfg, agent, picked, ui)) {
|
|
156
|
+
writeUserConfig(raw);
|
|
157
|
+
cfg = loadConfig(raw);
|
|
158
|
+
}
|
|
159
|
+
const token = getAgent(cfg, agent).login?.token;
|
|
160
|
+
const secrets = [];
|
|
161
|
+
for (const s of neededSecrets(cfg, provider, agent)) {
|
|
162
|
+
let source = s.source;
|
|
163
|
+
let ok = hasSecret(source);
|
|
164
|
+
if (!ok && ui && s.name === "GH_TOKEN" && (await useGhAuthToken(ui))) {
|
|
165
|
+
source = { command: "gh auth token" };
|
|
166
|
+
raw.env = { ...(raw.env ?? DEFAULTS.env), GH_TOKEN: source };
|
|
167
|
+
writeUserConfig(raw);
|
|
168
|
+
ok = hasSecret(source);
|
|
169
|
+
if (!ok)
|
|
170
|
+
ui.warn("`gh auth token` printed nothing", "run `gh auth login`, then `bottleship init` again");
|
|
171
|
+
}
|
|
172
|
+
else if (!ok && ui) {
|
|
173
|
+
ui.warn(`${s.name} is missing`, `looked in ${describe(source)}`);
|
|
174
|
+
const hint = s.name === token?.env ? token.hint : undefined;
|
|
175
|
+
ui.say(` ${hint ?? HINTS[s.name] ?? HINTS["store" in s.source ? s.source.store : ""] ?? "paste the value"}`);
|
|
176
|
+
if (token?.command && s.name === token.env && (await ui.confirm(`Run \`${token.command}\` now? Paste the token it prints below.`)))
|
|
177
|
+
await ui.handOver(token.command);
|
|
178
|
+
const value = await ui.secret(`${s.name}`);
|
|
179
|
+
if (value) {
|
|
180
|
+
source = storeAndPoint(raw, s, value);
|
|
181
|
+
writeUserConfig(raw);
|
|
182
|
+
ok = true;
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
if (ui && ok)
|
|
186
|
+
ui.ok(s.name, describe(source));
|
|
187
|
+
secrets.push({ name: s.name, source: describe(source), ok });
|
|
188
|
+
}
|
|
189
|
+
const missing = secrets.filter((s) => !s.ok).map((s) => s.name);
|
|
190
|
+
if (missing.length)
|
|
191
|
+
throw missingSecrets(missing);
|
|
192
|
+
cfg = loadConfig(raw);
|
|
193
|
+
ui?.section(`Live check: ${provider} + ${agent}`);
|
|
194
|
+
const checks = await liveCheck(raw, cfg, provider, agent, ui);
|
|
195
|
+
const ok = checks.every((ch) => ch.ok);
|
|
196
|
+
if (ui) {
|
|
197
|
+
const failed = checks.find((ch) => !ch.ok);
|
|
198
|
+
if (failed)
|
|
199
|
+
ui.fail(`${failed.step} failed`, JSON.stringify(failed.error));
|
|
200
|
+
else {
|
|
201
|
+
ui.note("Add this to your agent's global instructions (e.g. ~/.claude/CLAUDE.md)", INSTRUCTIONS);
|
|
202
|
+
ui.say(`config ${c.dim(CONFIG_FILE)}\nboxes ${provider} running ${agent}, committing as ${git.name} <${git.email}>`);
|
|
203
|
+
ui.close(c.green("All set. Tell your agent to start something in a box."));
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
return { ok, config: CONFIG_FILE, provider, agent, git, secrets, checks, instructions: INSTRUCTIONS };
|
|
207
|
+
}
|
|
208
|
+
// boxes push and open PRs with GH_TOKEN: a fine-grained token by default, the gh CLI's token only after a warning
|
|
209
|
+
async function useGhAuthToken(ui) {
|
|
210
|
+
ui.say("Boxes push branches and open PRs with a GitHub token. A fine-grained token limited to the repos you pick keeps a box away from everything else in your account.");
|
|
211
|
+
const how = await ui.choose("Which GitHub token do boxes get?", [
|
|
212
|
+
{ value: "fine-grained", hint: "a fine-grained token for the repos you pick (recommended)" },
|
|
213
|
+
{ value: "gh", hint: "`gh auth token`: your gh login, every repo you can reach" },
|
|
214
|
+
], "fine-grained");
|
|
215
|
+
if (how !== "gh")
|
|
216
|
+
return false;
|
|
217
|
+
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
|
+
return ui.confirm("Give boxes your gh login anyway?");
|
|
219
|
+
}
|
|
220
|
+
// a user entry for a builtin agent only needs the fields it overrides, see loadConfig
|
|
221
|
+
const patchAgent = (raw, name, patch) => {
|
|
222
|
+
const before = raw.agents?.[name];
|
|
223
|
+
const login = patch.login && { ...before?.login, ...patch.login };
|
|
224
|
+
raw.agents = { ...raw.agents, [name]: { ...before, ...patch, ...(login && { login }) } };
|
|
225
|
+
};
|
|
226
|
+
const tokenNames = (login) => (login.tokens ?? [login.token]).map((t) => t.env).join(", ");
|
|
227
|
+
const routeHint = (login, route) => route === "token" ? `${login.tokens ? `an API key (${tokenNames(login)})` : login.token.env}${login.token.command ? ` from \`${login.token.command}\`` : ""}, kept in bottleship's store`
|
|
228
|
+
: route === "device" ? "log in inside each box: bottleship shows you a URL and code to approve for every new box"
|
|
229
|
+
: "no login, e.g. free models";
|
|
230
|
+
// asks for the login route on a first choice or a missing token, and which key on the token route; returns whether raw changed
|
|
231
|
+
async function agentLogin(raw, cfg, agentName, picked, ui) {
|
|
232
|
+
const login = getAgent(cfg, agentName).login;
|
|
233
|
+
if (!login || !ui)
|
|
234
|
+
return false;
|
|
235
|
+
const ready = (route) => {
|
|
236
|
+
if (route !== "token" || !login.token)
|
|
237
|
+
return true;
|
|
238
|
+
const source = cfg.env[login.token.env] ?? { store: login.token.store };
|
|
239
|
+
return !isSource(source) || hasSecret(source);
|
|
240
|
+
};
|
|
241
|
+
let use = login.use;
|
|
242
|
+
const ask = picked || !ready(use);
|
|
243
|
+
const patch = {};
|
|
244
|
+
if (login.routes.length > 1 && ask) {
|
|
245
|
+
use = (await ui.choose(`How does ${agentName} log in inside boxes?`, login.routes.map((r) => ({ value: r, hint: routeHint(login, r) })), use));
|
|
246
|
+
if (use !== login.use)
|
|
247
|
+
patch.use = use;
|
|
248
|
+
}
|
|
249
|
+
if (use === "token" && login.tokens && ask) {
|
|
250
|
+
const env = await ui.choose(`Which key does ${agentName} use?`, login.tokens.map((t) => ({ value: t.env, hint: t.hint })), login.token?.env ?? login.tokens[0].env);
|
|
251
|
+
const token = login.tokens.find((t) => t.env === env);
|
|
252
|
+
if (token.env !== login.token?.env)
|
|
253
|
+
patch.token = token;
|
|
254
|
+
}
|
|
255
|
+
if (use === "device" && login.device?.setup)
|
|
256
|
+
ui.note(`Once, before boxes can log ${agentName} in`, login.device.setup);
|
|
257
|
+
if (!Object.keys(patch).length)
|
|
258
|
+
return false;
|
|
259
|
+
patchAgent(raw, agentName, { login: patch });
|
|
260
|
+
return true;
|
|
261
|
+
}
|
|
262
|
+
// the template (recorded in raw), create, exec, the agent's setup, its device login, one reply, destroy; stops at the first failure but always destroys
|
|
263
|
+
async function liveCheck(raw, cfg, providerName, agent, ui) {
|
|
264
|
+
const checks = [];
|
|
265
|
+
const step = async (name, fn) => {
|
|
266
|
+
const t0 = Date.now();
|
|
267
|
+
try {
|
|
268
|
+
const detail = ui ? await ui.step(name, fn) : await fn();
|
|
269
|
+
checks.push({ step: name, ok: true, ms: Date.now() - t0, ...(detail ? { detail } : {}) });
|
|
270
|
+
return true;
|
|
271
|
+
}
|
|
272
|
+
catch (e) {
|
|
273
|
+
checks.push({ step: name, ok: false, ms: Date.now() - t0, error: e instanceof BsError ? e.toJSON() : e.message });
|
|
274
|
+
return false;
|
|
275
|
+
}
|
|
276
|
+
};
|
|
277
|
+
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
|
+
const check = async () => {
|
|
283
|
+
// the provider finds a template it already built, so a rerun with the same spec is quick
|
|
284
|
+
if (provider.template && !(await step("build the box template", async () => {
|
|
285
|
+
const spec = templateSpec(cfg, agent);
|
|
286
|
+
template = await provider.template(spec);
|
|
287
|
+
raw.templates = { ...raw.templates, [providerName]: { name: spec.name, ref: template } };
|
|
288
|
+
writeUserConfig(raw);
|
|
289
|
+
return template;
|
|
290
|
+
})))
|
|
291
|
+
return;
|
|
292
|
+
if (!(await step("create a box", async () => {
|
|
293
|
+
const { id } = await provider.create({ id: newBoxId("check"), ...(template && { template }) });
|
|
294
|
+
b = { id: `${providerName}:${id}`, provider, box: id };
|
|
295
|
+
})))
|
|
296
|
+
return;
|
|
297
|
+
if (!(await step("run a command", async () => {
|
|
298
|
+
const r = await b.provider.exec(b.box, "echo bottleship-ok");
|
|
299
|
+
if (r.stdout.trim() !== "bottleship-ok")
|
|
300
|
+
throw new Error(`expected bottleship-ok, got exit ${r.code}: ${r.stdout}${r.stderr}`);
|
|
301
|
+
})))
|
|
302
|
+
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
|
+
await step(`${agent} replies`, async () => {
|
|
333
|
+
const s = await waitAgent(b, 300);
|
|
334
|
+
if (s.agent === "running")
|
|
335
|
+
throw new Error("no reply within 5 minutes");
|
|
336
|
+
if (s.agent !== "exited" || s.exitCode !== 0 || !s.log.includes("bottleship-ok"))
|
|
337
|
+
throw new Error(`agent ${s.agent}${"exitCode" in s ? ` (exit ${s.exitCode})` : ""}: ${s.log.slice(-500)}`);
|
|
338
|
+
return s.log.trim().slice(-200);
|
|
339
|
+
});
|
|
340
|
+
};
|
|
341
|
+
await check();
|
|
342
|
+
if (b)
|
|
343
|
+
await step("destroy the box", async () => void (await b.provider.destroy(b.box)));
|
|
344
|
+
return checks;
|
|
345
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
// Where things live. Local state is under ~/.bottleship (override: BOTTLESHIP_HOME).
|
|
2
|
+
import { existsSync } from "node:fs";
|
|
3
|
+
import { homedir } from "node:os";
|
|
4
|
+
import { dirname, join } from "node:path";
|
|
5
|
+
// runs from src/*.ts and from dist/src/*.js, so find the package root by its package.json
|
|
6
|
+
function packageRoot(from) {
|
|
7
|
+
for (let dir = from;; dir = dirname(dir)) {
|
|
8
|
+
if (existsSync(join(dir, "package.json")))
|
|
9
|
+
return dir;
|
|
10
|
+
if (dirname(dir) === dir)
|
|
11
|
+
throw new Error(`no package.json above ${from}`);
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
export const ROOT = packageRoot(import.meta.dirname);
|
|
15
|
+
export const DIST = join(ROOT, "dist");
|
|
16
|
+
export const HOME = process.env.BOTTLESHIP_HOME ?? join(homedir(), ".bottleship");
|
|
17
|
+
export const CONFIG_FILE = join(HOME, "config.json");
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
import type { SecretSource } from "./types.ts";
|
|
2
|
+
export declare function describe(source: SecretSource): string;
|
|
3
|
+
export declare function readSecret(source: SecretSource): string;
|
|
4
|
+
export declare function storeSecret(name: string, value: string): string;
|
|
5
|
+
export declare function hasSecret(source: SecretSource): boolean;
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
// Reads and stores secret values on this machine. Sources are described in types.ts (SecretSource).
|
|
2
|
+
import { execFileSync } from "node:child_process";
|
|
3
|
+
import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import { HOME } from "./paths.js";
|
|
6
|
+
const SERVICE = "bottleship";
|
|
7
|
+
const FILE_DIR = join(HOME, "secrets");
|
|
8
|
+
const useKeychain = () => process.platform === "darwin" && !process.env.BOTTLESHIP_NO_KEYCHAIN;
|
|
9
|
+
const sh = (cmd, args, input) => execFileSync(cmd, args, { input, encoding: "utf8", stdio: ["pipe", "pipe", "pipe"] });
|
|
10
|
+
export function describe(source) {
|
|
11
|
+
if ("store" in source)
|
|
12
|
+
return useKeychain() ? `keychain:${SERVICE}/${source.store}` : `file:${join(FILE_DIR, source.store)}`;
|
|
13
|
+
if ("command" in source)
|
|
14
|
+
return `command:${source.command}`;
|
|
15
|
+
return `env:${source.env}`;
|
|
16
|
+
}
|
|
17
|
+
export function readSecret(source) {
|
|
18
|
+
if ("store" in source) {
|
|
19
|
+
if (useKeychain())
|
|
20
|
+
return sh("security", ["find-generic-password", "-s", SERVICE, "-a", source.store, "-w"]).trim();
|
|
21
|
+
try {
|
|
22
|
+
return readFileSync(join(FILE_DIR, source.store), "utf8").trim();
|
|
23
|
+
}
|
|
24
|
+
catch {
|
|
25
|
+
throw new Error(`no secret "${source.store}" stored`);
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
if ("command" in source)
|
|
29
|
+
return sh("sh", ["-c", source.command]).trim();
|
|
30
|
+
const v = process.env[source.env];
|
|
31
|
+
if (!v)
|
|
32
|
+
throw new Error(`env ${source.env} is not set`);
|
|
33
|
+
return v;
|
|
34
|
+
}
|
|
35
|
+
// returns where the value went, for the CLI to print
|
|
36
|
+
export function storeSecret(name, value) {
|
|
37
|
+
if (useKeychain()) {
|
|
38
|
+
// `security -i` reads one line of at most 4096 bytes; anything past it would run as another command
|
|
39
|
+
if (/[\r\n]/.test(value) || Buffer.byteLength(value) > 4000)
|
|
40
|
+
throw new Error(`the value for "${name}" is too long or has line breaks for the keychain`);
|
|
41
|
+
// fed on stdin via `security -i` so the value never shows up in argv
|
|
42
|
+
const quoted = value.replace(/\\/g, "\\\\").replace(/"/g, '\\"');
|
|
43
|
+
sh("security", ["-i"], `add-generic-password -U -s ${SERVICE} -a ${name} -w "${quoted}"\n`);
|
|
44
|
+
}
|
|
45
|
+
else {
|
|
46
|
+
mkdirSync(FILE_DIR, { recursive: true, mode: 0o700 });
|
|
47
|
+
writeFileSync(join(FILE_DIR, name), value + "\n", { mode: 0o600 });
|
|
48
|
+
}
|
|
49
|
+
return describe({ store: name });
|
|
50
|
+
}
|
|
51
|
+
export function hasSecret(source) {
|
|
52
|
+
try {
|
|
53
|
+
return Boolean(readSecret(source));
|
|
54
|
+
}
|
|
55
|
+
catch {
|
|
56
|
+
return false;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
import type { Readable, Writable } from "node:stream";
|
|
2
|
+
export type SecretSource = {
|
|
3
|
+
store: string;
|
|
4
|
+
} | {
|
|
5
|
+
command: string;
|
|
6
|
+
} | {
|
|
7
|
+
env: string;
|
|
8
|
+
};
|
|
9
|
+
export type EnvMap = Record<string, string | SecretSource>;
|
|
10
|
+
export interface AgentSpec {
|
|
11
|
+
install?: string[];
|
|
12
|
+
run: string;
|
|
13
|
+
resume: string;
|
|
14
|
+
env?: EnvMap;
|
|
15
|
+
unset?: string[];
|
|
16
|
+
login?: AgentLogin;
|
|
17
|
+
}
|
|
18
|
+
export type LoginRoute = "token" | "device" | "none";
|
|
19
|
+
export interface AgentLogin {
|
|
20
|
+
routes: LoginRoute[];
|
|
21
|
+
use: LoginRoute;
|
|
22
|
+
token?: {
|
|
23
|
+
env: string;
|
|
24
|
+
store: string;
|
|
25
|
+
command?: string;
|
|
26
|
+
hint?: string;
|
|
27
|
+
};
|
|
28
|
+
tokens?: {
|
|
29
|
+
env: string;
|
|
30
|
+
store: string;
|
|
31
|
+
hint?: string;
|
|
32
|
+
}[];
|
|
33
|
+
device?: {
|
|
34
|
+
command: string;
|
|
35
|
+
status: string;
|
|
36
|
+
setup?: string;
|
|
37
|
+
timeout?: number;
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
export interface ProviderSpec {
|
|
41
|
+
module: string;
|
|
42
|
+
[option: string]: unknown;
|
|
43
|
+
}
|
|
44
|
+
export interface Config {
|
|
45
|
+
provider: string;
|
|
46
|
+
agent: string;
|
|
47
|
+
git: {
|
|
48
|
+
name: string;
|
|
49
|
+
email: string;
|
|
50
|
+
};
|
|
51
|
+
env: EnvMap;
|
|
52
|
+
providers: Record<string, ProviderSpec>;
|
|
53
|
+
agents: Record<string, AgentSpec>;
|
|
54
|
+
templates: Record<string, {
|
|
55
|
+
name: string;
|
|
56
|
+
ref: string;
|
|
57
|
+
}>;
|
|
58
|
+
}
|
|
59
|
+
export interface ExecResult {
|
|
60
|
+
code: number | null;
|
|
61
|
+
stdout: string;
|
|
62
|
+
stderr: string;
|
|
63
|
+
}
|
|
64
|
+
export interface BoxStream {
|
|
65
|
+
stdin: Writable;
|
|
66
|
+
stdout: Readable;
|
|
67
|
+
stderr: Readable;
|
|
68
|
+
close(): void;
|
|
69
|
+
onClose(cb: (code?: number | null) => void): void;
|
|
70
|
+
}
|
|
71
|
+
export type BoxStatus = "running" | "gone";
|
|
72
|
+
export interface TemplateSpec {
|
|
73
|
+
name: string;
|
|
74
|
+
dockerfile: string;
|
|
75
|
+
}
|
|
76
|
+
export interface Provider {
|
|
77
|
+
template?(spec: TemplateSpec): Promise<string>;
|
|
78
|
+
create(opts: {
|
|
79
|
+
id: string;
|
|
80
|
+
source?: string;
|
|
81
|
+
template?: string;
|
|
82
|
+
}): Promise<{
|
|
83
|
+
id: string;
|
|
84
|
+
}>;
|
|
85
|
+
exec(id: string, script: string, opts?: {
|
|
86
|
+
stdin?: string | Buffer;
|
|
87
|
+
}): Promise<ExecResult>;
|
|
88
|
+
destroy(id: string): Promise<unknown>;
|
|
89
|
+
openStream?(id: string, script: string): BoxStream | Promise<BoxStream>;
|
|
90
|
+
status?(id: string): Promise<BoxStatus>;
|
|
91
|
+
}
|
|
92
|
+
export interface LoadedProvider extends Provider {
|
|
93
|
+
name: string;
|
|
94
|
+
}
|
|
95
|
+
export interface RunOptions {
|
|
96
|
+
stdin?: string | Buffer;
|
|
97
|
+
check?: boolean;
|
|
98
|
+
env?: Record<string, string>;
|
|
99
|
+
}
|
|
100
|
+
export interface ProviderKit {
|
|
101
|
+
run(cmd: string, args: string[], opts?: RunOptions): Promise<ExecResult>;
|
|
102
|
+
shq(s: unknown): string;
|
|
103
|
+
fill(template: string, values: Record<string, unknown>): string;
|
|
104
|
+
spawnStream(cmd: string, args: string[]): BoxStream;
|
|
105
|
+
wsStream(url: string, headers?: Record<string, string>): BoxStream;
|
|
106
|
+
}
|
|
107
|
+
export interface ProviderCtx {
|
|
108
|
+
name: string;
|
|
109
|
+
options: Record<string, any>;
|
|
110
|
+
secret(source: SecretSource): string;
|
|
111
|
+
log(...args: unknown[]): void;
|
|
112
|
+
kit: ProviderKit;
|
|
113
|
+
}
|
|
114
|
+
export type ProviderFactory = (ctx: ProviderCtx) => Provider | Promise<Provider>;
|
|
115
|
+
export interface BsErrorJSON {
|
|
116
|
+
error: string;
|
|
117
|
+
message: string;
|
|
118
|
+
fix?: string;
|
|
119
|
+
[extra: string]: unknown;
|
|
120
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
package/dist/src/ui.d.ts
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
export declare const c: {
|
|
2
|
+
bold: (s: string) => string;
|
|
3
|
+
dim: (s: string) => string;
|
|
4
|
+
cyan: (s: string) => string;
|
|
5
|
+
green: (s: string) => string;
|
|
6
|
+
red: (s: string) => string;
|
|
7
|
+
yellow: (s: string) => string;
|
|
8
|
+
blue: (s: string) => string;
|
|
9
|
+
white: (s: string) => string;
|
|
10
|
+
};
|
|
11
|
+
export declare function header(): string;
|
|
12
|
+
export interface UI {
|
|
13
|
+
intro(): void;
|
|
14
|
+
say(line?: string): void;
|
|
15
|
+
section(title: string): void;
|
|
16
|
+
ok(label: string, detail?: string): void;
|
|
17
|
+
warn(label: string, detail?: string): void;
|
|
18
|
+
note(title: string, body: string): void;
|
|
19
|
+
text(question: string, def?: string): Promise<string>;
|
|
20
|
+
choose(question: string, options: {
|
|
21
|
+
value: string;
|
|
22
|
+
hint?: string;
|
|
23
|
+
}[], def: string): Promise<string>;
|
|
24
|
+
secret(question: string): Promise<string>;
|
|
25
|
+
confirm(question: string): Promise<boolean>;
|
|
26
|
+
handOver(command: string): Promise<number | null>;
|
|
27
|
+
step<T>(label: string, fn: () => Promise<T>): Promise<T>;
|
|
28
|
+
fail(message: string, fix?: string): void;
|
|
29
|
+
close(message?: string): void;
|
|
30
|
+
}
|
|
31
|
+
export declare function terminalUI(): UI;
|