@cldmv/gh-broker 0.7.0 → 0.9.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/README.md +165 -41
  2. package/config.example.json +38 -9
  3. package/config.sample.json +45 -0
  4. package/deploy/README.md +76 -17
  5. package/deploy/install.mjs +171 -104
  6. package/deploy/install.sh +0 -0
  7. package/deploy/lib/config-merge.mjs +174 -16
  8. package/deploy/lib/credentials.mjs +143 -0
  9. package/deploy/lib/install-args.mjs +144 -0
  10. package/deploy/lib/npm-setup.mjs +164 -0
  11. package/deploy/lib/secret-store.mjs +20 -2
  12. package/deploy/lib/system.mjs +26 -2
  13. package/dist/index.mjs +5864 -1809
  14. package/dist/index.mjs.map +4 -4
  15. package/package.json +18 -8
  16. package/.configs/fix-headers.json +0 -3
  17. package/.configs/vitest.config.mjs +0 -44
  18. package/.github/dependabot.yml +0 -111
  19. package/.github/workflows/branch-retention.yml +0 -44
  20. package/.github/workflows/bundle-size.yml +0 -52
  21. package/.github/workflows/ci.yml +0 -389
  22. package/.github/workflows/cla.yml +0 -66
  23. package/.github/workflows/codeql.yml +0 -87
  24. package/.github/workflows/dependabot-auto-merge.yml +0 -62
  25. package/.github/workflows/dependabot-recreate.yml +0 -67
  26. package/.github/workflows/dependency-review.yml +0 -46
  27. package/.github/workflows/feature-pr.yml +0 -75
  28. package/.github/workflows/hotfix-redirector.yml +0 -67
  29. package/.github/workflows/hotfixes-release.yml +0 -70
  30. package/.github/workflows/labeler.yml +0 -48
  31. package/.github/workflows/master-commit-audit.yml +0 -56
  32. package/.github/workflows/member-auto-merge.yml +0 -75
  33. package/.github/workflows/next-release.yml +0 -76
  34. package/.github/workflows/next-reset.yml +0 -50
  35. package/.github/workflows/pr-notify.yml +0 -44
  36. package/.github/workflows/pr-title-normalizer.yml +0 -52
  37. package/.github/workflows/provenance.yml +0 -51
  38. package/.github/workflows/publish.yml +0 -146
  39. package/.github/workflows/release-merge.yml +0 -104
  40. package/.github/workflows/release-notify.yml +0 -41
  41. package/.github/workflows/scorecard.yml +0 -64
  42. package/.github/workflows/stale.yml +0 -50
  43. package/.github/workflows/tag-health.yml +0 -70
  44. package/.github/workflows/update-major-version-tags.yml +0 -94
  45. package/.github/workflows/v4-bootstrap.yml +0 -115
  46. package/.github/workflows/welcome.yml +0 -42
  47. package/docs/TOOLS.md +0 -347
  48. package/docs/agent-user-model.md +0 -89
  49. package/docs/changelog/v0/v0.2.0.md +0 -26
  50. package/docs/changelog/v0/v0.2.1.md +0 -23
  51. package/docs/changelog/v0/v0.2.2.md +0 -23
  52. package/docs/changelog/v0/v0.2.3.md +0 -33
  53. package/docs/changelog/v0/v0.2.4.md +0 -25
  54. package/docs/changelog/v0/v0.2.5.md +0 -29
  55. package/docs/changelog/v0/v0.3.0.md +0 -84
  56. package/docs/changelog/v0/v0.3.1.md +0 -30
  57. package/docs/changelog/v0/v0.3.2.md +0 -24
  58. package/docs/changelog/v0/v0.3.3.md +0 -28
  59. package/docs/changelog/v0/v0.4.0.md +0 -58
  60. package/docs/changelog/v0/v0.4.1.md +0 -33
  61. package/docs/changelog/v0/v0.4.2.md +0 -45
  62. package/docs/changelog/v0/v0.5.0.md +0 -39
  63. package/docs/changelog/v0/v0.5.1.md +0 -28
  64. package/docs/changelog/v0/v0.6.0.md +0 -52
  65. package/docs/changelog/v0/v0.6.1.md +0 -48
  66. package/docs/changelog/v0/v0.7.0.md +0 -71
  67. package/docs/deploy-architecture.md +0 -82
  68. package/src/appauth.ts +0 -56
  69. package/src/audit.ts +0 -34
  70. package/src/catalog.ts +0 -165
  71. package/src/config.ts +0 -130
  72. package/src/index.ts +0 -107
  73. package/src/paths.ts +0 -60
  74. package/src/router.ts +0 -258
  75. package/src/secrets.ts +0 -137
  76. package/src/server.ts +0 -91
  77. package/src/types.ts +0 -104
  78. package/src/upstream.ts +0 -85
  79. package/src/verbs/issue_labels.ts +0 -329
  80. package/src/verbs/link_issue.ts +0 -84
  81. package/src/verbs/release-analysis.ts +0 -210
  82. package/src/verbs/repo-tier.ts +0 -90
  83. package/src/verbs/report_ci_status.ts +0 -358
  84. package/src/verbs/report_release_prs.ts +0 -238
  85. package/src/verbs/report_security_alerts.ts +0 -442
  86. package/src/verbs/report_unlabeled.ts +0 -194
  87. package/src/verbs/rest-client.ts +0 -181
  88. package/src/verbs/review_thread.ts +0 -138
  89. package/tests/appauth.test.vitest.mts +0 -76
  90. package/tests/audit.test.vitest.mts +0 -48
  91. package/tests/catalog.test.vitest.mts +0 -180
  92. package/tests/config-merge.test.vitest.mts +0 -58
  93. package/tests/config.test.vitest.mts +0 -314
  94. package/tests/issue-labels.test.vitest.mts +0 -347
  95. package/tests/paths.test.vitest.mts +0 -52
  96. package/tests/release-prs.test.vitest.mts +0 -237
  97. package/tests/rest-client.test.vitest.mts +0 -115
  98. package/tests/router.test.vitest.mts +0 -399
  99. package/tests/run-vitest.mjs +0 -51
  100. package/tests/secret-store.test.vitest.mts +0 -106
  101. package/tests/secrets.test.vitest.mts +0 -155
  102. package/tests/security-alerts.test.vitest.mts +0 -328
  103. package/tests/server.test.vitest.mts +0 -20
  104. package/tests/upstream.test.vitest.mts +0 -91
  105. package/tests/verb-helpers.test.vitest.mts +0 -331
  106. package/tests/verbs.test.vitest.mts +0 -624
  107. package/tsconfig.json +0 -21
