@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,409 @@
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-31T12:58:15-07:00 (1793476695)
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
+ * layering, 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, ensureDropDir
36
+ } from "./lib/system.mjs";
37
+ import { promptLine, promptHidden, confirm, choose } from "./lib/prompt.mjs";
38
+ import { buildConfig, readOperatorConfig, secretsBlock } from "./lib/config-merge.mjs";
39
+ import { writeSecretsFile, readSecretsFile, removeSecretsFile, referencedCredNames, optionalCredNames, detectSecretsProvider } from "./lib/secret-store.mjs";
40
+ import { parseArgs, NPM_SCOPE } from "./lib/install-args.mjs";
41
+ import { credentialPlan } from "./lib/credentials.mjs";
42
+ import { smokeTest } from "./lib/smoke.mjs";
43
+ import { createNpmToken } from "./lib/npm-setup.mjs";
44
+ import { hostname } from "node:os";
45
+
46
+ const REPO = dirname(dirname(fileURLToPath(import.meta.url)));
47
+ const SVC_USER = "gh-broker";
48
+ const SVC_GROUP = "gh-broker";
49
+ const CLIENTS_GROUP = "gh-broker-clients";
50
+ const APP_DIR = "/opt/gh-broker";
51
+ const DIST = join(REPO, "dist");
52
+ const DIST_ENTRY = join(DIST, "index.mjs");
53
+
54
+ const log = (m) => console.log(m);
55
+ const step = (m) => console.log(`\n==> ${m}`);
56
+ const die = (m) => {
57
+ console.error(`gh-broker install: ${m}`);
58
+ process.exit(1);
59
+ };
60
+
61
+ function readJson(path) {
62
+ try {
63
+ return JSON.parse(readFileSync(path, "utf8"));
64
+ } catch {
65
+ return null;
66
+ }
67
+ }
68
+
69
+ const HELP = `gh-broker installer — install and upgrade in one command.
70
+
71
+ Usage: sudo node deploy/install.mjs [agent-user] [flags]
72
+
73
+ Flags:
74
+ --agent-user <name> the unprivileged user the agent runs as
75
+ (also positional; defaults to $SUDO_USER)
76
+ --secrets-provider <p> systemd-creds | file (skips the prompt)
77
+ --secrets-file <path> JSON { "<cred-name>": "<token>" } to import tokens from
78
+ --secret <name>=<tok> one token on the command line (repeatable). Visible in
79
+ the process list and shell history: prefer the file or env
80
+ --create-agent-user create the agent user if missing (no confirm)
81
+ --npm set up the npm integration (npm_scaffold), or rotate its
82
+ token: walks through an npm web login and creates the token
83
+ --npm-scope <@scope> scope npm_scaffold may create packages in (repeatable;
84
+ default: the existing setting, else @<first owner>)
85
+ --npm-org <org> also grant the new npm token org read-write on <org>
86
+ (repeatable; default: the existing setting, else none)
87
+ --npm-token-days <n> lifetime of a newly created npm token (default 90)
88
+ -y, --non-interactive never prompt; fail if a value/decision is missing
89
+
90
+ Config (layered, lowest to highest: shipped defaults, the deployed config, --config,
91
+ these flags). An upgrade run with none of them keeps the deployed owners and ghcr as is.
92
+ --config <file> JSON config file in the deployed config's shape, merged on top
93
+ of the deployed one. Holds only "cred:" refs, never tokens;
94
+ no "tools" (the allowlist ships with the code)
95
+ --owner <org> GitHub owner (user or org) to serve; reads use cred:<org>-read,
96
+ writes cred:<org>-write (repeatable). A first install with no
97
+ owner asks for one, or fails under --non-interactive
98
+ --installation-id <org>=<id>
99
+ GitHub App installation id for an owner (repeatable)
100
+ --default-owner <org> owner whose read token serves calls that name no owner
101
+ (a first install with a single owner defaults to it)
102
+ --listen <unix:path> socket the broker listens on
103
+ --upstream <url> GitHub hosted MCP base URL
104
+ --app-id <n> GitHub App id (App-token-only verbs)
105
+ --app-private-key <cred-name>
106
+ credential holding the App private key (default gh-app-key)
107
+ --secrets-path <path> secrets file location for --secrets-provider file
108
+ --npm-registry <url> npm registry for npm_scaffold (https; with --npm)
109
+ --ghcr-owner <org> enable GHCR pushes for <org>; its classic PAT is cred:<org>-packages
110
+ --ghcr-username <login> GitHub login that owns that PAT (needs --ghcr-owner)
111
+ --ghcr-image <glob> image name or glob the owner may push, e.g. app or app-* (repeatable)
112
+ --ghcr-drop-dir <path> directory archives are read from (default /srv/gh-broker/ghcr-drop)
113
+ --ghcr-max-archive-bytes <n>
114
+ largest archive accepted (default 16 GiB)
115
+ --protect-release-tags, --no-protect-release-tags
116
+ refuse (or allow) overwriting 1.2.3 / v1.2.3 tags (default: refuse)
117
+ Several GHCR owners, or any other detail, go in a --config file.
118
+ -h, --help this help
119
+
120
+ Credentials: each token comes from, in order, --secret, --secrets-file, env
121
+ GH_BROKER_SECRET_<NAME> (NAME upper-cased, non-alnum -> _), and otherwise an
122
+ interactive prompt (input hidden). A token supplied by any of the first three
123
+ replaces a stored one (rotation). Optional tokens (npm-token) can be skipped.
124
+ Example (unattended):
125
+ sudo GH_BROKER_SECRET_ACME_READ=… GH_BROKER_SECRET_ACME_WRITE=… \\
126
+ /usr/local/bin/node deploy/install.mjs --agent-user repo --owner acme \\
127
+ --secrets-provider systemd-creds --non-interactive`;
128
+
129
+ const NPM_TOKEN_REF = "cred:npm-token";
130
+
131
+ /** parseArgs with installer-style failure (the parser itself throws). */
132
+ function parseArgsOrDie(argv) {
133
+ try {
134
+ return parseArgs(argv);
135
+ } catch (err) {
136
+ die(err.message);
137
+ }
138
+ }
139
+
140
+ async function main() {
141
+ const opts = parseArgsOrDie(process.argv.slice(2));
142
+ if (opts.help) {
143
+ log(HELP);
144
+ return;
145
+ }
146
+ const importTokens = opts.secretsFile ? readJson(opts.secretsFile) : {};
147
+ // Must be a plain JSON object of { "<name>": "<token>" } — an array parses as an
148
+ // "object" but yields no tokens, so reject it explicitly rather than importing nothing.
149
+ if (opts.secretsFile && (!importTokens || typeof importTokens !== "object" || Array.isArray(importTokens))) {
150
+ die(`--secrets-file: ${opts.secretsFile} must be a JSON object of { "<name>": "<token>" }`);
151
+ }
152
+
153
+ let operatorFile;
154
+ if (opts.configFile) {
155
+ try {
156
+ operatorFile = readOperatorConfig(opts.configFile, (f) => readFileSync(f, "utf8"));
157
+ } catch (err) {
158
+ die(err.message);
159
+ }
160
+ }
161
+
162
+ if (!isRoot()) die("must run as root: sudo node deploy/install.mjs [agent-user] [flags]");
163
+ if (process.platform !== "linux") {
164
+ die(`only Linux is implemented today (platform: ${process.platform}). The path/secret/service seams are ready; the impls are not.`);
165
+ }
166
+
167
+ step("prerequisites");
168
+ for (const cmd of ["systemctl", "useradd", "getent", "sudo"]) if (!has(cmd)) die(`missing required command: ${cmd}`);
169
+ 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"}`);
170
+
171
+ // --- agent user ---
172
+ step("agent user");
173
+ let agentUser = opts.agentUser || opts.positional[0] || process.env.SUDO_USER || "";
174
+ if (!agentUser) {
175
+ if (opts.nonInteractive) die("no agent user — pass --agent-user <name>");
176
+ agentUser = await promptLine("Which unprivileged user does the agent run as? ");
177
+ }
178
+ if (!agentUser) die("no agent user given");
179
+ if (!userExists(agentUser)) {
180
+ const create = opts.createAgentUser || (!opts.nonInteractive && (await confirm(`User "${agentUser}" does not exist. Create it (plain, no sudo)?`)));
181
+ if (!create) die(`agent user "${agentUser}" missing — create it first or pass --create-agent-user`);
182
+ createAgentUser(agentUser);
183
+ log(` created "${agentUser}"`);
184
+ } else {
185
+ log(` "${agentUser}" exists`);
186
+ }
187
+
188
+ // --- build if needed ---
189
+ // This installer requires root, but dist/ and node_modules/ are generated INSIDE
190
+ // the repo checkout itself — not the deploy target (/opt/gh-broker, a directory
191
+ // the installer owns outright and correctly chowns to the gh-broker service user
192
+ // via deployDist() below). The checkout already has an owner: whoever owns this
193
+ // very script file. Build AS that owner via `sudo -u '#<uid>'` — sudo resolves
194
+ // that uid's home directory, primary group, AND full supplementary groups itself
195
+ // (exactly like a real login), so generated files are correct from the moment
196
+ // they're created — never chown, which is neither cross-platform nor necessary
197
+ // once nothing is ever created with the wrong owner to begin with.
198
+ const nodeModulesDir = join(REPO, "node_modules");
199
+ const buildUid = statSync(fileURLToPath(import.meta.url)).uid;
200
+ const asBuildOwner = (cmd, args) => run("sudo", ["-u", `#${buildUid}`, cmd, ...args], { cwd: REPO, stdio: "inherit" });
201
+
202
+ // A prior root-run build (an older version of this script, or a manual `sudo npm
203
+ // run build`) can leave dist/ or node_modules/ owned by root instead. Detect that
204
+ // and rebuild rather than reuse it — root can always remove a root-owned
205
+ // directory (regular DAC checks don't apply to it), where the build subprocess
206
+ // (running as the checkout owner) could not. This makes re-running the installer
207
+ // self-healing with no separate manual fix ever required.
208
+ const distStale = existsSync(DIST_ENTRY) && statSync(DIST_ENTRY).uid !== buildUid;
209
+ const modulesStale = existsSync(nodeModulesDir) && statSync(nodeModulesDir).uid !== buildUid;
210
+ if (!existsSync(DIST_ENTRY) || distStale || modulesStale) {
211
+ step(distStale || modulesStale ? "build (owned by a different user — rebuilding)" : "build (dist not found)");
212
+ 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`);
213
+ if (distStale) rmSync(DIST, { recursive: true, force: true });
214
+ if (modulesStale) rmSync(nodeModulesDir, { recursive: true, force: true });
215
+ if (!existsSync(nodeModulesDir)) asBuildOwner("npm", ["ci"]);
216
+ asBuildOwner("npm", ["run", "build"]);
217
+ }
218
+
219
+ // --- resolve deployed paths from the daemon itself (one resolver, no drift) ---
220
+ const paths = JSON.parse(run(currentNodeBin(), [DIST_ENTRY, "--print-paths"]).stdout);
221
+ const configDir = dirname(paths.config);
222
+ log(` config: ${paths.config}`);
223
+ log(` secrets: ${paths.secrets}`);
224
+
225
+ // --- users, groups, dirs, node symlink ---
226
+ step("service user, groups, directories");
227
+ ensureServiceUser(SVC_USER, SVC_GROUP, CLIENTS_GROUP);
228
+ addUserToGroup(agentUser, CLIENTS_GROUP);
229
+ ensureDirs(configDir, APP_DIR, SVC_USER, SVC_GROUP);
230
+ const linked = pointNodeSymlinks(currentNodeBin());
231
+ log(` node symlink -> ${currentNodeBin()} (${linked.join(", ")})`);
232
+
233
+ // --- config: merge(example, current) ---
234
+ step("config (example + deployed + --config + flags)");
235
+ const example = readJson(join(REPO, "config.example.json"));
236
+ if (!example) die("config.example.json missing or invalid");
237
+ const current = readJson(paths.config); // null on first install
238
+ let merged;
239
+ try {
240
+ const built = await buildConfig({ example, current, file: operatorFile, opts, nonInteractive: opts.nonInteractive, ask: promptLine });
241
+ merged = built.config;
242
+ for (const n of built.notes) log(` ${n}`);
243
+ } catch (err) {
244
+ die(err.message);
245
+ }
246
+ if (merged.ghcr?.dropDir) {
247
+ ensureDropDir(merged.ghcr.dropDir, agentUser, SVC_GROUP);
248
+ log(` ghcr drop dir: ${merged.ghcr.dropDir} (2750 ${agentUser}:${SVC_GROUP})`);
249
+ }
250
+
251
+ // --- npm integration (npm_scaffold): opt-in ---
252
+ // Offered on a first interactive install; afterwards `--npm` enables it or, when it is
253
+ // already enabled, rotates the token. Either way a new token is created by driving the
254
+ // npm web login + token creation from here (see lib/npm-setup.mjs), unless the operator
255
+ // supplied one through --secrets-file/env.
256
+ step("npm integration");
257
+ let npmMint = false;
258
+ if (opts.npm || (!merged.npm && current === null && !opts.nonInteractive && (await confirm("Enable npm_scaffold (create new npm packages and their trusted publishers)?")))) {
259
+ let scopes = opts.npmScopes.length ? opts.npmScopes : merged.npm?.scopes;
260
+ if (!scopes?.length) {
261
+ const fallback = `@${Object.keys(merged.owners ?? {})[0] ?? ""}`.toLowerCase();
262
+ const answer = opts.nonInteractive ? fallback : (await promptLine(` npm scope(s) to allow, comma-separated [${fallback}]: `)) || fallback;
263
+ scopes = answer.split(",").map((s) => s.trim()).filter(Boolean);
264
+ }
265
+ for (const s of scopes) if (!NPM_SCOPE.test(s)) die(`npm scope must be a lowercase npm scope like "@acme", got "${s}"`);
266
+ const tokenOrgs = opts.npmOrgs.length ? opts.npmOrgs : (merged.npm?.tokenOrgs ?? []);
267
+ merged.npm = { ...(merged.npm ?? {}), token: merged.npm?.token ?? NPM_TOKEN_REF, scopes, ...(opts.npmRegistry ? { registry: opts.npmRegistry } : {}) };
268
+ if (tokenOrgs.length) merged.npm.tokenOrgs = tokenOrgs;
269
+ else delete merged.npm.tokenOrgs;
270
+ npmMint = true;
271
+ log(` ${current?.npm ? "rotating the npm token" : "enabling"} for ${scopes.join(", ")}${tokenOrgs.length ? ` (plus org read-write on ${tokenOrgs.join(", ")})` : ""}`);
272
+ } else {
273
+ log(merged.npm ? ` enabled for ${merged.npm.scopes.join(", ")} (rotate the token with --npm)` : " not enabled (enable with --npm)");
274
+ }
275
+ const npmTokenName = merged.npm?.token?.startsWith("cred:") ? merged.npm.token.slice("cred:".length) : undefined;
276
+
277
+ // --- secrets: operator chooses the backend, then tokens are stored in it ---
278
+ step("secrets");
279
+ const needed = referencedCredNames(merged);
280
+ const optional = new Set(optionalCredNames(merged));
281
+ const existingFile = readSecretsFile(paths.secrets); // {} if none
282
+ const credsDir = join(configDir, "creds"); // systemd-creds location
283
+ const priorProvider = merged.secrets?.provider ?? "systemd-creds";
284
+ let provider = opts.provider;
285
+ if (!provider) {
286
+ // An existing install already has a backend — keep it rather than asking again. Only a
287
+ // first install (or an ambiguous one) falls through to the prompt.
288
+ let credNames = [];
289
+ try {
290
+ credNames = readdirSync(credsDir).filter((f) => f.endsWith(".cred")).map((f) => f.slice(0, -".cred".length));
291
+ } catch {
292
+ // no creds dir yet — nothing encrypted on disk
293
+ }
294
+ const detected = detectSecretsProvider({ configured: current?.secrets?.provider, credNames, fileTokenNames: Object.keys(existingFile) });
295
+ if (detected) {
296
+ provider = detected;
297
+ log(` keeping existing backend: ${provider} (change it with --secrets-provider)`);
298
+ }
299
+ }
300
+ if (!provider) {
301
+ provider = opts.nonInteractive
302
+ ? priorProvider
303
+ : await choose(
304
+ "How should the PAT credentials be stored at rest?",
305
+ [
306
+ { key: "systemd-creds", label: "systemd-creds — encrypted, host-bound (recommended; Linux/systemd)" },
307
+ { key: "file", label: "file — plaintext JSON 0640 root:gh-broker (portable, platform-neutral)" }
308
+ ],
309
+ priorProvider === "file" ? 2 : 1
310
+ );
311
+ }
312
+ log(` backend: ${provider}`);
313
+ try {
314
+ merged.secrets = secretsBlock(provider, merged.secrets);
315
+ } catch (err) {
316
+ die(err.message);
317
+ }
318
+
319
+ // The App private key (if config.app is ever set) is a multi-line PEM the
320
+ // single-line prompt can't accept — it must be supplied out-of-band.
321
+ const appKeyName = merged.app?.privateKey?.startsWith("cred:") ? merged.app.privateKey.slice("cred:".length) : undefined;
322
+
323
+ let plan;
324
+ try {
325
+ plan = credentialPlan({
326
+ needed,
327
+ optional,
328
+ cliSecrets: opts.cliSecrets,
329
+ imported: importTokens ?? {},
330
+ env: process.env,
331
+ existingFile,
332
+ decrypt: (name) => trySystemdCredDecrypt(name, join(credsDir, `${name}.cred`)),
333
+ mints: (name) => name === npmTokenName && npmMint,
334
+ mint: async (name) => {
335
+ try {
336
+ const out = await createNpmToken({
337
+ ...(merged.npm.registry ? { registry: merged.npm.registry } : {}),
338
+ scopes: merged.npm.scopes,
339
+ ...(merged.npm.tokenOrgs?.length ? { orgs: merged.npm.tokenOrgs } : {}),
340
+ tokenName: `gh-broker ${hostname()} ${new Date().toISOString().slice(0, 10)}`,
341
+ ...(opts.npmTokenDays ? { days: opts.npmTokenDays } : {}),
342
+ promptHidden,
343
+ log
344
+ });
345
+ log(` ${name}: created${out.expires ? ` (expires ${out.expires.slice(0, 10)})` : ""}`);
346
+ return out.token;
347
+ } catch (err) {
348
+ throw new Error(`npm token setup failed: ${err.message}`);
349
+ }
350
+ },
351
+ appKeyName,
352
+ nonInteractive: opts.nonInteractive,
353
+ promptHidden,
354
+ log
355
+ });
356
+
357
+ if (provider === "systemd-creds") {
358
+ ensureCredsDir(configDir, SVC_GROUP);
359
+ const stored = await plan.storeSystemd({
360
+ exists: (name) => existsSync(join(credsDir, `${name}.cred`)),
361
+ encrypt: (name, value) => systemdCredsEncrypt(name, value, join(credsDir, `${name}.cred`))
362
+ });
363
+ writeCredsDropin(stored, credsDir);
364
+ // Now that creds are encrypted, a plaintext secrets file from a previous
365
+ // `file` install must not linger — it would defeat "encrypted at rest".
366
+ if (existsSync(paths.secrets)) {
367
+ removeSecretsFile(paths.secrets);
368
+ log(` removed plaintext secrets file (now using systemd-creds)`);
369
+ }
370
+ } else {
371
+ writeSecretsFile(paths.secrets, await plan.storeFile(), { gid: requireGid(SVC_GROUP) });
372
+ removeCredsDropin(); // no systemd-creds delivery under the file provider
373
+ }
374
+ } catch (err) {
375
+ die(err.message);
376
+ }
377
+ writeConfigFile(paths.config, merged, SVC_GROUP);
378
+
379
+ // --- validate with the real loader before deploying ---
380
+ step("validate config");
381
+ run(currentNodeBin(), [DIST_ENTRY, "--check", "--config", paths.config], {
382
+ env: { ...process.env, GH_BROKER_SECRETS: paths.secrets },
383
+ stdio: "inherit"
384
+ });
385
+
386
+ // --- deploy unit + dist, restart ---
387
+ step("deploy");
388
+ installUnit(join(REPO, "deploy", "gh-broker.service"));
389
+ enableService();
390
+ deployDist(DIST, APP_DIR, SVC_USER, SVC_GROUP);
391
+ restartService();
392
+ log(` service: ${serviceActive()}`);
393
+
394
+ // --- smoke test ---
395
+ step("smoke test");
396
+ const sock = merged.listen.replace(/^unix:/, "");
397
+ try {
398
+ const r = await smokeTest(sock);
399
+ log(` ✅ broker answered — ${r.tools.length} tools advertised${r.roundTrip ? `; ${r.roundTrip}` : ""}`);
400
+ } catch (e) {
401
+ die(`smoke test failed: ${e.message}\n check: journalctl -u gh-broker -n 30 --no-pager`);
402
+ }
403
+
404
+ log(`\n✅ gh-broker installed. Point the agent's MCP client at the socket:`);
405
+ log(` "github": { "type": "stdio", "command": "socat", "args": ["STDIO", "UNIX-CONNECT:${sock}"] }`);
406
+ log(` (re-login as "${agentUser}" so its ${CLIENTS_GROUP} membership is active)`);
407
+ }
408
+
409
+ 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,214 @@
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-11-01T00:40:44-07:00 (1793518844)
11
+ * -----
12
+ * @Copyright: Copyright (c) 2013-2026 Catalyzed Motivation Inc. All rights reserved.
13
+ *
14
+ */
15
+
16
+ /**
17
+ * Build the deployed config from four layers, lowest to highest precedence:
18
+ *
19
+ * 1. example defaults — config.example.json: `listen`, `upstream` and the `tools` allowlist.
20
+ * 2. deployed config — what is already on the host (the previous install's result).
21
+ * 3. --config file — an operator config file, same shape as the deployed config.
22
+ * 4. CLI flags — one flag per setting (--owner, --listen, --ghcr-owner, ...).
23
+ *
24
+ * Field-level rules, because the layers have different owners:
25
+ * - `tools` (the allowlist) is a SECURITY control and is always taken from the example, so a
26
+ * removal or tightening in an update applies and neither the deployed copy, a --config
27
+ * file nor a flag can widen it. A --config file that contains `tools` is refused.
28
+ * - Everything else is operator data and the deployed copy wins over the example: `listen`,
29
+ * `upstream`, `defaultOwner`, `owners`, `ghcr`, `app`, `secrets`, `npm`. The example ships
30
+ * none of the owner-specific ones, so a fresh install has no owners until the operator
31
+ * supplies one (--owner, --config, or the interactive prompt).
32
+ * - Layers 3 and 4 are merged on top of what is deployed rather than replacing it: scalars are
33
+ * replaced, `owners` and `ghcr.owners` are merged per owner (and per field within an owner),
34
+ * and the other blocks (`app`, `secrets`, `npm`, the `ghcr` scalars) are merged per field.
35
+ * An upgrade run without flags therefore keeps the deployed owners and `ghcr` untouched.
36
+ *
37
+ * The result is validated by the real loader (`--check`) before anything is deployed, and a
38
+ * --config file may hold only `cred:` references, never token values.
39
+ */
40
+ export const DEFAULT_GHCR_DROP_DIR = "/srv/gh-broker/ghcr-drop";
41
+
42
+ const OPERATOR_KEYS = ["listen", "upstream", "defaultOwner", "owners", "app", "secrets", "npm", "ghcr"];
43
+ const isObject = (v) => typeof v === "object" && v !== null && !Array.isArray(v);
44
+ /** Shapes of live GitHub / npm tokens (classic, fine-grained, app, npm granular). */
45
+ const TOKEN_SHAPE = /\b(?:gh[pousr]_[A-Za-z0-9]{20,}|github_pat_[A-Za-z0-9_]{20,}|npm_[A-Za-z0-9]{30,})\b/;
46
+
47
+ export function mergeConfig(example, current) {
48
+ if (!example || typeof example !== "object" || Array.isArray(example)) throw new Error("mergeConfig: example config must be an object");
49
+ if (!current) return structuredClone(example);
50
+ if (typeof current !== "object" || Array.isArray(current)) throw new Error("mergeConfig: current config must be an object");
51
+
52
+ const out = structuredClone(example);
53
+
54
+ // tools: always authoritative from the example (already copied into out).
55
+
56
+ // scalars: current wins when defined.
57
+ for (const k of ["listen", "upstream", "defaultOwner"]) {
58
+ if (current[k] !== undefined) out[k] = current[k];
59
+ }
60
+
61
+ // owners: operator data. Current entries win per owner; any the example ships are kept.
62
+ out.owners = { ...(example.owners ?? {}), ...(current.owners ?? {}) };
63
+
64
+ // optional blocks: current wins if present.
65
+ for (const k of ["app", "secrets", "npm", "ghcr"]) {
66
+ if (current[k] !== undefined) out[k] = structuredClone(current[k]);
67
+ }
68
+
69
+ return out;
70
+ }
71
+
72
+ /**
73
+ * Lay `overlay` (a --config file or the flags' config fragment) on top of `base`. `tools` in the
74
+ * overlay is ignored here (readOperatorConfig refuses it earlier). Returns a new object.
75
+ */
76
+ export function layerConfig(base, overlay) {
77
+ const out = structuredClone(base);
78
+ for (const k of ["listen", "upstream", "defaultOwner"]) {
79
+ if (overlay[k] !== undefined) out[k] = overlay[k];
80
+ }
81
+ for (const k of ["app", "secrets", "npm"]) {
82
+ if (overlay[k] !== undefined) out[k] = { ...(out[k] ?? {}), ...structuredClone(overlay[k]) };
83
+ }
84
+ if (overlay.owners !== undefined) {
85
+ out.owners = { ...(out.owners ?? {}) };
86
+ for (const [name, creds] of Object.entries(overlay.owners)) out.owners[name] = { ...(out.owners[name] ?? {}), ...creds };
87
+ }
88
+ if (overlay.ghcr !== undefined) {
89
+ const { owners: ghcrOwners, ...scalars } = overlay.ghcr;
90
+ const merged = { ...(out.ghcr ?? {}), ...structuredClone(scalars) };
91
+ if (ghcrOwners !== undefined) {
92
+ merged.owners = { ...(merged.owners ?? {}) };
93
+ for (const [name, o] of Object.entries(ghcrOwners)) merged.owners[name] = { ...(merged.owners[name] ?? {}), ...structuredClone(o) };
94
+ }
95
+ out.ghcr = merged;
96
+ }
97
+ return out;
98
+ }
99
+
100
+ /** The config fragment the CLI flags describe (same shape as a config file), or {} when none were given. */
101
+ export function overlayFromFlags(opts) {
102
+ const o = {};
103
+ if (opts.listen !== undefined) o.listen = opts.listen;
104
+ if (opts.upstream !== undefined) o.upstream = opts.upstream;
105
+ if (opts.defaultOwner !== undefined) o.defaultOwner = opts.defaultOwner;
106
+ for (const org of opts.owners ?? []) (o.owners ??= {})[org] = { read: `cred:${org}-read`, write: `cred:${org}-write` };
107
+ for (const [org, id] of Object.entries(opts.installationIds ?? {})) ((o.owners ??= {})[org] ??= {}).installationId = id;
108
+ if (opts.appId !== undefined) o.app = { appId: opts.appId, privateKey: `cred:${opts.appPrivateKey ?? "gh-app-key"}` };
109
+ if (opts.secretsPath !== undefined) o.secrets = { path: opts.secretsPath };
110
+ const g = {};
111
+ if (opts.ghcrDropDir !== undefined) g.dropDir = opts.ghcrDropDir;
112
+ if (opts.ghcrMaxArchiveBytes !== undefined) g.maxArchiveBytes = opts.ghcrMaxArchiveBytes;
113
+ if (opts.protectReleaseTags !== undefined) g.protectReleaseTags = opts.protectReleaseTags;
114
+ if (opts.ghcrOwner !== undefined) {
115
+ g.owners = { [opts.ghcrOwner]: { username: opts.ghcrUsername, credential: `cred:${opts.ghcrOwner}-packages`, images: [...opts.ghcrImages] } };
116
+ }
117
+ if (Object.keys(g).length) o.ghcr = g;
118
+ return o;
119
+ }
120
+
121
+ /**
122
+ * Parse an operator --config file. It must be a JSON object in the deployed config's shape, with
123
+ * no `tools` (the allowlist ships with the code), no unknown top-level keys and no token values.
124
+ * `read` is the file reader (injected so the check is testable). Throws on any problem.
125
+ */
126
+ export function readOperatorConfig(path, read) {
127
+ let raw;
128
+ try {
129
+ raw = JSON.parse(read(path));
130
+ } catch (err) {
131
+ throw new Error(`--config: cannot read/parse ${path}: ${err.message}`);
132
+ }
133
+ if (!isObject(raw)) throw new Error(`--config: ${path} must be a JSON object`);
134
+ if (raw.tools !== undefined) throw new Error("--config: `tools` is the shipped allowlist and cannot be set from a config file");
135
+ const unknown = Object.keys(raw).filter((k) => !OPERATOR_KEYS.includes(k));
136
+ if (unknown.length) throw new Error(`--config: unknown key(s) ${unknown.join(", ")} (allowed: ${OPERATOR_KEYS.join(", ")})`);
137
+ const hit = findTokenShape(raw, "");
138
+ if (hit) throw new Error(`--config: ${hit} looks like a token. The config holds only "cred:<name>" references; pass the token with --secret, --secrets-file or env`);
139
+ return raw;
140
+ }
141
+
142
+ /** Path of the first string in `v` shaped like a token, or undefined. */
143
+ function findTokenShape(v, where) {
144
+ if (typeof v === "string") return TOKEN_SHAPE.test(v) ? where : undefined;
145
+ if (Array.isArray(v)) {
146
+ for (let i = 0; i < v.length; i++) {
147
+ const hit = findTokenShape(v[i], `${where}[${i}]`);
148
+ if (hit) return hit;
149
+ }
150
+ } else if (isObject(v)) {
151
+ for (const [k, x] of Object.entries(v)) {
152
+ const hit = findTokenShape(x, where ? `${where}.${k}` : k);
153
+ if (hit) return hit;
154
+ }
155
+ }
156
+ return undefined;
157
+ }
158
+
159
+ /**
160
+ * The full pipeline: example -> deployed -> --config file -> flags, then the first-install
161
+ * fix-ups. Returns { config, notes }.
162
+ *
163
+ * @param {object} o
164
+ * @param {object} o.example parsed config.example.json
165
+ * @param {object | null} o.current the deployed config, or null on a first install
166
+ * @param {object | undefined} o.file a parsed operator --config file (see readOperatorConfig)
167
+ * @param {object} o.opts parsed installer flags
168
+ * @param {boolean} o.nonInteractive
169
+ * @param {(q: string) => Promise<string>} o.ask visible prompt, used only when no owner exists
170
+ */
171
+ export async function buildConfig({ example, current, file, opts, nonInteractive, ask }) {
172
+ const notes = [];
173
+ let config = mergeConfig(example, current);
174
+ if (file) config = layerConfig(config, file);
175
+ config = layerConfig(config, overlayFromFlags(opts));
176
+ config.owners ??= {};
177
+
178
+ if (Object.keys(config.owners).length === 0) {
179
+ if (nonInteractive) {
180
+ throw new Error("no GitHub owner configured — pass --owner <org> (repeatable) or --config <file> with an owners block");
181
+ }
182
+ const name = (await ask("GitHub owner (user or org) the broker serves, e.g. your-org: ")).trim().toLowerCase();
183
+ if (!/^[a-z0-9](?:[a-z0-9-]{0,38})$/.test(name)) throw new Error(`"${name}" is not a valid GitHub owner name — or pass --owner <org>`);
184
+ config.owners[name] = { read: `cred:${name}-read`, write: `cred:${name}-write` };
185
+ }
186
+ for (const [name, creds] of Object.entries(config.owners)) {
187
+ if (typeof creds.read !== "string" || typeof creds.write !== "string") {
188
+ throw new Error(`owner "${name}" has no credentials — add it with --owner ${name}, or give "read" and "write" cred refs in --config`);
189
+ }
190
+ }
191
+
192
+ const names = Object.keys(config.owners);
193
+ if (current === null && config.defaultOwner === undefined && names.length === 1) {
194
+ config.defaultOwner = names[0];
195
+ notes.push(`defaultOwner set to "${names[0]}" (the only owner); change it with --default-owner`);
196
+ }
197
+
198
+ if (config.ghcr && config.ghcr.dropDir === undefined) {
199
+ config.ghcr.dropDir = DEFAULT_GHCR_DROP_DIR;
200
+ notes.push(`ghcr.dropDir defaulted to ${DEFAULT_GHCR_DROP_DIR} (change it with --ghcr-drop-dir)`);
201
+ }
202
+ return { config, notes };
203
+ }
204
+
205
+ /**
206
+ * The `secrets` block for the chosen provider. A `path` only belongs to the "file" provider, and
207
+ * the loader refuses the combination, so refuse it here before any credential is written.
208
+ */
209
+ export function secretsBlock(provider, existing) {
210
+ if (existing?.path && provider !== "file") {
211
+ throw new Error(`secrets.path (${existing.path}) is only used by the "file" provider — use --secrets-provider file, or drop the path`);
212
+ }
213
+ return { provider, ...(existing?.path ? { path: existing.path } : {}) };
214
+ }