@cldmv/gh-broker 0.0.0-stage → 0.7.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 (107) hide show
  1. package/.configs/fix-headers.json +3 -0
  2. package/.configs/vitest.config.mjs +44 -0
  3. package/.github/dependabot.yml +111 -0
  4. package/.github/workflows/branch-retention.yml +44 -0
  5. package/.github/workflows/bundle-size.yml +52 -0
  6. package/.github/workflows/ci.yml +389 -0
  7. package/.github/workflows/cla.yml +66 -0
  8. package/.github/workflows/codeql.yml +87 -0
  9. package/.github/workflows/dependabot-auto-merge.yml +62 -0
  10. package/.github/workflows/dependabot-recreate.yml +67 -0
  11. package/.github/workflows/dependency-review.yml +46 -0
  12. package/.github/workflows/feature-pr.yml +75 -0
  13. package/.github/workflows/hotfix-redirector.yml +67 -0
  14. package/.github/workflows/hotfixes-release.yml +70 -0
  15. package/.github/workflows/labeler.yml +48 -0
  16. package/.github/workflows/master-commit-audit.yml +56 -0
  17. package/.github/workflows/member-auto-merge.yml +75 -0
  18. package/.github/workflows/next-release.yml +76 -0
  19. package/.github/workflows/next-reset.yml +50 -0
  20. package/.github/workflows/pr-notify.yml +44 -0
  21. package/.github/workflows/pr-title-normalizer.yml +52 -0
  22. package/.github/workflows/provenance.yml +51 -0
  23. package/.github/workflows/publish.yml +146 -0
  24. package/.github/workflows/release-merge.yml +104 -0
  25. package/.github/workflows/release-notify.yml +41 -0
  26. package/.github/workflows/scorecard.yml +64 -0
  27. package/.github/workflows/stale.yml +50 -0
  28. package/.github/workflows/tag-health.yml +70 -0
  29. package/.github/workflows/update-major-version-tags.yml +94 -0
  30. package/.github/workflows/v4-bootstrap.yml +115 -0
  31. package/.github/workflows/welcome.yml +42 -0
  32. package/LICENSE +202 -0
  33. package/README.md +185 -2
  34. package/config.example.json +281 -0
  35. package/deploy/README.md +92 -0
  36. package/deploy/gh-broker.service +57 -0
  37. package/deploy/install.mjs +344 -0
  38. package/deploy/install.sh +29 -0
  39. package/deploy/lib/config-merge.mjs +56 -0
  40. package/deploy/lib/prompt.mjs +98 -0
  41. package/deploy/lib/secret-store.mjs +114 -0
  42. package/deploy/lib/smoke.mjs +137 -0
  43. package/deploy/lib/system.mjs +222 -0
  44. package/dist/index.mjs +22970 -0
  45. package/dist/index.mjs.map +7 -0
  46. package/docs/TOOLS.md +347 -0
  47. package/docs/agent-user-model.md +89 -0
  48. package/docs/changelog/v0/v0.2.0.md +26 -0
  49. package/docs/changelog/v0/v0.2.1.md +23 -0
  50. package/docs/changelog/v0/v0.2.2.md +23 -0
  51. package/docs/changelog/v0/v0.2.3.md +33 -0
  52. package/docs/changelog/v0/v0.2.4.md +25 -0
  53. package/docs/changelog/v0/v0.2.5.md +29 -0
  54. package/docs/changelog/v0/v0.3.0.md +84 -0
  55. package/docs/changelog/v0/v0.3.1.md +30 -0
  56. package/docs/changelog/v0/v0.3.2.md +24 -0
  57. package/docs/changelog/v0/v0.3.3.md +28 -0
  58. package/docs/changelog/v0/v0.4.0.md +58 -0
  59. package/docs/changelog/v0/v0.4.1.md +33 -0
  60. package/docs/changelog/v0/v0.4.2.md +45 -0
  61. package/docs/changelog/v0/v0.5.0.md +39 -0
  62. package/docs/changelog/v0/v0.5.1.md +28 -0
  63. package/docs/changelog/v0/v0.6.0.md +52 -0
  64. package/docs/changelog/v0/v0.6.1.md +48 -0
  65. package/docs/changelog/v0/v0.7.0.md +71 -0
  66. package/docs/deploy-architecture.md +82 -0
  67. package/package.json +71 -5
  68. package/src/appauth.ts +56 -0
  69. package/src/audit.ts +34 -0
  70. package/src/catalog.ts +165 -0
  71. package/src/config.ts +130 -0
  72. package/src/index.ts +107 -0
  73. package/src/paths.ts +60 -0
  74. package/src/router.ts +258 -0
  75. package/src/secrets.ts +137 -0
  76. package/src/server.ts +91 -0
  77. package/src/types.ts +104 -0
  78. package/src/upstream.ts +85 -0
  79. package/src/verbs/issue_labels.ts +329 -0
  80. package/src/verbs/link_issue.ts +84 -0
  81. package/src/verbs/release-analysis.ts +210 -0
  82. package/src/verbs/repo-tier.ts +90 -0
  83. package/src/verbs/report_ci_status.ts +358 -0
  84. package/src/verbs/report_release_prs.ts +238 -0
  85. package/src/verbs/report_security_alerts.ts +442 -0
  86. package/src/verbs/report_unlabeled.ts +194 -0
  87. package/src/verbs/rest-client.ts +181 -0
  88. package/src/verbs/review_thread.ts +138 -0
  89. package/tests/appauth.test.vitest.mts +76 -0
  90. package/tests/audit.test.vitest.mts +48 -0
  91. package/tests/catalog.test.vitest.mts +180 -0
  92. package/tests/config-merge.test.vitest.mts +58 -0
  93. package/tests/config.test.vitest.mts +314 -0
  94. package/tests/issue-labels.test.vitest.mts +347 -0
  95. package/tests/paths.test.vitest.mts +52 -0
  96. package/tests/release-prs.test.vitest.mts +237 -0
  97. package/tests/rest-client.test.vitest.mts +115 -0
  98. package/tests/router.test.vitest.mts +399 -0
  99. package/tests/run-vitest.mjs +51 -0
  100. package/tests/secret-store.test.vitest.mts +106 -0
  101. package/tests/secrets.test.vitest.mts +155 -0
  102. package/tests/security-alerts.test.vitest.mts +328 -0
  103. package/tests/server.test.vitest.mts +20 -0
  104. package/tests/upstream.test.vitest.mts +91 -0
  105. package/tests/verb-helpers.test.vitest.mts +331 -0
  106. package/tests/verbs.test.vitest.mts +624 -0
  107. package/tsconfig.json +21 -0