@@ -8,7 +8,7 @@
8
8
  * @Email: <Shinrai@users.noreply.github.com>
9
9
  * -----
10
10
  * @Last modified by: Nate Corcoran <CLDMV> (Shinrai@users.noreply.github.com)
11
- * @Last modified time: 2026-10-02T11:30:58-07:00 (1790965858)
11
+ * @Last modified time: 2026-10-31T12:58:15-07:00 (1793476695)
12
12
  * -----
13
13
  * @Copyright: Copyright (c) 2013-2026 Catalyzed Motivation Inc. All rights reserved.
14
14
  *
@@ -17,10 +17,11 @@
17
17
  /**
18
18
  * gh-broker installer — one command for first install AND upgrade.
19
19
  *
20
- * sudo node deploy/install.mjs [agent-user] [flags]
20
+ * sudo npx @cldmv/gh-broker [agent-user] [flags] (from npm)
21
+ * sudo node deploy/install.mjs [agent-user] [flags] (from a checkout)
21
22
  *
22
23
  * Prerequisite: node >= 20. Idempotent: re-run to upgrade (config regenerated by
23
- * merge, existing secrets kept). Interactive by default; pass flags plus
24
+ * layering, existing secrets kept). Interactive by default; pass flags plus
24
25
  * --non-interactive for a single unattended command. See --help and
25
26
  * docs/deploy-architecture.md.
26
27
  */
@@ -32,12 +33,16 @@ import {
32
33
  ensureServiceUser, createAgentUser, addUserToGroup,
33
34
  pointNodeSymlinks, currentNodeBin, ensureDirs, installUnit,
34
35
  enableService, restartService, serviceActive, deployDist, writeConfigFile,
35
- trySystemdCredDecrypt, systemdCredsEncrypt, ensureCredsDir, writeCredsDropin, removeCredsDropin
36
+ trySystemdCredDecrypt, systemdCredsEncrypt, ensureCredsDir, writeCredsDropin, removeCredsDropin, ensureDropDir
36
37
  } from "./lib/system.mjs";
