@cldmv/gh-broker 0.0.0-stage → 0.8.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.
Files changed (104) hide show
  1. package/LICENSE +202 -0
  2. package/README.md +279 -2
  3. package/config.example.json +294 -0
  4. package/config.sample.json +45 -0
  5. package/deploy/README.md +150 -0
  6. package/deploy/gh-broker.service +57 -0
  7. package/deploy/install.mjs +409 -0
  8. package/deploy/install.sh +29 -0
  9. package/deploy/lib/config-merge.mjs +214 -0
  10. package/deploy/lib/credentials.mjs +143 -0
  11. package/deploy/lib/install-args.mjs +144 -0
  12. package/deploy/lib/npm-setup.mjs +164 -0
  13. package/deploy/lib/prompt.mjs +98 -0
  14. package/deploy/lib/secret-store.mjs +132 -0
  15. package/deploy/lib/smoke.mjs +137 -0
  16. package/deploy/lib/system.mjs +246 -0
  17. package/dist/index.mjs +24406 -0
  18. package/dist/index.mjs.map +7 -0
  19. package/docs/TOOLS.md +538 -0
  20. package/docs/agent-user-model.md +89 -0
  21. package/docs/changelog/v0/v0.2.0.md +26 -0
  22. package/docs/changelog/v0/v0.2.1.md +23 -0
  23. package/docs/changelog/v0/v0.2.2.md +23 -0
  24. package/docs/changelog/v0/v0.2.3.md +33 -0
  25. package/docs/changelog/v0/v0.2.4.md +25 -0
  26. package/docs/changelog/v0/v0.2.5.md +29 -0
  27. package/docs/changelog/v0/v0.3.0.md +84 -0
  28. package/docs/changelog/v0/v0.3.1.md +30 -0
  29. package/docs/changelog/v0/v0.3.2.md +24 -0
  30. package/docs/changelog/v0/v0.3.3.md +28 -0
  31. package/docs/changelog/v0/v0.4.0.md +58 -0
  32. package/docs/changelog/v0/v0.4.1.md +33 -0
  33. package/docs/changelog/v0/v0.4.2.md +45 -0
  34. package/docs/changelog/v0/v0.5.0.md +39 -0
  35. package/docs/changelog/v0/v0.5.1.md +28 -0
  36. package/docs/changelog/v0/v0.6.0.md +52 -0
  37. package/docs/changelog/v0/v0.6.1.md +48 -0
  38. package/docs/changelog/v0/v0.7.0.md +71 -0
  39. package/docs/changelog/v0/v0.8.0.md +115 -0
  40. package/docs/deploy-architecture.md +84 -0
  41. package/package.json +71 -5
  42. package/src/appauth.ts +56 -0
  43. package/src/audit.ts +34 -0
  44. package/src/availability.ts +42 -0
  45. package/src/catalog.ts +182 -0
  46. package/src/config.ts +157 -0
  47. package/src/index.ts +107 -0
  48. package/src/paths.ts +60 -0
  49. package/src/router.ts +299 -0
  50. package/src/secrets.ts +167 -0
  51. package/src/server.ts +91 -0
  52. package/src/types.ts +128 -0
  53. package/src/upstream.ts +85 -0
  54. package/src/verbs/ghcr/archive.ts +314 -0
  55. package/src/verbs/ghcr/config.ts +142 -0
  56. package/src/verbs/ghcr/jobs.ts +141 -0
  57. package/src/verbs/ghcr/push.ts +192 -0
  58. package/src/verbs/ghcr/registry.ts +275 -0
  59. package/src/verbs/ghcr/tar.ts +203 -0
  60. package/src/verbs/ghcr_push.ts +264 -0
  61. package/src/verbs/issue_labels.ts +329 -0
  62. package/src/verbs/link_issue.ts +84 -0
  63. package/src/verbs/npm-registry.ts +168 -0
  64. package/src/verbs/npm-tarball.ts +76 -0
  65. package/src/verbs/npm_scaffold.ts +325 -0
  66. package/src/verbs/release-analysis.ts +212 -0
  67. package/src/verbs/repo-tier.ts +90 -0
  68. package/src/verbs/report_ci_status.ts +358 -0
  69. package/src/verbs/report_release_prs.ts +238 -0
  70. package/src/verbs/report_security_alerts.ts +442 -0
  71. package/src/verbs/report_unlabeled.ts +194 -0
  72. package/src/verbs/rest-client.ts +181 -0
  73. package/src/verbs/review_thread.ts +138 -0
  74. package/tests/appauth.test.vitest.mts +76 -0
  75. package/tests/audit.test.vitest.mts +48 -0
  76. package/tests/catalog.test.vitest.mts +234 -0
  77. package/tests/config-merge.test.vitest.mts +325 -0
  78. package/tests/config.test.vitest.mts +388 -0
  79. package/tests/credentials.test.vitest.mts +127 -0
  80. package/tests/ghcr-archive.test.vitest.mts +439 -0
  81. package/tests/ghcr-config.test.vitest.mts +96 -0
  82. package/tests/ghcr-push.test.vitest.mts +526 -0
  83. package/tests/ghcr-registry.test.vitest.mts +206 -0
  84. package/tests/helpers/ghcr-fixtures.mts +373 -0
  85. package/tests/install-args.test.vitest.mts +152 -0
  86. package/tests/issue-labels.test.vitest.mts +362 -0
  87. package/tests/npm-scaffold.test.vitest.mts +479 -0
  88. package/tests/npm-setup.test.vitest.mts +196 -0
  89. package/tests/paths.test.vitest.mts +57 -0
  90. package/tests/prompt.test.vitest.mts +128 -0
  91. package/tests/release-prs.test.vitest.mts +247 -0
  92. package/tests/rest-client.test.vitest.mts +115 -0
  93. package/tests/router.test.vitest.mts +505 -0
  94. package/tests/run-vitest.mjs +51 -0
  95. package/tests/secret-store.test.vitest.mts +167 -0
  96. package/tests/secrets.test.vitest.mts +208 -0
  97. package/tests/security-alerts.test.vitest.mts +338 -0
  98. package/tests/server.test.vitest.mts +200 -0
  99. package/tests/smoke.test.vitest.mts +177 -0
  100. package/tests/system.test.vitest.mts +355 -0
  101. package/tests/upstream.test.vitest.mts +91 -0
  102. package/tests/verb-helpers.test.vitest.mts +377 -0
  103. package/tests/verbs.test.vitest.mts +624 -0
  104. package/tsconfig.json +21 -0