@@ -0,0 +1,344 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ *
4
+ * @Project: @cldmv/gh-broker
5
+ * @Filename: /deploy/install.mjs
6
+ * @Date: 2026-09-06T10:05:35-07:00 (1788714335)
7
+ * @Author: Nate Corcoran <CLDMV>
8
+ * @Email: <Shinrai@users.noreply.github.com>
9
+ * -----
10
+ * @Last modified by: Nate Corcoran <CLDMV> (Shinrai@users.noreply.github.com)
11
+ * @Last modified time: 2026-10-02T11:30:58-07:00 (1790965858)
12
+ * -----
13
+ * @Copyright: Copyright (c) 2013-2026 Catalyzed Motivation Inc. All rights reserved.
14
+ *
15
+ */
16
+
17
+ /**
18
+ * gh-broker installer — one command for first install AND upgrade.
19
+ *
20
+ * sudo node deploy/install.mjs [agent-user] [flags]
21
+ *
22
+ * Prerequisite: node >= 20. Idempotent: re-run to upgrade (config regenerated by
23
+ * merge, existing secrets kept). Interactive by default; pass flags plus
24
+ * --non-interactive for a single unattended command. See --help and
25
+ * docs/deploy-architecture.md.
26
+ */
27
+ import { readFileSync, readdirSync, existsSync, statSync, rmSync } from "node:fs";
28
+ import { fileURLToPath } from "node:url";
29
+ import { dirname, join } from "node:path";
30
+ import {
31
+ run, has, isRoot, userExists, requireGid,
32
+ ensureServiceUser, createAgentUser, addUserToGroup,
33
+ pointNodeSymlinks, currentNodeBin, ensureDirs, installUnit,
34
+ enableService, restartService, serviceActive, deployDist, writeConfigFile,
35
+ trySystemdCredDecrypt, systemdCredsEncrypt, ensureCredsDir, writeCredsDropin, removeCredsDropin
36
+ } from "./lib/system.mjs";
37
+ import { promptLine, promptHidden, confirm, choose } from "./lib/prompt.mjs";
38
+ import { mergeConfig } from "./lib/config-merge.mjs";
39
+ import { writeSecretsFile, readSecretsFile, removeSecretsFile, referencedCredNames, detectSecretsProvider } from "./lib/secret-store.mjs";
40
+ import { smokeTest } from "./lib/smoke.mjs";
41
+
42
+ const REPO = dirname(dirname(fileURLToPath(import.meta.url)));
43
+ const SVC_USER = "gh-broker";
44
+ const SVC_GROUP = "gh-broker";
45
+ const CLIENTS_GROUP = "gh-broker-clients";
46
+ const APP_DIR = "/opt/gh-broker";
47
+ const DIST = join(REPO, "dist");
48
+ const DIST_ENTRY = join(DIST, "index.mjs");
49
+
50
+ const log = (m) => console.log(m);
51
+ const step = (m) => console.log(`\n==> ${m}`);
52
+ const die = (m) => {
53
+ console.error(`gh-broker install: ${m}`);
54
+ process.exit(1);
55
+ };
56
+
57
+ function readJson(path) {
58
+ try {
59
+ return JSON.parse(readFileSync(path, "utf8"));
60
+ } catch {
61
+ return null;
62
+ }
63
+ }
64
+
65
+ const HELP = `gh-broker installer — install and upgrade in one command.
66
+
67
+ Usage: sudo node deploy/install.mjs [agent-user] [flags]
68
+
69
+ Flags:
70
+ --agent-user <name> the unprivileged user the agent runs as
71
+ (also positional; defaults to $SUDO_USER)
72
+ --secrets-provider <p> systemd-creds | file (skips the prompt)
73
+ --secrets-file <path> JSON { "<cred-name>": "<token>" } to import tokens from
74
+ --create-agent-user create the agent user if missing (no confirm)
75
+ -y, --non-interactive never prompt; fail if a value/decision is missing
76
+ -h, --help this help
77
+
78
+ Non-interactive credentials: for each not already stored, the token is taken from
79
+ --secrets-file, then env GH_BROKER_SECRET_<NAME> (NAME upper-cased, non-alnum -> _).
80
+ Example (unattended):
81
+ sudo GH_BROKER_SECRET_CLDMV_READ=… GH_BROKER_SECRET_CLDMV_WRITE=… \\
82
+ /usr/local/bin/node deploy/install.mjs --agent-user repo \\
83
+ --secrets-provider systemd-creds --non-interactive`;
84
+
85
+ function parseArgs(argv) {
86
+ const o = { positional: [], nonInteractive: false, createAgentUser: false };
87
+ // Consume the value after a value-taking flag, failing if it's missing or is
88
+ // itself a flag — so `--agent-user --secrets-provider file` can't silently take
89
+ // "--secrets-provider" as the username and proceed with an unintended user.
90
+ const value = (i, flag) => {
91
+ const v = argv[i];
92
+ if (v === undefined || v.startsWith("-")) die(`${flag} requires a value (see --help)`);
93
+ return v;
94
+ };
95
+ for (let i = 0; i < argv.length; i++) {
96
+ const a = argv[i];
97
+ if (a === "-h" || a === "--help") o.help = true;
98
+ else if (a === "-y" || a === "--non-interactive") o.nonInteractive = true;
99
+ else if (a === "--create-agent-user") o.createAgentUser = true;
100
+ else if (a === "--agent-user") o.agentUser = value(++i, a);
101
+ else if (a === "--secrets-provider") o.provider = value(++i, a);
102
+ else if (a === "--secrets-file") o.secretsFile = value(++i, a);
103
+ else if (a.startsWith("-")) die(`unknown flag: ${a} (see --help)`);
104
+ else o.positional.push(a);
105
+ }
106
+ return o;
107
+ }
108
+
109
+ const envSecretKey = (name) => "GH_BROKER_SECRET_" + name.toUpperCase().replace(/[^A-Z0-9]/g, "_");
110
+
111
+ async function main() {
112
+ const opts = parseArgs(process.argv.slice(2));
113
+ if (opts.help) {
114
+ log(HELP);
115
+ return;
116
+ }
117
+ if (opts.provider && opts.provider !== "systemd-creds" && opts.provider !== "file") {
118
+ die(`--secrets-provider must be "systemd-creds" or "file"`);
119
+ }
120
+ const importTokens = opts.secretsFile ? readJson(opts.secretsFile) : {};
121
+ // Must be a plain JSON object of { "<name>": "<token>" } — an array parses as an
122
+ // "object" but yields no tokens, so reject it explicitly rather than importing nothing.
123
+ if (opts.secretsFile && (!importTokens || typeof importTokens !== "object" || Array.isArray(importTokens))) {
124
+ die(`--secrets-file: ${opts.secretsFile} must be a JSON object of { "<name>": "<token>" }`);
125
+ }
126
+
127
+ if (!isRoot()) die("must run as root: sudo node deploy/install.mjs [agent-user] [flags]");
128
+ if (process.platform !== "linux") {
129
+ die(`only Linux is implemented today (platform: ${process.platform}). The path/secret/service seams are ready; the impls are not.`);
130
+ }
131
+
132
+ step("prerequisites");
133
+ for (const cmd of ["systemctl", "useradd", "getent", "sudo"]) if (!has(cmd)) die(`missing required command: ${cmd}`);
134
+ for (const cmd of ["socat", "rsync"]) if (!has(cmd)) log(` note: '${cmd}' not found — ${cmd === "socat" ? "the agent socket bridge needs it" : "will fall back to a plain copy"}`);
135
+
136
+ // --- agent user ---
137
+ step("agent user");
138
+ let agentUser = opts.agentUser || opts.positional[0] || process.env.SUDO_USER || "";
139
+ if (!agentUser) {
140
+ if (opts.nonInteractive) die("no agent user — pass --agent-user <name>");
141
+ agentUser = await promptLine("Which unprivileged user does the agent run as? ");
142
+ }
143
+ if (!agentUser) die("no agent user given");
144
+ if (!userExists(agentUser)) {
145
+ const create = opts.createAgentUser || (!opts.nonInteractive && (await confirm(`User "${agentUser}" does not exist. Create it (plain, no sudo)?`)));
146
+ if (!create) die(`agent user "${agentUser}" missing — create it first or pass --create-agent-user`);
147
+ createAgentUser(agentUser);
148
+ log(` created "${agentUser}"`);
149
+ } else {
150
+ log(` "${agentUser}" exists`);
151
+ }
152
+
153
+ // --- build if needed ---
154
+ // This installer requires root, but dist/ and node_modules/ are generated INSIDE
155
+ // the repo checkout itself — not the deploy target (/opt/gh-broker, a directory
156
+ // the installer owns outright and correctly chowns to the gh-broker service user
157
+ // via deployDist() below). The checkout already has an owner: whoever owns this
158
+ // very script file. Build AS that owner via `sudo -u '#<uid>'` — sudo resolves
159
+ // that uid's home directory, primary group, AND full supplementary groups itself
160
+ // (exactly like a real login), so generated files are correct from the moment
161
+ // they're created — never chown, which is neither cross-platform nor necessary
162
+ // once nothing is ever created with the wrong owner to begin with.
163
+ const nodeModulesDir = join(REPO, "node_modules");
164
+ const buildUid = statSync(fileURLToPath(import.meta.url)).uid;
165
+ const asBuildOwner = (cmd, args) => run("sudo", ["-u", `#${buildUid}`, cmd, ...args], { cwd: REPO, stdio: "inherit" });
166
+
167
+ // A prior root-run build (an older version of this script, or a manual `sudo npm
168
+ // run build`) can leave dist/ or node_modules/ owned by root instead. Detect that
169
+ // and rebuild rather than reuse it — root can always remove a root-owned
170
+ // directory (regular DAC checks don't apply to it), where the build subprocess
171
+ // (running as the checkout owner) could not. This makes re-running the installer
172
+ // self-healing with no separate manual fix ever required.
173
+ const distStale = existsSync(DIST_ENTRY) && statSync(DIST_ENTRY).uid !== buildUid;
174
+ const modulesStale = existsSync(nodeModulesDir) && statSync(nodeModulesDir).uid !== buildUid;
175
+ if (!existsSync(DIST_ENTRY) || distStale || modulesStale) {
176
+ step(distStale || modulesStale ? "build (owned by a different user — rebuilding)" : "build (dist not found)");
177
+ if (!has("npm")) die(`dist/ is missing and 'npm' is not installed — build the bundle first (npm run build) or install npm, then re-run`);
178
+ if (distStale) rmSync(DIST, { recursive: true, force: true });
179
+ if (modulesStale) rmSync(nodeModulesDir, { recursive: true, force: true });
180
+ if (!existsSync(nodeModulesDir)) asBuildOwner("npm", ["ci"]);
181
+ asBuildOwner("npm", ["run", "build"]);
182
+ }
183
+
184
+ // --- resolve deployed paths from the daemon itself (one resolver, no drift) ---
185
+ const paths = JSON.parse(run(currentNodeBin(), [DIST_ENTRY, "--print-paths"]).stdout);
186
+ const configDir = dirname(paths.config);
187
+ log(` config: ${paths.config}`);
188
+ log(` secrets: ${paths.secrets}`);
189
+
190
+ // --- users, groups, dirs, node symlink ---
191
+ step("service user, groups, directories");
192
+ ensureServiceUser(SVC_USER, SVC_GROUP, CLIENTS_GROUP);
193
+ addUserToGroup(agentUser, CLIENTS_GROUP);
194
+ ensureDirs(configDir, APP_DIR, SVC_USER, SVC_GROUP);
195
+ const linked = pointNodeSymlinks(currentNodeBin());
196
+ log(` node symlink -> ${currentNodeBin()} (${linked.join(", ")})`);
197
+
198
+ // --- config: merge(example, current) ---
199
+ step("config (merge example + current)");
200
+ const example = readJson(join(REPO, "config.example.json"));
201
+ if (!example) die("config.example.json missing or invalid");
202
+ const current = readJson(paths.config); // null on first install
203
+ const merged = mergeConfig(example, current);
204
+
205
+ // --- secrets: operator chooses the backend, then tokens are stored in it ---
206
+ step("secrets");
207
+ const needed = referencedCredNames(merged);
208
+ const existingFile = readSecretsFile(paths.secrets); // {} if none
209
+ const credsDir = join(configDir, "creds"); // systemd-creds location
210
+ const priorProvider = merged.secrets?.provider ?? "systemd-creds";
211
+ let provider = opts.provider;
212
+ if (!provider) {
213
+ // An existing install already has a backend — keep it rather than asking again. Only a
214
+ // first install (or an ambiguous one) falls through to the prompt.
215
+ let credNames = [];
216
+ try {
217
+ credNames = readdirSync(credsDir).filter((f) => f.endsWith(".cred")).map((f) => f.slice(0, -".cred".length));
218
+ } catch {
219
+ // no creds dir yet — nothing encrypted on disk
220
+ }
221
+ const detected = detectSecretsProvider({ configured: current?.secrets?.provider, credNames, fileTokenNames: Object.keys(existingFile) });
222
+ if (detected) {
223
+ provider = detected;
224
+ log(` keeping existing backend: ${provider} (change it with --secrets-provider)`);
225
+ }
226
+ }
227
+ if (!provider) {
228
+ provider = opts.nonInteractive
229
+ ? priorProvider
230
+ : await choose(
231
+ "How should the PAT credentials be stored at rest?",
232
+ [
233
+ { key: "systemd-creds", label: "systemd-creds — encrypted, host-bound (recommended; Linux/systemd)" },
234
+ { key: "file", label: "file — plaintext JSON 0640 root:gh-broker (portable, platform-neutral)" }
235
+ ],
236
+ priorProvider === "file" ? 2 : 1
237
+ );
238
+ }
239
+ log(` backend: ${provider}`);
240
+ merged.secrets = { provider, ...(merged.secrets?.path ? { path: merged.secrets.path } : {}) };
241
+
242
+ // The App private key (if config.app is ever set) is a multi-line PEM the
243
+ // single-line prompt can't accept — it must be supplied out-of-band.
244
+ const appKeyName = merged.app?.privateKey?.startsWith("cred:") ? merged.app.privateKey.slice("cred:".length) : undefined;
245
+
246
+ // A token the operator EXPLICITLY supplied for this run — --secrets-file, then
247
+ // env GH_BROKER_SECRET_<NAME>. Its presence is what makes a re-run ROTATE an
248
+ // existing credential rather than keep it. Only non-empty strings count.
249
+ const explicitToken = (name) => {
250
+ const fromImport = importTokens && importTokens[name];
251
+ if (typeof fromImport === "string" && fromImport) return fromImport;
252
+ const fromEnv = process.env[envSecretKey(name)];
253
+ if (typeof fromEnv === "string" && fromEnv) return fromEnv;
254
+ return undefined;
255
+ };
256
+
257
+ // Resolve a token's plaintext, only when needed. Precedence:
258
+ // plaintext secrets file (file → creds migration) → explicit --secrets-file/env
259
+ // (a new value = rotation) → decrypt an existing legacy .cred (migration/keep) →
260
+ // prompt (interactive only). Explicit MUST beat decrypt, or a rotation would
261
+ // just re-encrypt the old decrypted value. Every source yields a non-empty STRING.
262
+ const resolveToken = async (name) => {
263
+ const fromFile = existingFile[name];
264
+ if (typeof fromFile === "string" && fromFile) return fromFile;
265
+ const explicit = explicitToken(name);
266
+ if (explicit) return explicit;
267
+ const dec = trySystemdCredDecrypt(name, join(credsDir, `${name}.cred`));
268
+ if (dec) {
269
+ log(` ${name}: migrated from systemd-creds`);
270
+ return dec;
271
+ }
272
+ if (opts.nonInteractive) die(`missing credential "${name}" — provide via --secrets-file or env ${envSecretKey(name)}`);
273
+ if (name === appKeyName) {
274
+ die(`credential "${name}" is a GitHub App private key (multi-line PEM); the single-line prompt can't accept it — supply it via --secrets-file or env ${envSecretKey(name)}`);
275
+ }
276
+ const entered = await promptHidden(` ${name} (input hidden): `);
277
+ if (!entered) die(`empty value for "${name}"`);
278
+ return entered;
279
+ };
280
+
281
+ if (provider === "systemd-creds") {
282
+ ensureCredsDir(configDir, SVC_GROUP);
283
+ for (const name of needed) {
284
+ const credFile = join(credsDir, `${name}.cred`);
285
+ const existed = existsSync(credFile);
286
+ // Re-encrypt (rotate) only when the operator supplies a new value — a
287
+ // plaintext entry (file → creds migration) or an explicit --secrets-file/env
288
+ // token. On a plain re-run with no new value, keep the existing cred as-is.
289
+ const rotate = Boolean(existingFile[name]) || explicitToken(name) !== undefined;
290
+ if (existed && !rotate) {
291
+ log(` ${name}: existing systemd-creds kept`);
292
+ continue;
293
+ }
294
+ systemdCredsEncrypt(name, await resolveToken(name), credFile);
295
+ log(` ${name}: ${existed ? "rotated" : "encrypted"} (systemd-creds)`);
296
+ }
297
+ writeCredsDropin(needed, credsDir);
298
+ // Now that creds are encrypted, a plaintext secrets file from a previous
299
+ // `file` install must not linger — it would defeat "encrypted at rest".
300
+ if (existsSync(paths.secrets)) {
301
+ removeSecretsFile(paths.secrets);
302
+ log(` removed plaintext secrets file (now using systemd-creds)`);
303
+ }
304
+ } else {
305
+ const tokens = { ...existingFile };
306
+ for (const name of needed) {
307
+ if (!tokens[name]) tokens[name] = await resolveToken(name);
308
+ }
309
+ writeSecretsFile(paths.secrets, tokens, { gid: requireGid(SVC_GROUP) });
310
+ removeCredsDropin(); // no systemd-creds delivery under the file provider
311
+ }
312
+ writeConfigFile(paths.config, merged, SVC_GROUP);
313
+
314
+ // --- validate with the real loader before deploying ---
315
+ step("validate config");
316
+ run(currentNodeBin(), [DIST_ENTRY, "--check", "--config", paths.config], {
317
+ env: { ...process.env, GH_BROKER_SECRETS: paths.secrets },
318
+ stdio: "inherit"
319
+ });
320
+
321
+ // --- deploy unit + dist, restart ---
322
+ step("deploy");
323
+ installUnit(join(REPO, "deploy", "gh-broker.service"));
324
+ enableService();
325
+ deployDist(DIST, APP_DIR, SVC_USER, SVC_GROUP);
326
+ restartService();
327
+ log(` service: ${serviceActive()}`);
328
+
329
+ // --- smoke test ---
330
+ step("smoke test");
331
+ const sock = merged.listen.replace(/^unix:/, "");
332
+ try {
333
+ const r = await smokeTest(sock);
334
+ log(` ✅ broker answered — ${r.tools.length} tools advertised${r.roundTrip ? `; ${r.roundTrip}` : ""}`);
335
+ } catch (e) {
336
+ die(`smoke test failed: ${e.message}\n check: journalctl -u gh-broker -n 30 --no-pager`);
337
+ }
338
+
339
+ log(`\n✅ gh-broker installed. Point the agent's MCP client at the socket:`);
340
+ log(` "github": { "type": "stdio", "command": "socat", "args": ["STDIO", "UNIX-CONNECT:${sock}"] }`);
341
+ log(` (re-login as "${agentUser}" so its ${CLIENTS_GROUP} membership is active)`);
342
+ }
343
+
344
+ main().catch((e) => die(e.message));
@@ -0,0 +1,29 @@
1
+ #!/usr/bin/env bash
2
+ #
3
+ # OPTIONAL Linux convenience bootstrap. The real installer is the Node one:
4
+ # sudo node deploy/install.mjs [agent-user]
5
+ # with node >= 20 as the only prerequisite. This script just gets a system-wide
6
+ # node onto the box first (via nvm) for a truly zero-prerequisite run, then hands
7
+ # off. Not needed if node is already installed. See docs/deploy-architecture.md.
8
+ set -euo pipefail
9
+ [[ $EUID -eq 0 ]] || { echo "run as root: sudo $0 [agent-user]" >&2; exit 1; }
10
+ HERE="$(cd "$(dirname "$0")" && pwd)"
11
+
12
+ node_ok() { command -v node >/dev/null 2>&1 && node -e 'process.exit(+process.versions.node.split(".")[0] >= 20 ? 0 : 1)' 2>/dev/null; }
13
+
14
+ if ! node_ok; then
15
+ echo "==> node >=20 not found — installing system-wide nvm + node"
16
+ export NVM_DIR=/usr/local/nvm
17
+ if [[ ! -s "$NVM_DIR/nvm.sh" ]]; then
18
+ command -v git >/dev/null 2>&1 || { echo "git not found — needed to fetch nvm; install git, or install node>=20 yourself then run: sudo node $HERE/install.mjs [agent-user]" >&2; exit 1; }
19
+ git clone --depth 1 https://github.com/nvm-sh/nvm.git "$NVM_DIR"
20
+ fi
21
+ # shellcheck source=/dev/null
22
+ . "$NVM_DIR/nvm.sh"
23
+ nvm install --lts >/dev/null
24
+ nvm alias default node >/dev/null
25
+ ver="$(nvm version default)"
26
+ for b in node npm npx; do ln -sfn "/usr/local/nvm/versions/node/$ver/bin/$b" "/usr/local/bin/$b"; done
27
+ fi
28
+
29
+ exec "$(command -v node)" "$HERE/install.mjs" "$@"
@@ -0,0 +1,56 @@
1
+ /**
2
+ *
3
+ * @Project: @cldmv/gh-broker
4
+ * @Filename: /deploy/lib/config-merge.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
+ /**
17
+ * Regenerate the deployed config by merging shipped defaults with what's already
18
+ * deployed — never a blind overwrite. See docs/deploy-architecture.md.
19
+ *
20
+ * new_config = mergeConfig(example_defaults, current_config)
21
+ *
22
+ * Field-level precedence, because the two halves have different owners:
23
+ * - `tools` (the allowlist) is a SECURITY control → taken from the example, so a
24
+ * removal/tightening in an update always applies (a "current wins" merge would
25
+ * silently keep a tool the repo dropped).
26
+ * - `owners` → current entries win and are preserved; example owners not present
27
+ * in current are added (a newly-onboarded org in the repo shows up on upgrade).
28
+ * - `listen` / `upstream` / `defaultOwner` / `app` / `secrets` → current wins if
29
+ * set, else the example's value.
30
+ *
31
+ * On a first install (no current), the example is used as-is.
32
+ */
33
+ export function mergeConfig(example, current) {
34
+ if (!example || typeof example !== "object" || Array.isArray(example)) throw new Error("mergeConfig: example config must be an object");
35
+ if (!current) return structuredClone(example);
36
+ if (typeof current !== "object" || Array.isArray(current)) throw new Error("mergeConfig: current config must be an object");
37
+
38
+ const out = structuredClone(example);
39
+
40
+ // tools: always authoritative from the example (already copied into out).
41
+
42
+ // scalars: current wins when defined.
43
+ for (const k of ["listen", "upstream", "defaultOwner"]) {
44
+ if (current[k] !== undefined) out[k] = current[k];
45
+ }
46
+
47
+ // owners: current entries win per-owner; example owners not in current are kept.
48
+ out.owners = { ...(example.owners ?? {}), ...(current.owners ?? {}) };
49
+
50
+ // optional blocks: current wins if present.
51
+ for (const k of ["app", "secrets"]) {
52
+ if (current[k] !== undefined) out[k] = current[k];
53
+ }
54
+
55
+ return out;
56
+ }
@@ -0,0 +1,98 @@
1
+ /**
2
+ *
3
+ * @Project: @cldmv/gh-broker
4
+ * @Filename: /deploy/lib/prompt.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 { stdin, stdout } from "node:process";
17
+
18
+ // Control codes, by number, so no literal control characters live in this file.
19
+ const LF = 10; // Enter
20
+ const CR = 13; // Enter
21
+ const EOT = 4; // Ctrl-D
22
+ const ETX = 3; // Ctrl-C
23
+ const DEL = 127; // Backspace
24
+ const BS = 8; // Backspace
25
+
26
+ /**
27
+ * Raw-mode line reader. `echo` controls whether typed characters are shown:
28
+ * visible prompts echo them, secret prompts don't. Handles Enter, Ctrl-C (abort),
29
+ * Ctrl-D, and Backspace. Falls back to non-raw when stdin isn't a TTY.
30
+ */
31
+ function readLine(question, echo) {
32
+ return new Promise((resolve, reject) => {
33
+ stdout.write(question);
34
+ let input = "";
35
+ const onData = (chunk) => {
36
+ const s = chunk.toString("utf8");
37
+ for (const ch of s) {
38
+ const code = ch.charCodeAt(0);
39
+ if (code === LF || code === CR || code === EOT) {
40
+ cleanup();
41
+ stdout.write("\n");
42
+ return resolve(input.trim());
43
+ }
44
+ if (code === ETX) {
45
+ cleanup();
46
+ stdout.write("\n");
47
+ return reject(new Error("aborted"));
48
+ }
49
+ if (code === DEL || code === BS) {
50
+ if (input.length > 0) {
51
+ input = input.slice(0, -1);
52
+ if (echo) stdout.write("\b \b");
53
+ }
54
+ continue;
55
+ }
56
+ input += ch;
57
+ if (echo) stdout.write(ch);
58
+ }
59
+ };
60
+ const cleanup = () => {
61
+ stdin.removeListener("data", onData);
62
+ if (stdin.isTTY) stdin.setRawMode(false);
63
+ stdin.pause();
64
+ };
65
+ if (stdin.isTTY) stdin.setRawMode(true);
66
+ stdin.resume();
67
+ stdin.on("data", onData);
68
+ });
69
+ }
70
+
71
+ /** Ask a visible question; typed characters are echoed. Returns the trimmed answer. */
72
+ export function promptLine(question) {
73
+ return readLine(question, true);
74
+ }
75
+
76
+ /** Ask for a secret; typed characters are NOT echoed. Never prints the value. */
77
+ export function promptHidden(question) {
78
+ return readLine(question, false);
79
+ }
80
+
81
+ /** Yes/no confirmation. Defaults to no unless the answer starts with y/Y. */
82
+ export async function confirm(question) {
83
+ const a = await promptLine(`${question} [y/N] `);
84
+ return /^y(es)?$/i.test(a);
85
+ }
86
+
87
+ /**
88
+ * Present numbered options ({ key, label }) and return the chosen key.
89
+ * Empty input picks the 1-based default `def`.
90
+ */
91
+ export async function choose(question, options, def = 1) {
92
+ stdout.write(`${question}\n`);
93
+ options.forEach((o, i) => stdout.write(` ${i + 1}) ${o.label}\n`));
94
+ const a = await promptLine(`Choose [${def}]: `);
95
+ const n = a === "" ? def : parseInt(a, 10);
96
+ const idx = Number.isInteger(n) && n >= 1 && n <= options.length ? n - 1 : def - 1;
97
+ return options[idx].key;
98
+ }
@@ -0,0 +1,114 @@
1
+ /**
2
+ *
3
+ * @Project: @cldmv/gh-broker
4
+ * @Filename: /deploy/lib/secret-store.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 { writeFileSync, chmodSync, chownSync, readFileSync, existsSync, statSync, rmSync } from "node:fs";
17
+
18
+ /**
19
+ * Write the secrets map ({ "<name>": "<token>" }) to `path` as JSON, mode 0640,
20
+ * owned by uid:gid (root:<service-group>) so the service user reads it and the
21
+ * agent user (not in that group, dir 0750) cannot. The file provider counterpart
22
+ * to src/secrets.ts. Never logs a value.
23
+ */
24
+ export function writeSecretsFile(path, secrets, { uid = 0, gid, mode = 0o640 } = {}) {
25
+ const json = JSON.stringify(secrets, null, "\t") + "\n";
26
+ writeFileSync(path, json, { mode });
27
+ chmodSync(path, mode);
28
+ if (gid !== undefined) chownSync(path, uid, gid);
29
+ }
30
+
31
+ /**
32
+ * Read an existing secrets file so an upgrade preserves tokens the operator isn't
33
+ * re-entering. Returns {} if the file is absent or unreadable.
34
+ */
35
+ export function readSecretsFile(path) {
36
+ if (!existsSync(path)) return {};
37
+ try {
38
+ const v = JSON.parse(readFileSync(path, "utf8"));
39
+ if (!v || typeof v !== "object" || Array.isArray(v)) return {};
40
+ // Keep only non-empty string values — a non-string (number/object) must never
41
+ // be treated as a token (it would crash encryption or be written back invalid).
42
+ const out = {};
43
+ for (const [name, val] of Object.entries(v)) if (typeof val === "string" && val.length > 0) out[name] = val;
44
+ return out;
45
+ } catch {
46
+ return {};
47
+ }
48
+ }
49
+
50
+ /**
51
+ * Remove a plaintext secrets file — used when the operator switches to the
52
+ * systemd-creds provider, so the tokens don't linger on disk and defeat
53
+ * "encrypted at rest". Best-effort zero-overwrite before unlink. No-op if absent.
54
+ */
55
+ export function removeSecretsFile(path) {
56
+ if (!existsSync(path)) return;
57
+ try {
58
+ writeFileSync(path, Buffer.alloc(statSync(path).size, 0));
59
+ } catch {
60
+ // overwrite is best-effort; unlink still happens below
61
+ }
62
+ rmSync(path, { force: true });
63
+ }
64
+
65
+ const SAFE_CRED_NAME = /^[A-Za-z0-9._-]+$/;
66
+
67
+ /**
68
+ * Constrain a credential name to a safe charset — it becomes a `.cred` filename
69
+ * and is interpolated into a systemd drop-in line written as root, so a name with
70
+ * path separators, "..", colons, or control characters must be rejected.
71
+ */
72
+ export function assertSafeCredName(name) {
73
+ if (name === "." || name === ".." || !SAFE_CRED_NAME.test(name)) {
74
+ throw new Error(`gh-broker: unsafe credential name "${name}" (allowed: letters, digits, ".", "_", "-")`);
75
+ }
76
+ return name;
77
+ }
78
+
79
+ /**
80
+ * The distinct cred names a config's owners + app reference (strips the "cred:"
81
+ * prefix). Validates each name — this is the single source feeding the encrypted
82
+ * `.cred` filenames and the systemd drop-in, so hardening here covers both.
83
+ */
84
+ export function referencedCredNames(config) {
85
+ const names = new Set();
86
+ const add = (ref) => {
87
+ if (typeof ref === "string" && ref.startsWith("cred:")) names.add(assertSafeCredName(ref.slice("cred:".length)));
88
+ };
89
+ for (const owner of Object.values(config.owners ?? {})) {
90
+ add(owner.read);
91
+ add(owner.write);
92
+ }
93
+ if (config.app) add(config.app.privateKey);
94
+ return [...names];
95
+ }
96
+
97
+ /**
98
+ * Works out which secrets backend an EXISTING install already uses, so a re-run keeps it
99
+ * instead of asking again. Precedence: the provider recorded in the deployed config, then
100
+ * what is actually on disk (encrypted `.cred` files → systemd-creds; tokens in the secrets
101
+ * file → file). Returns undefined when it is a first install, or when both backends have
102
+ * credentials and nothing is recorded (ambiguous — the caller should ask).
103
+ *
104
+ * @param {{ configured?: unknown, credNames?: string[], fileTokenNames?: string[] }} found
105
+ * @returns {"systemd-creds" | "file" | undefined}
106
+ */
107
+ export function detectSecretsProvider({ configured, credNames = [], fileTokenNames = [] } = {}) {
108
+ if (configured === "systemd-creds" || configured === "file") return configured;
109
+ const hasCreds = credNames.length > 0;
110
+ const hasFile = fileTokenNames.length > 0;
111
+ if (hasCreds && !hasFile) return "systemd-creds";
112
+ if (hasFile && !hasCreds) return "file";
113
+ return undefined;
114
+ }