37
38
  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";
39
+ import { buildConfig, readOperatorConfig, secretsBlock } from "./lib/config-merge.mjs";
40
+ import { writeSecretsFile, readSecretsFile, removeSecretsFile, referencedCredNames, optionalCredNames, detectSecretsProvider } from "./lib/secret-store.mjs";
41
+ import { parseArgs, NPM_SCOPE } from "./lib/install-args.mjs";
42
+ import { credentialPlan } from "./lib/credentials.mjs";
40
43
  import { smokeTest } from "./lib/smoke.mjs";
44
+ import { createNpmToken } from "./lib/npm-setup.mjs";
45
+ import { hostname } from "node:os";
41
46
 
42
47
  const REPO = dirname(dirname(fileURLToPath(import.meta.url)));
43
48
  const SVC_USER = "gh-broker";
@@ -64,59 +69,82 @@ function readJson(path) {
64
69
 
65
70
  const HELP = `gh-broker installer — install and upgrade in one command.
66
71
 
67
- Usage: sudo node deploy/install.mjs [agent-user] [flags]
72
+ Usage: sudo npx @cldmv/gh-broker [agent-user] [flags] (from npm)
73
+ sudo node deploy/install.mjs [agent-user] [flags] (from a checkout)
68
74
 
69
75
  Flags:
70
76
  --agent-user <name> the unprivileged user the agent runs as
71
77
  (also positional; defaults to $SUDO_USER)
72
78
  --secrets-provider <p> systemd-creds | file (skips the prompt)
73
79
  --secrets-file <path> JSON { "<cred-name>": "<token>" } to import tokens from
80
+ --secret <name>=<tok> one token on the command line (repeatable). Visible in
81
+ the process list and shell history: prefer the file or env
74
82
  --create-agent-user create the agent user if missing (no confirm)
83
+ --npm set up the npm integration (npm_scaffold), or rotate its
84
+ token: walks through an npm web login and creates the token
85
+ --npm-scope <@scope> scope npm_scaffold may create packages in (repeatable;
86
+ default: the existing setting, else @<first owner>)
87
+ --npm-org <org> also grant the new npm token org read-write on <org>
88
+ (repeatable; default: the existing setting, else none)
89
+ --npm-token-days <n> lifetime of a newly created npm token (default 90)
75
90
  -y, --non-interactive never prompt; fail if a value/decision is missing
91
+
92
+ Config (layered, lowest to highest: shipped defaults, the deployed config, --config,
93
+ these flags). An upgrade run with none of them keeps the deployed owners and ghcr as is.
94
+ --config <file> JSON config file in the deployed config's shape, merged on top
95
+ of the deployed one. Holds only "cred:" refs, never tokens;
96
+ no "tools" (the allowlist ships with the code)
97
+ --owner <org> GitHub owner (user or org) to serve; reads use cred:<org>-read,
98
+ writes cred:<org>-write (repeatable). A first install with no
99
+ owner asks for one, or fails under --non-interactive
100
+ --installation-id <org>=<id>
101
+ GitHub App installation id for an owner (repeatable)
102
+ --default-owner <org> owner whose read token serves calls that name no owner
103
+ (a first install with a single owner defaults to it)
104
+ --listen <unix:path> socket the broker listens on
105
+ --upstream <url> GitHub hosted MCP base URL
106
+ --app-id <n> GitHub App id (App-token-only verbs)
107
+ --app-private-key <cred-name>
108
+ credential holding the App private key (default gh-app-key)
109
+ --secrets-path <path> secrets file location for --secrets-provider file
110
+ --npm-registry <url> npm registry for npm_scaffold (https; with --npm)
111
+ --ghcr-owner <org> enable GHCR pushes for <org>; its classic PAT is cred:<org>-packages
112
+ --ghcr-username <login> GitHub login that owns that PAT (needs --ghcr-owner)
113
+ --ghcr-image <glob> image name or glob the owner may push, e.g. app or app-* (repeatable)
114
+ --ghcr-drop-dir <path> directory archives are read from (default /srv/gh-broker/ghcr-drop)
115
+ --ghcr-max-archive-bytes <n>
116
+ largest archive accepted (default 16 GiB)
117
+ --protect-release-tags, --no-protect-release-tags
118
+ refuse (or allow) overwriting 1.2.3 / v1.2.3 tags (default: refuse)
119
+ Several GHCR owners, or any other detail, go in a --config file.
76
120
  -h, --help this help
77
121
 
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 -> _).
122
+ Credentials: each token comes from, in order, --secret, --secrets-file, env
123
+ GH_BROKER_SECRET_<NAME> (NAME upper-cased, non-alnum -> _), and otherwise an
124
+ interactive prompt (input hidden). A token supplied by any of the first three
125
+ replaces a stored one (rotation). Optional tokens (npm-token) can be skipped.
80
126
  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 \\
127
+ sudo GH_BROKER_SECRET_ACME_READ=… GH_BROKER_SECRET_ACME_WRITE=… \\
128
+ /usr/local/bin/node deploy/install.mjs --agent-user repo --owner acme \\
83
129
  --secrets-provider systemd-creds --non-interactive`;
84
130
 
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);
131
+ const NPM_TOKEN_REF = "cred:npm-token";
132
+
133
+ /** parseArgs with installer-style failure (the parser itself throws). */
134
+ function parseArgsOrDie(argv) {
135
+ try {
136
+ return parseArgs(argv);
137
+ } catch (err) {
138
+ die(err.message);
105
139
  }
106
- return o;
107
140
  }