@@ -0,0 +1,137 @@
1
+ /**
2
+ *
3
+ * @Project: @cldmv/gh-broker
4
+ * @Filename: /deploy/lib/smoke.mjs
5
+ * @Date: 2026-09-06T10:05:35-07:00 (1788714335)
6
+ * @Author: Nate Corcoran <CLDMV>
7
+ * @Email: <Shinrai@users.noreply.github.com>
8
+ * -----
9
+ * @Last modified by: Nate Corcoran <CLDMV> (Shinrai@users.noreply.github.com)
10
+ * @Last modified time: 2026-10-02T11:30:58-07:00 (1790965858)
11
+ * -----
12
+ * @Copyright: Copyright (c) 2013-2026 Catalyzed Motivation Inc. All rights reserved.
13
+ *
14
+ */
15
+
16
+ import { connect } from "node:net";
17
+
18
+ /**
19
+ * Post-deploy smoke test: connect to the broker's Unix socket, do the MCP
20
+ * handshake, list tools, and — if a safe owner-less read (`get_me`) is
21
+ * advertised — make one real call so a GitHub round-trip is actually exercised.
22
+ * Resolves with { tools, roundTrip } or rejects with a clear reason.
23
+ *
24
+ * The daemon does a network `buildCatalog` before it creates the socket, and
25
+ * `systemctl restart` returns as soon as the process spawns — so the socket may
26
+ * not exist yet. We therefore RETRY the connection (ENOENT/ECONNREFUSED) until
27
+ * the deadline rather than failing on the first attempt.
28
+ *
29
+ * The broker speaks newline-delimited JSON-RPC (MCP stdio framing) over the
30
+ * socket, so this needs no dependency — raw net + JSON lines.
31
+ */
32
+ function connectWithRetry(socketPath, deadline) {
33
+ return new Promise((resolve, reject) => {
34
+ const attempt = () => {
35
+ const sock = connect(socketPath);
36
+ let settled = false;
37
+ sock.once("connect", () => {
38
+ settled = true;
39
+ resolve(sock);
40
+ });
41
+ sock.once("error", (e) => {
42
+ if (settled) return;
43
+ sock.destroy();
44
+ if (Date.now() < deadline && (e.code === "ENOENT" || e.code === "ECONNREFUSED")) {
45
+ setTimeout(attempt, 250);
46
+ } else {
47
+ reject(new Error(`smoke: socket error (${socketPath}): ${e.message}`));
48
+ }
49
+ });
50
+ };
51
+ attempt();
52
+ });
53
+ }
54
+
55
+ function handshake(sock, deadline) {
56
+ return new Promise((resolve, reject) => {
57
+ let buf = "";
58
+ let idc = 0;
59
+ const pending = new Map();
60
+ const timer = setTimeout(() => {
61
+ cleanup();
62
+ reject(new Error(`smoke: no response before deadline`));
63
+ }, Math.max(1000, deadline - Date.now()));
64
+
65
+ const cleanup = () => {
66
+ clearTimeout(timer);
67
+ sock.destroy();
68
+ };
69
+ const send = (method, params) =>
70
+ new Promise((res, rej) => {
71
+ const id = ++idc;
72
+ pending.set(id, { res, rej });
73
+ sock.write(JSON.stringify({ jsonrpc: "2.0", id, method, params }) + "\n");
74
+ });
75
+ const notify = (method) => sock.write(JSON.stringify({ jsonrpc: "2.0", method }) + "\n");
76
+
77
+ sock.on("error", (e) => {
78
+ cleanup();
79
+ reject(new Error(`smoke: socket error: ${e.message}`));
80
+ });
81
+ sock.on("data", (d) => {
82
+ buf += d.toString("utf8");
83
+ let nl;
84
+ while ((nl = buf.indexOf("\n")) >= 0) {
85
+ const line = buf.slice(0, nl);
86
+ buf = buf.slice(nl + 1);
87
+ if (!line.trim()) continue;
88
+ let msg;
89
+ try {
90
+ msg = JSON.parse(line);
91
+ } catch {
92
+ continue;
93
+ }
94
+ if (msg.id != null && pending.has(msg.id)) {
95
+ const { res, rej } = pending.get(msg.id);
96
+ pending.delete(msg.id);
97
+ if (msg.error) rej(new Error(msg.error.message || "rpc error"));
98
+ else res(msg.result);
99
+ }
100
+ }
101
+ });
102
+
103
+ (async () => {
104
+ try {
105
+ await send("initialize", {
106
+ protocolVersion: "2024-11-05",
107
+ capabilities: {},
108
+ clientInfo: { name: "gh-broker-smoke", version: "1.0" }
109
+ });
110
+ notify("notifications/initialized");
111
+ const listed = await send("tools/list", {});
112
+ const tools = (listed?.tools ?? []).map((t) => t.name);
113
+
114
+ let roundTrip = null;
115
+ if (tools.includes("get_me")) {
116
+ try {
117
+ const out = await send("tools/call", { name: "get_me", arguments: {} });
118
+ roundTrip = out?.isError ? "get_me returned an error" : "get_me ok";
119
+ } catch (e) {
120
+ roundTrip = `get_me failed: ${e.message}`;
121
+ }
122
+ }
123
+ cleanup();
124
+ resolve({ tools, roundTrip });
125
+ } catch (e) {
126
+ cleanup();
127
+ reject(e);
128
+ }
129
+ })();
130
+ });
131
+ }
132
+
133
+ export async function smokeTest(socketPath, { timeoutMs = 15000 } = {}) {
134
+ const deadline = Date.now() + timeoutMs;
135
+ const sock = await connectWithRetry(socketPath, deadline);
136
+ return handshake(sock, deadline);
137
+ }
@@ -0,0 +1,246 @@
1
+ /**
2
+ *
3
+ * @Project: @cldmv/gh-broker
4
+ * @Filename: /deploy/lib/system.mjs
5
+ * @Date: 2026-09-06T10:05:35-07:00 (1788714335)
6
+ * @Author: Nate Corcoran <CLDMV>
7
+ * @Email: <Shinrai@users.noreply.github.com>
8
+ * -----
9
+ * @Last modified by: Nate Corcoran <CLDMV> (Shinrai@users.noreply.github.com)
10
+ * @Last modified time: 2026-10-31T23:26:29-07:00 (1793514389)
11
+ * -----
12
+ * @Copyright: Copyright (c) 2013-2026 Catalyzed Motivation Inc. All rights reserved.
13
+ *
14
+ */
15
+
16
+ import { spawnSync } from "node:child_process";
17
+ import { existsSync, mkdirSync, chmodSync, chownSync, lstatSync, writeFileSync, cpSync, rmSync, symlinkSync, realpathSync } from "node:fs";
18
+ import { join, dirname } from "node:path";
19
+
20
+ /** spawnSync wrapper: throws on non-zero unless opts.allowFail. Returns the result. */
21
+ export function run(cmd, args = [], opts = {}) {
22
+ const r = spawnSync(cmd, args, { encoding: "utf8", ...opts });
23
+ if (r.error) throw new Error(`${cmd}: ${r.error.message}`);
24
+ if (r.status !== 0 && !opts.allowFail) {
25
+ throw new Error(`${cmd} ${args.join(" ")} exited ${r.status}: ${(r.stderr || r.stdout || "").trim()}`);
26
+ }
27
+ return r;
28
+ }
29
+
30
+ export function has(cmd) {
31
+ return spawnSync("sh", ["-c", `command -v ${cmd} >/dev/null 2>&1`]).status === 0;
32
+ }
33
+ export function isRoot() {
34
+ return typeof process.getuid === "function" && process.getuid() === 0;
35
+ }
36
+
37
+ export function userExists(name) {
38
+ return run("id", [name], { allowFail: true, stdio: "ignore" }).status === 0;
39
+ }
40
+ export function groupExists(name) {
41
+ return run("getent", ["group", name], { allowFail: true }).status === 0;
42
+ }
43
+ export function gidOf(name) {
44
+ const r = run("getent", ["group", name], { allowFail: true });
45
+ if (r.status !== 0) return undefined;
46
+ const gid = Number(r.stdout.split(":")[2]);
47
+ return Number.isFinite(gid) ? gid : undefined;
48
+ }
49
+ export function uidOf(name) {
50
+ const r = run("id", ["-u", name], { allowFail: true });
51
+ if (r.status !== 0) return undefined;
52
+ const uid = Number(r.stdout.trim());
53
+ return Number.isFinite(uid) ? uid : undefined;
54
+ }
55
+
56
+ /**
57
+ * gidOf/uidOf that throw a clear error when the name can't be resolved. Used
58
+ * wherever ownership is set, so a broken getent/id lookup fails fast instead of
59
+ * silently leaving a path with the wrong owner/group (or running chown NaN:NaN).
60
+ */
61
+ export function requireGid(name) {
62
+ const gid = gidOf(name);
63
+ if (gid === undefined) throw new Error(`gh-broker install: cannot resolve gid for group "${name}"`);
64
+ return gid;
65
+ }
66
+ export function requireUid(name) {
67
+ const uid = uidOf(name);
68
+ if (uid === undefined) throw new Error(`gh-broker install: cannot resolve uid for user "${name}"`);
69
+ return uid;
70
+ }
71
+
72
+ export function ensureGroup(name) {
73
+ if (!groupExists(name)) run("groupadd", ["--system", name]);
74
+ }
75
+ export function addUserToGroup(user, group) {
76
+ run("usermod", ["--append", "--groups", group, user]);
77
+ }
78
+
79
+ /** Create the dedicated service user + its group + the socket bridge group (idempotent). */
80
+ export function ensureServiceUser(svcUser, svcGroup, clientsGroup) {
81
+ ensureGroup(svcGroup);
82
+ ensureGroup(clientsGroup);
83
+ if (!userExists(svcUser)) {
84
+ run("useradd", [
85
+ "--system",
86
+ "--gid", svcGroup,
87
+ "--groups", clientsGroup,
88
+ "--home-dir", "/nonexistent",
89
+ "--shell", "/usr/sbin/nologin",
90
+ "--comment", "CLDMV gh-broker service",
91
+ svcUser
92
+ ]);
93
+ } else {
94
+ addUserToGroup(svcUser, clientsGroup);
95
+ }
96
+ }
97
+
98
+ /** Create a plain unprivileged agent user with a home + login shell, and no sudo. */
99
+ export function createAgentUser(name) {
100
+ run("useradd", ["--create-home", "--shell", "/bin/bash", name]);
101
+ }
102
+
103
+ /**
104
+ * Migrate a token from the legacy systemd-creds store: decrypt the host-bound
105
+ * `<name>.cred` (as root, same host) into plaintext for the new secrets file.
106
+ * Returns the token, or null if there's nothing to migrate (file absent, no
107
+ * systemd-creds, wrong host/name → can't decrypt). Never logs the value.
108
+ */
109
+ export function trySystemdCredDecrypt(name, credFile) {
110
+ if (!existsSync(credFile) || !has("systemd-creds")) return null;
111
+ try {
112
+ const r = run("systemd-creds", ["decrypt", `--name=${name}`, credFile, "-"], { allowFail: true });
113
+ if (r.status !== 0) return null;
114
+ const val = r.stdout.replace(/\r?\n$/, "");
115
+ return val || null;
116
+ } catch {
117
+ return null;
118
+ }
119
+ }
120
+
121
+ /** Encrypt a token into a host-bound `.cred` (systemd-creds), 0400 root:root. */
122
+ export function systemdCredsEncrypt(name, plaintext, credFile) {
123
+ run("systemd-creds", ["encrypt", `--name=${name}`, "-", credFile], { input: plaintext });
124
+ chmodSync(credFile, 0o400);
125
+ chownSync(credFile, 0, 0);
126
+ }
127
+
128
+ /** Ensure the creds dir exists (0750 root:group) for the systemd-creds provider. */
129
+ export function ensureCredsDir(configDir, svcGroup) {
130
+ mkdirp(join(configDir, "creds"), 0o750, 0, requireGid(svcGroup));
131
+ }
132
+
133
+ /** Write a unit drop-in with LoadCredentialEncrypted lines for the given creds, and reload. */
134
+ export function writeCredsDropin(names, credsDir, unitName = "gh-broker.service") {
135
+ const dir = `/etc/systemd/system/${unitName}.d`;
136
+ mkdirp(dir, 0o755, 0, 0);
137
+ const lines = ["[Service]", ...names.map((n) => `LoadCredentialEncrypted=${n}:${join(credsDir, `${n}.cred`)}`), ""];
138
+ writeFileSync(join(dir, "creds.conf"), lines.join("\n"), { mode: 0o644 });
139
+ run("systemctl", ["daemon-reload"]);
140
+ }
141
+
142
+ /** Remove the creds drop-in (used when switching to the file provider), and reload. */
143
+ export function removeCredsDropin(unitName = "gh-broker.service") {
144
+ const f = `/etc/systemd/system/${unitName}.d/creds.conf`;
145
+ if (existsSync(f)) {
146
+ rmSync(f, { force: true });
147
+ run("systemctl", ["daemon-reload"]);
148
+ }
149
+ }
150
+
151
+ /** Point /usr/local/bin/{node,npm,npx} at the given node binary's dir (the single indirection). */
152
+ export function pointNodeSymlinks(nodeBin) {
153
+ const binDir = dirname(nodeBin);
154
+ const linked = [];
155
+ for (const b of ["node", "npm", "npx"]) {
156
+ const src = join(binDir, b);
157
+ if (existsSync(src)) {
158
+ rmSync(`/usr/local/bin/${b}`, { force: true });
159
+ symlinkSync(src, `/usr/local/bin/${b}`);
160
+ linked.push(b);
161
+ }
162
+ }
163
+ return linked;
164
+ }
165
+
166
+ /** The real path of the node running this installer — the node the service will use. */
167
+ export function currentNodeBin() {
168
+ return realpathSync(process.execPath);
169
+ }
170
+
171
+ function mkdirp(path, mode, uid, gid) {
172
+ if (!existsSync(path)) mkdirSync(path, { recursive: true, mode });
173
+ chmodSync(path, mode);
174
+ // Every caller passes a resolved uid and gid (requireUid/requireGid throw otherwise), so the skipped-chown branch is unreachable.
175
+ /* v8 ignore else */
176
+ if (uid !== undefined && gid !== undefined) chownSync(path, uid, gid);
177
+ }
178
+
179
+ /**
180
+ * Create the GHCR drop directory: `2750 <agentUser>:<svcGroup>`. The agent (owner) writes
181
+ * archives there; the setgid bit gives every new file the service group, so the broker can
182
+ * read a group-readable archive while other users can't see in. A missing parent is created
183
+ * `0755 root:root`; an existing parent is never chmod-ed. Refuses a symlink at the path.
184
+ */
185
+ export function ensureDropDir(path, agentUser, svcGroup) {
186
+ const parent = dirname(path);
187
+ if (!existsSync(parent)) mkdirSync(parent, { recursive: true, mode: 0o755 });
188
+ let st;
189
+ try {
190
+ st = lstatSync(path); // lstat: a symlink (even a dangling one) is seen as itself
191
+ } catch {
192
+ st = undefined;
193
+ }
194
+ if (!st) mkdirSync(path, { mode: 0o750 });
195
+ else if (st.isSymbolicLink()) throw new Error(`gh-broker install: ${path} is a symlink — refusing to use it as the GHCR drop dir`);
196
+ else if (!st.isDirectory()) throw new Error(`gh-broker install: ${path} exists and is not a directory`);
197
+ chownSync(path, requireUid(agentUser), requireGid(svcGroup));
198
+ chmodSync(path, 0o2750); // after chown: chown clears the setgid bit
199
+ }
200
+
201
+ /** Create /etc/gh-broker (0750 root:group) and /opt/gh-broker (0755 svcUser:group). */
202
+ export function ensureDirs(configDir, appDir, svcUser, svcGroup) {
203
+ const gid = requireGid(svcGroup);
204
+ const uid = requireUid(svcUser);
205
+ mkdirp(configDir, 0o750, 0, gid);
206
+ mkdirp(appDir, 0o755, uid, gid);
207
+ }
208
+
209
+ /** Install the systemd unit and reload. */
210
+ export function installUnit(srcPath, unitPath = "/etc/systemd/system/gh-broker.service") {
211
+ cpSync(srcPath, unitPath);
212
+ chmodSync(unitPath, 0o644);
213
+ run("systemctl", ["daemon-reload"]);
214
+ }
215
+ export function enableService() {
216
+ run("systemctl", ["enable", "gh-broker.service"], { allowFail: true });
217
+ }
218
+ export function restartService() {
219
+ run("systemctl", ["restart", "gh-broker.service"]);
220
+ }
221
+ export function serviceActive() {
222
+ return run("systemctl", ["is-active", "gh-broker.service"], { allowFail: true }).stdout.trim();
223
+ }
224
+
225
+ /** Sync the built dist/ into the app dir and chown it to the service user. */
226
+ export function deployDist(srcDir, destDir, svcUser, svcGroup) {
227
+ const gid = requireGid(svcGroup);
228
+ const uid = requireUid(svcUser);
229
+ mkdirp(destDir, 0o755, uid, gid);
230
+ const dist = join(destDir, "dist");
231
+ if (has("rsync")) {
232
+ run("rsync", ["-a", "--delete", `${srcDir}/`, `${dist}/`]);
233
+ } else {
234
+ rmSync(dist, { recursive: true, force: true });
235
+ cpSync(srcDir, dist, { recursive: true });
236
+ }
237
+ run("chown", ["-R", `${uid}:${gid}`, destDir]);
238
+ }
239
+
240
+ /** Write the token-free config, 0640 root:group. */
241
+ export function writeConfigFile(path, obj, svcGroup) {
242
+ const gid = requireGid(svcGroup);
243
+ writeFileSync(path, JSON.stringify(obj, null, "\t") + "\n", { mode: 0o640 });
244
+ chmodSync(path, 0o640);
245
+ chownSync(path, 0, gid);
246
+ }