bostrat-node 0.0.1 → 0.2.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/LICENSE.md +18 -0
- package/README.md +37 -0
- package/bin/bostrat-git-credential +46 -0
- package/bin/bostrat-mock +118 -0
- package/bin/bostrat-node.js +12 -0
- package/bin/bostrat-runbook +126 -0
- package/bin/bostrat-ticket +137 -0
- package/bin/bostrat-view +96 -0
- package/bin/gh +51 -0
- package/dist/cli.js +162 -0
- package/dist/index.js +265 -0
- package/node-entrypoint.sh +21 -0
- package/package.json +19 -5
- package/cli.js +0 -4
package/LICENSE.md
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
# License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Bostrat. All rights reserved.
|
|
4
|
+
|
|
5
|
+
This package is proprietary software, distributed in bundled form for use with
|
|
6
|
+
the Bostrat service (https://bostrat.ai). You may install and run it to operate
|
|
7
|
+
a Bostrat node connected to a Bostrat coordination plane under your account.
|
|
8
|
+
|
|
9
|
+
You may not copy, modify, reverse engineer, decompile, redistribute, sublicense,
|
|
10
|
+
or create derivative works from this software, in whole or in part, except as
|
|
11
|
+
permitted by applicable law notwithstanding this restriction.
|
|
12
|
+
|
|
13
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
14
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
15
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
16
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
17
|
+
LIABILITY ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE
|
|
18
|
+
OR OTHER DEALINGS IN THE SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
# bostrat-node
|
|
2
|
+
|
|
3
|
+
The Bostrat **node** — the runtime that turns a machine you own (laptop, home
|
|
4
|
+
server, or a VM in your own cloud account) into an isolated home for your coding
|
|
5
|
+
agents, coordinated from one chat by your [Bostrat](https://bostrat.ai) plane.
|
|
6
|
+
|
|
7
|
+
The node **dials out** over WSS; nothing listens publicly. It custodies no model
|
|
8
|
+
credentials — you sign in to your own agent CLIs (Claude Code, Codex, Cursor)
|
|
9
|
+
yourself, inside the boxes it runs, and those logins never leave the machine.
|
|
10
|
+
|
|
11
|
+
## Quick start
|
|
12
|
+
|
|
13
|
+
```sh
|
|
14
|
+
npx bostrat-node init # paste your node token from the Bostrat setup chat
|
|
15
|
+
npx bostrat-node run # run in the foreground — your plane sees it connect
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
Keep it running across reboots:
|
|
19
|
+
|
|
20
|
+
```sh
|
|
21
|
+
npm install -g bostrat-node
|
|
22
|
+
bostrat-node install-service # systemd --user (Linux) / launchd (macOS)
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
Other commands: `doctor` (check this machine), `fly` (provision a node in your
|
|
26
|
+
own Fly.io org, on your bill), `--version`.
|
|
27
|
+
|
|
28
|
+
## Requirements
|
|
29
|
+
|
|
30
|
+
- Node.js ≥ 22.5, `tmux`, `git`, and at least one agent CLI (`claude`, `codex`,
|
|
31
|
+
or `cursor-agent`).
|
|
32
|
+
- macOS installs with no build toolchain. Linux compiles one native dependency
|
|
33
|
+
on install and needs `build-essential` + `python3` (Debian/Ubuntu:
|
|
34
|
+
`sudo apt-get install build-essential python3`).
|
|
35
|
+
|
|
36
|
+
Setup walkthrough and node tokens: your plane's setup chat at
|
|
37
|
+
[bostrat.ai](https://bostrat.ai).
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// git credential helper — the broker path (github-node-provisioning-scope.md N2).
|
|
3
|
+
//
|
|
4
|
+
// git invokes this with the action appended after any configured args:
|
|
5
|
+
// bostrat-git-credential [agent-url] <get|store|erase>
|
|
6
|
+
// On `get` it reads git's key=value description from stdin (host, protocol, and —
|
|
7
|
+
// because managed clones set credential.useHttpPath — the repo path), asks the
|
|
8
|
+
// local agent's loopback for a per-repo broker token, and prints the credential.
|
|
9
|
+
// `store`/`erase` are no-ops: there is nothing to persist — tokens live only in
|
|
10
|
+
// the agent's memory (P1-D1) and expire on their own.
|
|
11
|
+
//
|
|
12
|
+
// Exit 0 with no output on anything unhandled: git then falls through to its
|
|
13
|
+
// remaining helpers/prompting rather than hard-failing the operation.
|
|
14
|
+
|
|
15
|
+
const args = process.argv.slice(2);
|
|
16
|
+
const action = args[args.length - 1];
|
|
17
|
+
const agentUrl = (args.length > 1 ? args[0] : "") || process.env.BOSTRAT_AGENT_URL || "http://127.0.0.1:3362";
|
|
18
|
+
|
|
19
|
+
if (action !== "get") process.exit(0);
|
|
20
|
+
|
|
21
|
+
let input = "";
|
|
22
|
+
process.stdin.setEncoding("utf8");
|
|
23
|
+
process.stdin.on("data", (d) => (input += d));
|
|
24
|
+
process.stdin.on("end", async () => {
|
|
25
|
+
const desc = {};
|
|
26
|
+
for (const line of input.split("\n")) {
|
|
27
|
+
const i = line.indexOf("=");
|
|
28
|
+
if (i > 0) desc[line.slice(0, i)] = line.slice(i + 1).trim();
|
|
29
|
+
}
|
|
30
|
+
if (desc.host !== "github.com" || (desc.protocol && desc.protocol !== "https")) process.exit(0);
|
|
31
|
+
const slug = String(desc.path || "").replace(/^\/+/, "").replace(/\.git$/, "");
|
|
32
|
+
if (!/^[^/\s]+\/[^/\s]+$/.test(slug)) process.exit(0);
|
|
33
|
+
try {
|
|
34
|
+
const r = await fetch(`${agentUrl.replace(/\/+$/, "")}/api/github/token?repo=${encodeURIComponent(slug)}`);
|
|
35
|
+
if (!r.ok) {
|
|
36
|
+
const body = await r.json().catch(() => ({}));
|
|
37
|
+
process.stderr.write(`bostrat-git-credential: ${body?.error || `agent replied ${r.status}`}\n`);
|
|
38
|
+
process.exit(0);
|
|
39
|
+
}
|
|
40
|
+
const { token } = await r.json();
|
|
41
|
+
if (token) process.stdout.write(`username=x-access-token\npassword=${token}\n`);
|
|
42
|
+
} catch (e) {
|
|
43
|
+
process.stderr.write(`bostrat-git-credential: agent unreachable at ${agentUrl} (${e?.message || e})\n`);
|
|
44
|
+
}
|
|
45
|
+
process.exit(0);
|
|
46
|
+
});
|
package/bin/bostrat-mock
ADDED
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// bostrat-mock — the in-box CLI agents use to publish design comps as IMAGES
|
|
3
|
+
// (mocks scope §6, §12). Talks to the on-box bostrat agent over loopback (same
|
|
4
|
+
// pattern as bostrat-view) and prints the outcome plus a stable `(mock:<id>)`
|
|
5
|
+
// marker. The Track is resolved server-side from this process's cwd (the box
|
|
6
|
+
// always runs inside its Track's workspace), so the CLI needs no configuration.
|
|
7
|
+
//
|
|
8
|
+
// bostrat-mock add --name "Homepage — v1" [--group options] [--width 1300] <file.html|file.png>
|
|
9
|
+
// bostrat-mock update <name|id> <file.html|file.png> # a new VERSION of the same mock
|
|
10
|
+
// bostrat-mock ls
|
|
11
|
+
// bostrat-mock rm <name|id>
|
|
12
|
+
//
|
|
13
|
+
// .html is rendered full-page in the box; .png is published verbatim (the image
|
|
14
|
+
// IS the deliverable). To revise after feedback, always `update` — never add a
|
|
15
|
+
// near-duplicate mock.
|
|
16
|
+
|
|
17
|
+
import fsp from "node:fs/promises";
|
|
18
|
+
import path from "node:path";
|
|
19
|
+
|
|
20
|
+
const AGENT = process.env.BOSTRAT_AGENT_URL || `http://127.0.0.1:${process.env.BOSTRAT_AGENT_PORT || 3362}`;
|
|
21
|
+
|
|
22
|
+
async function call(method, apiPath, body, contentType) {
|
|
23
|
+
const r = await fetch(`${AGENT}${apiPath}`, {
|
|
24
|
+
method,
|
|
25
|
+
headers: body ? { "content-type": contentType || "application/json" } : undefined,
|
|
26
|
+
body: body ? (contentType ? body : JSON.stringify(body)) : undefined,
|
|
27
|
+
});
|
|
28
|
+
const data = await r.json().catch(() => ({}));
|
|
29
|
+
if (!r.ok) throw new Error(data.error || `HTTP ${r.status}`);
|
|
30
|
+
return data;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function fail(msg) {
|
|
34
|
+
console.error(`[bostrat-mock] ${msg}`);
|
|
35
|
+
process.exit(1);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// Poll until the mock's latest version settles (renders are serialized; an image
|
|
39
|
+
// publish is ready immediately) so the printed state is real.
|
|
40
|
+
async function settle(id) {
|
|
41
|
+
let m = (await call("GET", `/api/mocks/${id}`)).mock;
|
|
42
|
+
for (let i = 0; i < 60 && (m.state === "queued" || m.state === "rendering"); i++) {
|
|
43
|
+
await new Promise((r) => setTimeout(r, 2000));
|
|
44
|
+
m = (await call("GET", `/api/mocks/${id}`)).mock;
|
|
45
|
+
}
|
|
46
|
+
return m;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function report(m, verb) {
|
|
50
|
+
if (m.state === "error") fail(`render failed:\n${m.error || "(no detail)"}`);
|
|
51
|
+
if (m.state !== "ready") fail(`render still ${m.state} — check the gallery later (mock:${m.id})`);
|
|
52
|
+
const v = m.version > 1 ? ` v${m.version}` : "";
|
|
53
|
+
console.log(`[bostrat-mock] ${verb}: "${m.name}"${v} ${m.width}×${m.height}${m.dpr > 1 ? ` @${m.dpr}x` : ""} (mock:${m.id})`);
|
|
54
|
+
console.log(`The operator sees it as an image in the Track chat and the gallery — no need to screenshot or link it.`);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// A publishable payload from a file: html → JSON field, png → raw body.
|
|
58
|
+
async function payload(file) {
|
|
59
|
+
const isPng = path.extname(file).toLowerCase() === ".png";
|
|
60
|
+
const data = await fsp.readFile(file).catch(() => fail(`cannot read ${file}`));
|
|
61
|
+
return isPng ? { image: data } : { html: data.toString("utf8") };
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const argv = process.argv.slice(2);
|
|
65
|
+
const sub = argv.shift();
|
|
66
|
+
|
|
67
|
+
if (sub === "add") {
|
|
68
|
+
let name = null, group, width, file = null;
|
|
69
|
+
for (let i = 0; i < argv.length; i++) {
|
|
70
|
+
if (argv[i] === "--name") name = argv[++i];
|
|
71
|
+
else if (argv[i] === "--group") group = argv[++i];
|
|
72
|
+
else if (argv[i] === "--width") width = Number(argv[++i]);
|
|
73
|
+
else if (!argv[i].startsWith("--")) file = argv[i];
|
|
74
|
+
}
|
|
75
|
+
if (!name || !file) fail('usage: bostrat-mock add --name "<name>" [--group <id>] [--width N] <file.html|file.png>');
|
|
76
|
+
try {
|
|
77
|
+
const p = await payload(file);
|
|
78
|
+
const mock = p.image
|
|
79
|
+
? (await call("POST", `/api/mocks?${new URLSearchParams({ name, cwd: process.cwd(), ...(group ? { group } : {}) })}`, p.image, "image/png")).mock
|
|
80
|
+
: (await call("POST", "/api/mocks", { name, html: p.html, group, width, cwd: process.cwd() })).mock;
|
|
81
|
+
report(await settle(mock.id), "ready");
|
|
82
|
+
} catch (e) {
|
|
83
|
+
fail(e.message);
|
|
84
|
+
}
|
|
85
|
+
} else if (sub === "update") {
|
|
86
|
+
const [key, file] = argv.filter((a) => !a.startsWith("--"));
|
|
87
|
+
if (!key || !file) fail("usage: bostrat-mock update <name|id> <file.html|file.png>");
|
|
88
|
+
try {
|
|
89
|
+
const { mocks } = await call("GET", "/api/mocks");
|
|
90
|
+
const target = mocks.find((x) => x.id === key || x.name === key);
|
|
91
|
+
if (!target) fail(`no mock matching "${key}" — use \`bostrat-mock ls\`, or \`add\` for a new comp`);
|
|
92
|
+
const p = await payload(file);
|
|
93
|
+
const mock = p.image
|
|
94
|
+
? (await call("POST", `/api/mocks/${target.id}/update`, p.image, "image/png")).mock
|
|
95
|
+
: (await call("POST", `/api/mocks/${target.id}/update`, { html: p.html })).mock;
|
|
96
|
+
report(await settle(mock.id), "updated");
|
|
97
|
+
} catch (e) {
|
|
98
|
+
fail(e.message);
|
|
99
|
+
}
|
|
100
|
+
} else if (sub === "ls") {
|
|
101
|
+
const { mocks } = await call("GET", "/api/mocks").catch((e) => fail(e.message));
|
|
102
|
+
if (!mocks.length) console.log("no mocks");
|
|
103
|
+
for (const m of mocks) {
|
|
104
|
+
const dims = m.readyVersion ? ` ${m.width}×${m.height}` : "";
|
|
105
|
+
const ver = m.version > 1 ? ` v${m.version}` : "";
|
|
106
|
+
console.log(`${m.state.padEnd(9)} ${m.name}${ver}${m.group ? ` [${m.group}]` : ""}${dims} (mock:${m.id})`);
|
|
107
|
+
}
|
|
108
|
+
} else if (sub === "rm") {
|
|
109
|
+
const key = argv[0];
|
|
110
|
+
if (!key) fail("usage: bostrat-mock rm <name|id>");
|
|
111
|
+
const { mocks } = await call("GET", "/api/mocks").catch((e) => fail(e.message));
|
|
112
|
+
const m = mocks.find((x) => x.id === key || x.name === key);
|
|
113
|
+
if (!m) fail(`no mock matching "${key}"`);
|
|
114
|
+
await call("DELETE", `/api/mocks/${m.id}`).catch((e) => fail(e.message));
|
|
115
|
+
console.log(`[bostrat-mock] deleted: ${m.name}`);
|
|
116
|
+
} else {
|
|
117
|
+
fail('usage: bostrat-mock <add|update|ls|rm> … (add --name "<name>" <file>; update <name|id> <file>)');
|
|
118
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Thin dual-layout wrapper — everything lives (testably) in ../src/node-cli.js.
|
|
3
|
+
// A repo checkout runs ../src (always fresh, never a stale bundle); the
|
|
4
|
+
// published bostrat-node package ships no src/ and falls through to the
|
|
5
|
+
// minified ../dist bundle (node-package-distribution-scope-2026-07-20.md §2).
|
|
6
|
+
import { existsSync } from "node:fs";
|
|
7
|
+
|
|
8
|
+
const src = new URL("../src/node-cli.js", import.meta.url);
|
|
9
|
+
const { main } = existsSync(src) ? await import(src.href) : await import(new URL("../dist/cli.js", import.meta.url).href);
|
|
10
|
+
|
|
11
|
+
const code = await main();
|
|
12
|
+
if (code !== null) process.exit(code);
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// bostrat-runbook — the in-box CLI agents use to publish OPERATOR RUNBOOKS: the
|
|
3
|
+
// steps only the operator can perform when a piece of work lands (registrations,
|
|
4
|
+
// secrets, interactive logins, device installs). Talks to the on-box bostrat
|
|
5
|
+
// agent over loopback (same pattern as bostrat-mock) and prints the outcome plus
|
|
6
|
+
// a stable `(runbook:<id>)` marker. The Track is resolved server-side from this
|
|
7
|
+
// process's cwd, so the CLI needs no configuration.
|
|
8
|
+
//
|
|
9
|
+
// bostrat-runbook add --name "<deliverable state>" <file.md>
|
|
10
|
+
// bostrat-runbook update <name|id> [--note "<what changed>"] <file.md> # a new VERSION of the same runbook
|
|
11
|
+
// bostrat-runbook done <name|id> # every step confirmed by the operator
|
|
12
|
+
// bostrat-runbook reopen <name|id> # a step turned out not-done / new steps appeared
|
|
13
|
+
// bostrat-runbook ls [--all] # default: this Track's runbooks; --all: everything
|
|
14
|
+
// bostrat-runbook rm <name|id>
|
|
15
|
+
//
|
|
16
|
+
// Runbooks are read-only for the operator — you are the only writer. Never put a
|
|
17
|
+
// secret VALUE in one (name the secret and where it goes; the server rejects
|
|
18
|
+
// token-shaped content). To revise after operator feedback, always `update` —
|
|
19
|
+
// never add a near-duplicate runbook.
|
|
20
|
+
|
|
21
|
+
import fsp from "node:fs/promises";
|
|
22
|
+
|
|
23
|
+
const AGENT = process.env.BOSTRAT_AGENT_URL || `http://127.0.0.1:${process.env.BOSTRAT_AGENT_PORT || 3362}`;
|
|
24
|
+
|
|
25
|
+
async function call(method, apiPath, body) {
|
|
26
|
+
const r = await fetch(`${AGENT}${apiPath}`, {
|
|
27
|
+
method,
|
|
28
|
+
headers: body ? { "content-type": "application/json" } : undefined,
|
|
29
|
+
body: body ? JSON.stringify(body) : undefined,
|
|
30
|
+
});
|
|
31
|
+
const data = await r.json().catch(() => ({}));
|
|
32
|
+
if (!r.ok) throw new Error(data.error || `HTTP ${r.status}`);
|
|
33
|
+
return data;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function fail(msg) {
|
|
37
|
+
console.error(`[bostrat-runbook] ${msg}`);
|
|
38
|
+
process.exit(1);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
async function resolve(key) {
|
|
42
|
+
const { runbooks } = await call("GET", "/api/runbooks");
|
|
43
|
+
const rb = runbooks.find((x) => x.id === key || x.name === key);
|
|
44
|
+
if (!rb) fail(`no runbook matching "${key}" — use \`bostrat-runbook ls\`, or \`add\` for a new one`);
|
|
45
|
+
return rb;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function report(rb, verb) {
|
|
49
|
+
const v = rb.version > 1 ? ` v${rb.version}` : "";
|
|
50
|
+
console.log(`[bostrat-runbook] ${verb}: "${rb.name}"${v} [${rb.status}] (runbook:${rb.id})`);
|
|
51
|
+
console.log(`The operator sees it as a card in the Track chat and in the Runbooks tab — no need to paste the steps into chat.`);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const argv = process.argv.slice(2);
|
|
55
|
+
const sub = argv.shift();
|
|
56
|
+
|
|
57
|
+
if (sub === "add") {
|
|
58
|
+
let name = null, file = null;
|
|
59
|
+
for (let i = 0; i < argv.length; i++) {
|
|
60
|
+
if (argv[i] === "--name") name = argv[++i];
|
|
61
|
+
else if (!argv[i].startsWith("--")) file = argv[i];
|
|
62
|
+
}
|
|
63
|
+
if (!name || !file) fail('usage: bostrat-runbook add --name "<name>" <file.md>');
|
|
64
|
+
try {
|
|
65
|
+
const md = await fsp.readFile(file, "utf8").catch(() => fail(`cannot read ${file}`));
|
|
66
|
+
const { runbook } = await call("POST", "/api/runbooks", { name, md, cwd: process.cwd() });
|
|
67
|
+
report(runbook, "published");
|
|
68
|
+
} catch (e) {
|
|
69
|
+
fail(e.message);
|
|
70
|
+
}
|
|
71
|
+
} else if (sub === "update") {
|
|
72
|
+
let note = null;
|
|
73
|
+
const rest = [];
|
|
74
|
+
for (let i = 0; i < argv.length; i++) {
|
|
75
|
+
if (argv[i] === "--note") note = argv[++i];
|
|
76
|
+
else if (!argv[i].startsWith("--")) rest.push(argv[i]);
|
|
77
|
+
}
|
|
78
|
+
const [key, file] = rest;
|
|
79
|
+
if (!key || !file) fail('usage: bostrat-runbook update <name|id> [--note "<what changed>"] <file.md>');
|
|
80
|
+
try {
|
|
81
|
+
const target = await resolve(key);
|
|
82
|
+
const md = await fsp.readFile(file, "utf8").catch(() => fail(`cannot read ${file}`));
|
|
83
|
+
const { runbook } = await call("POST", `/api/runbooks/${target.id}/update`, { md, ...(note ? { note } : {}) });
|
|
84
|
+
report(runbook, "updated");
|
|
85
|
+
} catch (e) {
|
|
86
|
+
fail(e.message);
|
|
87
|
+
}
|
|
88
|
+
} else if (sub === "done" || sub === "reopen") {
|
|
89
|
+
const key = argv.find((a) => !a.startsWith("--"));
|
|
90
|
+
if (!key) fail(`usage: bostrat-runbook ${sub} <name|id>`);
|
|
91
|
+
try {
|
|
92
|
+
const target = await resolve(key);
|
|
93
|
+
const { runbook } = await call("POST", `/api/runbooks/${target.id}/status`, { status: sub === "done" ? "done" : "open" });
|
|
94
|
+
report(runbook, sub === "done" ? "marked done" : "reopened");
|
|
95
|
+
} catch (e) {
|
|
96
|
+
fail(e.message);
|
|
97
|
+
}
|
|
98
|
+
} else if (sub === "ls") {
|
|
99
|
+
// Default to THIS Track's runbooks (resolved from cwd); --all shows the fleet,
|
|
100
|
+
// including orphaned ones from deleted Tracks.
|
|
101
|
+
const all = argv.includes("--all");
|
|
102
|
+
try {
|
|
103
|
+
const query = all ? "" : `?cwd=${encodeURIComponent(process.cwd())}`;
|
|
104
|
+
const { runbooks } = await call("GET", `/api/runbooks${query}`);
|
|
105
|
+
if (!runbooks.length) console.log(all ? "no runbooks" : "no runbooks on this Track (try --all)");
|
|
106
|
+
for (const rb of runbooks) {
|
|
107
|
+
const ver = rb.version > 1 ? ` v${rb.version}` : "";
|
|
108
|
+
const orphan = rb.trackDeletedAt ? " [orphaned]" : "";
|
|
109
|
+
console.log(`${rb.status.padEnd(5)} ${rb.name}${ver}${orphan} (runbook:${rb.id})`);
|
|
110
|
+
}
|
|
111
|
+
} catch (e) {
|
|
112
|
+
fail(e.message);
|
|
113
|
+
}
|
|
114
|
+
} else if (sub === "rm") {
|
|
115
|
+
const key = argv[0];
|
|
116
|
+
if (!key) fail("usage: bostrat-runbook rm <name|id>");
|
|
117
|
+
try {
|
|
118
|
+
const rb = await resolve(key);
|
|
119
|
+
await call("DELETE", `/api/runbooks/${rb.id}`);
|
|
120
|
+
console.log(`[bostrat-runbook] deleted: ${rb.name}`);
|
|
121
|
+
} catch (e) {
|
|
122
|
+
fail(e.message);
|
|
123
|
+
}
|
|
124
|
+
} else {
|
|
125
|
+
fail('usage: bostrat-runbook <add|update|done|reopen|ls|rm> … (add --name "<name>" <file.md>; update <name|id> <file.md>)');
|
|
126
|
+
}
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// bostrat-ticket — the in-box CLI for linking a Track to its Linear/Jira issue
|
|
3
|
+
// (docs/ticketing-integration-epic.md §4). Talks to the on-box bostrat agent over
|
|
4
|
+
// loopback like its siblings (bostrat-view / bostrat-mock); the Track is resolved
|
|
5
|
+
// server-side from this process's cwd, and the TRACKER TOKEN NEVER ENTERS THE BOX —
|
|
6
|
+
// the agent makes the API calls.
|
|
7
|
+
//
|
|
8
|
+
// bostrat-ticket link <issue-url-or-KEY-123>
|
|
9
|
+
// bostrat-ticket status
|
|
10
|
+
// bostrat-ticket unlink
|
|
11
|
+
// bostrat-ticket flow # show automation modes
|
|
12
|
+
// bostrat-ticket flow set <phase>=<mode> [...] # e.g. flow set prMerged=auto
|
|
13
|
+
// bostrat-ticket propose [plan.json | -] # offer to CREATE issues/an epic
|
|
14
|
+
//
|
|
15
|
+
// `flow` is the PREPARED automation config (phases: linked, prOpened, prMerged,
|
|
16
|
+
// create; modes: prompt | auto | off — `create` is prompt|off only, never auto).
|
|
17
|
+
// Only these phases exist — if asked to automate or silence a ticket behavior that
|
|
18
|
+
// is not one of them, say it isn't configurable yet; do not improvise a workaround.
|
|
19
|
+
//
|
|
20
|
+
// `propose` is the outbound-authoring door: hand it a JSON plan (from a file or
|
|
21
|
+
// stdin) and bostrat posts a one-tap review card to the Track chat that CREATES a
|
|
22
|
+
// Linear epic (a Project) and/or issues once the operator confirms. Nothing is
|
|
23
|
+
// created until they tap. Plan shape:
|
|
24
|
+
// { "epic": {"name":"…","description":"…"}, // omit for standalone issues
|
|
25
|
+
// "issues": [{"title":"…","description":"…","priority":2}],
|
|
26
|
+
// "team": "ENG", // optional; else inferred
|
|
27
|
+
// "linkCurrentTrack": true } // link this Track to a lone new issue
|
|
28
|
+
|
|
29
|
+
const AGENT = process.env.BOSTRAT_AGENT_URL || `http://127.0.0.1:${process.env.BOSTRAT_AGENT_PORT || 3362}`;
|
|
30
|
+
|
|
31
|
+
async function call(method, apiPath, body) {
|
|
32
|
+
const r = await fetch(`${AGENT}${apiPath}`, {
|
|
33
|
+
method,
|
|
34
|
+
headers: body ? { "content-type": "application/json" } : undefined,
|
|
35
|
+
body: body ? JSON.stringify(body) : undefined,
|
|
36
|
+
});
|
|
37
|
+
const data = await r.json().catch(() => ({}));
|
|
38
|
+
if (!r.ok) throw new Error(data.error || `HTTP ${r.status}`);
|
|
39
|
+
return data;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function fail(msg) {
|
|
43
|
+
console.error(`[bostrat-ticket] ${msg}`);
|
|
44
|
+
process.exit(1);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const argv = process.argv.slice(2);
|
|
48
|
+
const sub = argv.shift();
|
|
49
|
+
|
|
50
|
+
if (sub === "link") {
|
|
51
|
+
const ref = argv[0];
|
|
52
|
+
if (!ref) fail("usage: bostrat-ticket link <issue-url-or-KEY-123>");
|
|
53
|
+
try {
|
|
54
|
+
const { ticket } = await call("POST", "/api/tickets/link", { cwd: process.cwd(), ref });
|
|
55
|
+
console.log(`[bostrat-ticket] linked ${ticket.key} — ${ticket.title}`);
|
|
56
|
+
console.log(`${ticket.url}`);
|
|
57
|
+
console.log("The issue now carries a live Bostrat card; PR opened/merged land as comments.");
|
|
58
|
+
} catch (e) {
|
|
59
|
+
fail(e.message);
|
|
60
|
+
}
|
|
61
|
+
} else if (sub === "status") {
|
|
62
|
+
try {
|
|
63
|
+
const { configured, ticket } = await call("GET", `/api/tickets/status?cwd=${encodeURIComponent(process.cwd())}`);
|
|
64
|
+
if (!configured) console.log("[bostrat-ticket] no ticketing integration configured (add a linear or jira entry to ~/.bostrat/integrations.json on the host)");
|
|
65
|
+
if (ticket) console.log(`[bostrat-ticket] linked: ${ticket.key} — ${ticket.title}\n${ticket.url}`);
|
|
66
|
+
else console.log("[bostrat-ticket] no ticket linked to this Track");
|
|
67
|
+
} catch (e) {
|
|
68
|
+
fail(e.message);
|
|
69
|
+
}
|
|
70
|
+
} else if (sub === "unlink") {
|
|
71
|
+
try {
|
|
72
|
+
const { trackId } = await call("GET", `/api/tickets/status?cwd=${encodeURIComponent(process.cwd())}`);
|
|
73
|
+
const { removed } = await call("DELETE", `/api/tracks/${trackId}/ticket`);
|
|
74
|
+
console.log(removed ? "[bostrat-ticket] unlinked" : "[bostrat-ticket] nothing was linked");
|
|
75
|
+
} catch (e) {
|
|
76
|
+
fail(e.message);
|
|
77
|
+
}
|
|
78
|
+
} else if (sub === "flow") {
|
|
79
|
+
const showFlow = (flow) => {
|
|
80
|
+
for (const [phase, mode] of Object.entries(flow)) console.log(` ${phase.padEnd(9)} ${mode}`);
|
|
81
|
+
};
|
|
82
|
+
if (argv[0] === "set") {
|
|
83
|
+
const set = {};
|
|
84
|
+
for (const arg of argv.slice(1)) {
|
|
85
|
+
const m = arg.match(/^([A-Za-z]+)=([A-Za-z]+)$/);
|
|
86
|
+
if (!m) fail(`bad assignment "${arg}" — usage: bostrat-ticket flow set <phase>=<mode> (phases: linked, prOpened, prMerged, create; modes: prompt, auto, off — create is prompt|off)`);
|
|
87
|
+
set[m[1]] = m[2];
|
|
88
|
+
}
|
|
89
|
+
if (!Object.keys(set).length) fail("usage: bostrat-ticket flow set <phase>=<mode> [...]");
|
|
90
|
+
try {
|
|
91
|
+
const { flow } = await call("POST", "/api/tickets/flow", { cwd: process.cwd(), set });
|
|
92
|
+
console.log("[bostrat-ticket] ticket automation updated:");
|
|
93
|
+
showFlow(flow);
|
|
94
|
+
console.log("(prompt = one-tap offer in chat · auto = bostrat moves the issue · off = nothing)");
|
|
95
|
+
} catch (e) {
|
|
96
|
+
fail(e.message);
|
|
97
|
+
}
|
|
98
|
+
} else if (!argv.length) {
|
|
99
|
+
try {
|
|
100
|
+
const { flow } = await call("GET", "/api/tickets/flow");
|
|
101
|
+
console.log("[bostrat-ticket] ticket automation (phases → modes):");
|
|
102
|
+
showFlow(flow);
|
|
103
|
+
} catch (e) {
|
|
104
|
+
fail(e.message);
|
|
105
|
+
}
|
|
106
|
+
} else {
|
|
107
|
+
fail("usage: bostrat-ticket flow [set <phase>=<mode> ...]");
|
|
108
|
+
}
|
|
109
|
+
} else if (sub === "propose") {
|
|
110
|
+
const src = argv[0];
|
|
111
|
+
const readStdin = async () => {
|
|
112
|
+
const chunks = [];
|
|
113
|
+
for await (const c of process.stdin) chunks.push(c);
|
|
114
|
+
return Buffer.concat(chunks).toString("utf8");
|
|
115
|
+
};
|
|
116
|
+
try {
|
|
117
|
+
const raw = !src || src === "-" ? await readStdin() : await (await import("node:fs/promises")).readFile(src, "utf8");
|
|
118
|
+
let plan;
|
|
119
|
+
try {
|
|
120
|
+
plan = JSON.parse(raw);
|
|
121
|
+
} catch {
|
|
122
|
+
fail("plan must be JSON (a file path, or piped on stdin) — see `bostrat-ticket` header for the shape");
|
|
123
|
+
}
|
|
124
|
+
const { proposal } = await call("POST", "/api/tickets/propose", { cwd: process.cwd(), plan });
|
|
125
|
+
const n = proposal.issues?.length ?? 0;
|
|
126
|
+
const bits = [];
|
|
127
|
+
if (proposal.epic) bits.push(`epic “${proposal.epic.name}”`);
|
|
128
|
+
if (n) bits.push(`${n} issue${n === 1 ? "" : "s"}`);
|
|
129
|
+
console.log(`[bostrat-ticket] proposed ${bits.join(" + ") || "nothing"} — review card posted to the Track chat.`);
|
|
130
|
+
if (proposal.needsTeam) console.log(" (no team resolved — the card will ask which team to create in)");
|
|
131
|
+
else if (proposal.team) console.log(` team: ${proposal.team.key} · nothing is created until you confirm.`);
|
|
132
|
+
} catch (e) {
|
|
133
|
+
fail(e.message);
|
|
134
|
+
}
|
|
135
|
+
} else {
|
|
136
|
+
fail("usage: bostrat-ticket <link|status|unlink|flow|propose> … (link <issue-url-or-KEY-123>)");
|
|
137
|
+
}
|
package/bin/bostrat-view
ADDED
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// bostrat-view — the in-box CLI agents use to publish a live preview (Views scope
|
|
3
|
+
// §5.1). Talks to the on-box bostrat agent over loopback (same pattern as the
|
|
4
|
+
// claude hook URL) and prints the PUBLIC URL — the link that works off-box —
|
|
5
|
+
// plus a stable `(view:<id>)` marker the chat projection can key on.
|
|
6
|
+
//
|
|
7
|
+
// bostrat-view up --name "Checkout preview" [--port 4310] -- npm run dev
|
|
8
|
+
// bostrat-view ls
|
|
9
|
+
// bostrat-view stop <name|slug|id>
|
|
10
|
+
// bostrat-view rm <name|slug|id>
|
|
11
|
+
//
|
|
12
|
+
// The Track is resolved server-side from this process's cwd (the box always runs
|
|
13
|
+
// inside its Track's workspace), so the CLI needs no configuration at all. That same
|
|
14
|
+
// cwd is where the serve command runs — in a monorepo, invoke this from the directory
|
|
15
|
+
// holding the app's package.json (e.g. client/), not the workspace root.
|
|
16
|
+
|
|
17
|
+
const AGENT = process.env.BOSTRAT_AGENT_URL || `http://127.0.0.1:${process.env.BOSTRAT_AGENT_PORT || 3362}`;
|
|
18
|
+
|
|
19
|
+
async function call(method, apiPath, body) {
|
|
20
|
+
const r = await fetch(`${AGENT}${apiPath}`, {
|
|
21
|
+
method,
|
|
22
|
+
headers: body ? { "content-type": "application/json" } : undefined,
|
|
23
|
+
body: body ? JSON.stringify(body) : undefined,
|
|
24
|
+
});
|
|
25
|
+
const data = await r.json().catch(() => ({}));
|
|
26
|
+
if (!r.ok) throw new Error(data.error || `HTTP ${r.status}`);
|
|
27
|
+
return data;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function fail(msg) {
|
|
31
|
+
console.error(`[bostrat-view] ${msg}`);
|
|
32
|
+
process.exit(1);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const argv = process.argv.slice(2);
|
|
36
|
+
const sub = argv.shift();
|
|
37
|
+
|
|
38
|
+
if (sub === "up") {
|
|
39
|
+
const dashdash = argv.indexOf("--");
|
|
40
|
+
if (dashdash === -1 || dashdash === argv.length - 1) {
|
|
41
|
+
fail('usage: bostrat-view up --name "<name>" [--port N] -- <serve command>');
|
|
42
|
+
}
|
|
43
|
+
const opts = argv.slice(0, dashdash);
|
|
44
|
+
const cmd = argv.slice(dashdash + 1).join(" ");
|
|
45
|
+
let name = null;
|
|
46
|
+
let port;
|
|
47
|
+
for (let i = 0; i < opts.length; i++) {
|
|
48
|
+
if (opts[i] === "--name") name = opts[++i];
|
|
49
|
+
else if (opts[i] === "--port") port = Number(opts[++i]);
|
|
50
|
+
}
|
|
51
|
+
if (!name) fail("--name is required");
|
|
52
|
+
try {
|
|
53
|
+
const { view } = await call("POST", "/api/views", { name, cmd, port, cwd: process.cwd() });
|
|
54
|
+
if (view.reused) {
|
|
55
|
+
console.log(`[bostrat-view] reusing existing view "${view.name}" — same URL, no new server needed`);
|
|
56
|
+
}
|
|
57
|
+
// Poll until live (or error) so the printed URL is real, not a race.
|
|
58
|
+
let v = view;
|
|
59
|
+
for (let i = 0; i < 60 && v.state === "starting"; i++) {
|
|
60
|
+
await new Promise((r) => setTimeout(r, 2000));
|
|
61
|
+
v = (await call("GET", `/api/views/${view.id}`)).view;
|
|
62
|
+
}
|
|
63
|
+
if (v.state === "error") fail(`server failed to start:\n${v.error || "(no output captured)"}`);
|
|
64
|
+
console.log(`[bostrat-view] ${v.state}: ${v.url} (view:${v.id})`);
|
|
65
|
+
// Print WHERE it runs: a view serving the wrong directory is otherwise invisible.
|
|
66
|
+
if (v.serveCwd) console.log(`[bostrat-view] running \`${v.cmd}\` in ${v.serveCwd}`);
|
|
67
|
+
console.log(`Share this URL — it works from the operator's phone. Do NOT share a localhost URL.`);
|
|
68
|
+
if (v.state === "starting") console.log("(still starting — the URL shows a holding page until it's up)");
|
|
69
|
+
} catch (e) {
|
|
70
|
+
fail(e.message);
|
|
71
|
+
}
|
|
72
|
+
} else if (sub === "ls") {
|
|
73
|
+
const { views } = await call("GET", "/api/views").catch((e) => fail(e.message));
|
|
74
|
+
if (!views.length) console.log("no views");
|
|
75
|
+
for (const v of views) {
|
|
76
|
+
console.log(`${v.state.padEnd(9)} ${v.name} — ${v.url} (view:${v.id}, port ${v.port})`);
|
|
77
|
+
if (v.cmd) console.log(`${" ".repeat(10)}\`${v.cmd}\` in ${v.serveCwd || v.cwd}`);
|
|
78
|
+
}
|
|
79
|
+
} else if (sub === "stop" || sub === "rm") {
|
|
80
|
+
const key = argv[0];
|
|
81
|
+
if (!key) fail(`usage: bostrat-view ${sub} <name|slug|id>`);
|
|
82
|
+
const { views } = await call("GET", "/api/views").catch((e) => fail(e.message));
|
|
83
|
+
const v = views.find((x) => x.id === key || x.slug === key || x.name === key);
|
|
84
|
+
if (!v) fail(`no view matching "${key}"`);
|
|
85
|
+
if (sub === "stop") {
|
|
86
|
+
await call("POST", `/api/views/${v.id}/stop`).catch((e) => fail(e.message));
|
|
87
|
+
console.log(`[bostrat-view] stopped: ${v.name}`);
|
|
88
|
+
} else {
|
|
89
|
+
// `stop` keeps the record, and createView's reuse match hits on slug OR exact cmd
|
|
90
|
+
// regardless of state — so a record with a bad field is sticky until it is removed.
|
|
91
|
+
await call("DELETE", `/api/views/${v.id}`).catch((e) => fail(e.message));
|
|
92
|
+
console.log(`[bostrat-view] removed: ${v.name}`);
|
|
93
|
+
}
|
|
94
|
+
} else {
|
|
95
|
+
fail('usage: bostrat-view <up|ls|stop|rm> … (up --name "<name>" -- <serve command>)');
|
|
96
|
+
}
|
package/bin/gh
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
#!/bin/sh
|
|
2
|
+
# gh shim — broker auth for agent-typed gh (github-node-provisioning-scope.md N4).
|
|
3
|
+
#
|
|
4
|
+
# Sits ahead of the real gh on the box PATH (~/.local/bin symlink, ensureViewCli).
|
|
5
|
+
# For the PR-surface command families (pr / run / api / issue / search) it asks
|
|
6
|
+
# the local agent loopback for a per-repo broker token derived from the cwd's
|
|
7
|
+
# origin remote and execs the REAL gh with GH_TOKEN set for THIS invocation only
|
|
8
|
+
# — in-memory, per-exec, nothing on disk (P1-D1). Everything else, an already-set
|
|
9
|
+
# token env, an uncovered repo, or an unreachable agent runs gh unchanged (the
|
|
10
|
+
# keyring fallback, until phase R). Actions under the broker are actored by the
|
|
11
|
+
# App bot (P1-D8) — prs.js adds the on-behalf-of breadcrumbs.
|
|
12
|
+
|
|
13
|
+
self="$(readlink -f "$0" 2>/dev/null || echo "$0")"
|
|
14
|
+
real=""
|
|
15
|
+
OLDIFS="$IFS"
|
|
16
|
+
IFS=:
|
|
17
|
+
for d in $PATH; do
|
|
18
|
+
c="${d:-.}/gh"
|
|
19
|
+
[ -x "$c" ] || continue
|
|
20
|
+
[ "$(readlink -f "$c" 2>/dev/null || echo "$c")" = "$self" ] && continue
|
|
21
|
+
real="$c"
|
|
22
|
+
break
|
|
23
|
+
done
|
|
24
|
+
IFS="$OLDIFS"
|
|
25
|
+
if [ -z "$real" ]; then
|
|
26
|
+
echo "gh: real gh binary not found behind the bostrat shim" >&2
|
|
27
|
+
exit 127
|
|
28
|
+
fi
|
|
29
|
+
|
|
30
|
+
case "$1" in
|
|
31
|
+
pr|run|api|issue|search) ;;
|
|
32
|
+
*) exec "$real" "$@" ;;
|
|
33
|
+
esac
|
|
34
|
+
if [ -n "$GH_TOKEN" ] || [ -n "$GITHUB_TOKEN" ]; then
|
|
35
|
+
exec "$real" "$@"
|
|
36
|
+
fi
|
|
37
|
+
|
|
38
|
+
origin="$(git config --get remote.origin.url 2>/dev/null)"
|
|
39
|
+
slug="$(printf '%s' "$origin" | sed -nE 's#.*github\.com[:/]+##p' | sed -E 's#\.git$##; s#/+$##')"
|
|
40
|
+
case "$slug" in
|
|
41
|
+
*/*) ;;
|
|
42
|
+
*) exec "$real" "$@" ;;
|
|
43
|
+
esac
|
|
44
|
+
|
|
45
|
+
agent_url="${BOSTRAT_AGENT_URL:-http://127.0.0.1:3362}"
|
|
46
|
+
token="$(curl -fsS --max-time 10 "$agent_url/api/github/token?repo=$slug" 2>/dev/null \
|
|
47
|
+
| sed -nE 's/.*"token":"([^"]+)".*/\1/p')"
|
|
48
|
+
if [ -n "$token" ]; then
|
|
49
|
+
GH_TOKEN="$token" exec "$real" "$@"
|
|
50
|
+
fi
|
|
51
|
+
exec "$real" "$@"
|