108
141
 
109
- const envSecretKey = (name) => "GH_BROKER_SECRET_" + name.toUpperCase().replace(/[^A-Z0-9]/g, "_");
110
-
111
142
  async function main() {
112
- const opts = parseArgs(process.argv.slice(2));
143
+ const opts = parseArgsOrDie(process.argv.slice(2));
113
144
  if (opts.help) {
114
145
  log(HELP);
115
146
  return;
116
147
  }
117
- if (opts.provider && opts.provider !== "systemd-creds" && opts.provider !== "file") {
118
- die(`--secrets-provider must be "systemd-creds" or "file"`);
119
- }
120
148
  const importTokens = opts.secretsFile ? readJson(opts.secretsFile) : {};
121
149
  // Must be a plain JSON object of { "<name>": "<token>" } — an array parses as an
122
150
  // "object" but yields no tokens, so reject it explicitly rather than importing nothing.
@@ -124,7 +152,16 @@ async function main() {
124
152
  die(`--secrets-file: ${opts.secretsFile} must be a JSON object of { "<name>": "<token>" }`);
125
153
  }
126
154
 
127
- if (!isRoot()) die("must run as root: sudo node deploy/install.mjs [agent-user] [flags]");
155
+ let operatorFile;
156
+ if (opts.configFile) {
157
+ try {
158
+ operatorFile = readOperatorConfig(opts.configFile, (f) => readFileSync(f, "utf8"));
159
+ } catch (err) {
160
+ die(err.message);
161
+ }
162
+ }
163
+
164
+ if (!isRoot()) die("must run as root: sudo npx @cldmv/gh-broker [agent-user] [flags], or sudo node deploy/install.mjs from a checkout");
128
165
  if (process.platform !== "linux") {
129
166
  die(`only Linux is implemented today (platform: ${process.platform}). The path/secret/service seams are ready; the impls are not.`);
130
167
  }
@@ -196,15 +233,53 @@ async function main() {
196
233
  log(` node symlink -> ${currentNodeBin()} (${linked.join(", ")})`);
197
234
 
198
235
  // --- config: merge(example, current) ---
199
- step("config (merge example + current)");
236
+ step("config (example + deployed + --config + flags)");
200
237
  const example = readJson(join(REPO, "config.example.json"));
201
238
  if (!example) die("config.example.json missing or invalid");
202
239
  const current = readJson(paths.config); // null on first install
203
- const merged = mergeConfig(example, current);
240
+ let merged;
241
+ try {
242
+ const built = await buildConfig({ example, current, file: operatorFile, opts, nonInteractive: opts.nonInteractive, ask: promptLine });
243
+ merged = built.config;
244
+ for (const n of built.notes) log(` ${n}`);
245
+ } catch (err) {
246
+ die(err.message);
247
+ }
248
+ if (merged.ghcr?.dropDir) {
249
+ ensureDropDir(merged.ghcr.dropDir, agentUser, SVC_GROUP);
250
+ log(` ghcr drop dir: ${merged.ghcr.dropDir} (2750 ${agentUser}:${SVC_GROUP})`);
251
+ }
252
+
253
+ // --- npm integration (npm_scaffold): opt-in ---
254
+ // Offered on a first interactive install; afterwards `--npm` enables it or, when it is
255
+ // already enabled, rotates the token. Either way a new token is created by driving the
256
+ // npm web login + token creation from here (see lib/npm-setup.mjs), unless the operator
257
+ // supplied one through --secrets-file/env.
258
+ step("npm integration");
259
+ let npmMint = false;
260
+ if (opts.npm || (!merged.npm && current === null && !opts.nonInteractive && (await confirm("Enable npm_scaffold (create new npm packages and their trusted publishers)?")))) {
261
+ let scopes = opts.npmScopes.length ? opts.npmScopes : merged.npm?.scopes;
262
+ if (!scopes?.length) {
263
+ const fallback = `@${Object.keys(merged.owners ?? {})[0] ?? ""}`.toLowerCase();
264
+ const answer = opts.nonInteractive ? fallback : (await promptLine(` npm scope(s) to allow, comma-separated [${fallback}]: `)) || fallback;
265
+ scopes = answer.split(",").map((s) => s.trim()).filter(Boolean);
266
+ }
267
+ for (const s of scopes) if (!NPM_SCOPE.test(s)) die(`npm scope must be a lowercase npm scope like "@acme", got "${s}"`);
268
+ const tokenOrgs = opts.npmOrgs.length ? opts.npmOrgs : (merged.npm?.tokenOrgs ?? []);
269
+ merged.npm = { ...(merged.npm ?? {}), token: merged.npm?.token ?? NPM_TOKEN_REF, scopes, ...(opts.npmRegistry ? { registry: opts.npmRegistry } : {}) };
270
+ if (tokenOrgs.length) merged.npm.tokenOrgs = tokenOrgs;
271
+ else delete merged.npm.tokenOrgs;
272
+ npmMint = true;
273
+ log(` ${current?.npm ? "rotating the npm token" : "enabling"} for ${scopes.join(", ")}${tokenOrgs.length ? ` (plus org read-write on ${tokenOrgs.join(", ")})` : ""}`);
274
+ } else {
275
+ log(merged.npm ? ` enabled for ${merged.npm.scopes.join(", ")} (rotate the token with --npm)` : " not enabled (enable with --npm)");
276
+ }
277
+ const npmTokenName = merged.npm?.token?.startsWith("cred:") ? merged.npm.token.slice("cred:".length) : undefined;
204
278
 
205
279
  // --- secrets: operator chooses the backend, then tokens are stored in it ---
206
280
  step("secrets");
207
281
  const needed = referencedCredNames(merged);
282
+ const optional = new Set(optionalCredNames(merged));
208
283
  const existingFile = readSecretsFile(paths.secrets); // {} if none
209
284
  const credsDir = join(configDir, "creds"); // systemd-creds location
210
285
  const priorProvider = merged.secrets?.provider ?? "systemd-creds";
@@ -237,77 +312,69 @@ async function main() {
237
312
  );
238
313
  }
239
314
  log(` backend: ${provider}`);
240
- merged.secrets = { provider, ...(merged.secrets?.path ? { path: merged.secrets.path } : {}) };
315
+ try {
316
+ merged.secrets = secretsBlock(provider, merged.secrets);
317
+ } catch (err) {
318
+ die(err.message);
319
+ }
241
320
 
242
321
  // The App private key (if config.app is ever set) is a multi-line PEM the
243
322
  // single-line prompt can't accept — it must be supplied out-of-band.
244
323
  const appKeyName = merged.app?.privateKey?.startsWith("cred:") ? merged.app.privateKey.slice("cred:".length) : undefined;
245
324
 
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
- };
325
+ let plan;
326
+ try {
327
+ plan = credentialPlan({
328
+ needed,
329
+ optional,
330
+ cliSecrets: opts.cliSecrets,
331
+ imported: importTokens ?? {},
332
+ env: process.env,
333
+ existingFile,
334
+ decrypt: (name) => trySystemdCredDecrypt(name, join(credsDir, `${name}.cred`)),
335
+ mints: (name) => name === npmTokenName && npmMint,
336
+ mint: async (name) => {
337
+ try {
338
+ const out = await createNpmToken({
339
+ ...(merged.npm.registry ? { registry: merged.npm.registry } : {}),
340
+ scopes: merged.npm.scopes,
341
+ ...(merged.npm.tokenOrgs?.length ? { orgs: merged.npm.tokenOrgs } : {}),
342
+ tokenName: `gh-broker ${hostname()} ${new Date().toISOString().slice(0, 10)}`,
343
+ ...(opts.npmTokenDays ? { days: opts.npmTokenDays } : {}),
344
+ promptHidden,
345
+ log
346
+ });
347
+ log(` ${name}: created${out.expires ? ` (expires ${out.expires.slice(0, 10)})` : ""}`);
348
+ return out.token;
349
+ } catch (err) {
350
+ throw new Error(`npm token setup failed: ${err.message}`);
351
+ }
352
+ },
353
+ appKeyName,
354
+ nonInteractive: opts.nonInteractive,
355
+ promptHidden,
356
+ log
357
+ });
280
358
 
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;
359
+ if (provider === "systemd-creds") {
360
+ ensureCredsDir(configDir, SVC_GROUP);
361
+ const stored = await plan.storeSystemd({
362
+ exists: (name) => existsSync(join(credsDir, `${name}.cred`)),
363
+ encrypt: (name, value) => systemdCredsEncrypt(name, value, join(credsDir, `${name}.cred`))
364
+ });
365
+ writeCredsDropin(stored, credsDir);
366
+ // Now that creds are encrypted, a plaintext secrets file from a previous
367
+ // `file` install must not linger — it would defeat "encrypted at rest".
368
+ if (existsSync(paths.secrets)) {
369
+ removeSecretsFile(paths.secrets);
370
+ log(` removed plaintext secrets file (now using systemd-creds)`);
293
371
  }
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);
372
+ } else {
373
+ writeSecretsFile(paths.secrets, await plan.storeFile(), { gid: requireGid(SVC_GROUP) });
374
+ removeCredsDropin(); // no systemd-creds delivery under the file provider
308
375
  }
309
- writeSecretsFile(paths.secrets, tokens, { gid: requireGid(SVC_GROUP) });
310
- removeCredsDropin(); // no systemd-creds delivery under the file provider
376
+ } catch (err) {
377
+ die(err.message);
311
378
  }
312
379
  writeConfigFile(paths.config, merged, SVC_GROUP);
313
380
 
package/deploy/install.sh CHANGED
File without changes
@@ -7,29 +7,43 @@
7
7
  * @Email: <Shinrai@users.noreply.github.com>
8
8
  * -----
9
9
  * @Last modified by: Nate Corcoran <CLDMV> (Shinrai@users.noreply.github.com)
10
- * @Last modified time: 2026-10-02T11:30:58-07:00 (1790965858)
10
+ * @Last modified time: 2026-11-01T00:40:44-07:00 (1793518844)
11
11
  * -----
12
12
  * @Copyright: Copyright (c) 2013-2026 Catalyzed Motivation Inc. All rights reserved.
13
13
  *
14
14
  */
15
15
 
16
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.
17
+ * Build the deployed config from four layers, lowest to highest precedence:
19
18
  *
20
- * new_config = mergeConfig(example_defaults, current_config)
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, ...).
21
23
  *
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.
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.
30
36
  *
31
- * On a first install (no current), the example is used as-is.
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.
32
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
+
33
47
  export function mergeConfig(example, current) {
34
48
  if (!example || typeof example !== "object" || Array.isArray(example)) throw new Error("mergeConfig: example config must be an object");
35
49
  if (!current) return structuredClone(example);
@@ -44,13 +58,157 @@ export function mergeConfig(example, current) {
44
58
  if (current[k] !== undefined) out[k] = current[k];
45
59
  }
46
60
 
47
- // owners: current entries win per-owner; example owners not in current are kept.
61
+ // owners: operator data. Current entries win per owner; any the example ships are kept.
48
62
  out.owners = { ...(example.owners ?? {}), ...(current.owners ?? {}) };
49
63
 
50
64
  // optional blocks: current wins if present.
51
- for (const k of ["app", "secrets"]) {
52
- if (current[k] !== undefined) out[k] = current[k];
65
+ for (const k of ["app", "secrets", "npm", "ghcr"]) {
66
+ if (current[k] !== undefined) out[k] = structuredClone(current[k]);
53
67
  }
54
68
 
55
69
  return out;
56
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
+ }