@cldmv/gh-broker 0.7.0 → 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.
- package/README.md +134 -40
- package/config.example.json +21 -8
- package/config.sample.json +45 -0
- package/deploy/README.md +73 -15
- package/deploy/install.mjs +166 -101
- package/deploy/install.sh +0 -0
- package/deploy/lib/config-merge.mjs +174 -16
- package/deploy/lib/credentials.mjs +143 -0
- package/deploy/lib/install-args.mjs +144 -0
- package/deploy/lib/npm-setup.mjs +164 -0
- package/deploy/lib/secret-store.mjs +20 -2
- package/deploy/lib/system.mjs +26 -2
- package/dist/index.mjs +1638 -202
- package/dist/index.mjs.map +4 -4
- package/docs/TOOLS.md +192 -1
- package/docs/changelog/v0/v0.8.0.md +115 -0
- package/docs/deploy-architecture.md +13 -11
- package/package.json +7 -7
- package/src/availability.ts +42 -0
- package/src/catalog.ts +18 -1
- package/src/config.ts +29 -2
- package/src/router.ts +43 -2
- package/src/secrets.ts +34 -4
- package/src/types.ts +25 -1
- package/src/verbs/ghcr/archive.ts +314 -0
- package/src/verbs/ghcr/config.ts +142 -0
- package/src/verbs/ghcr/jobs.ts +141 -0
- package/src/verbs/ghcr/push.ts +192 -0
- package/src/verbs/ghcr/registry.ts +275 -0
- package/src/verbs/ghcr/tar.ts +203 -0
- package/src/verbs/ghcr_push.ts +264 -0
- package/src/verbs/npm-registry.ts +168 -0
- package/src/verbs/npm-tarball.ts +76 -0
- package/src/verbs/npm_scaffold.ts +325 -0
- package/src/verbs/release-analysis.ts +3 -1
- package/tests/catalog.test.vitest.mts +55 -1
- package/tests/config-merge.test.vitest.mts +275 -8
- package/tests/config.test.vitest.mts +75 -1
- package/tests/credentials.test.vitest.mts +127 -0
- package/tests/ghcr-archive.test.vitest.mts +439 -0
- package/tests/ghcr-config.test.vitest.mts +96 -0
- package/tests/ghcr-push.test.vitest.mts +526 -0
- package/tests/ghcr-registry.test.vitest.mts +206 -0
- package/tests/helpers/ghcr-fixtures.mts +373 -0
- package/tests/install-args.test.vitest.mts +152 -0
- package/tests/issue-labels.test.vitest.mts +15 -0
- package/tests/npm-scaffold.test.vitest.mts +479 -0
- package/tests/npm-setup.test.vitest.mts +196 -0
- package/tests/paths.test.vitest.mts +5 -0
- package/tests/prompt.test.vitest.mts +128 -0
- package/tests/release-prs.test.vitest.mts +10 -0
- package/tests/router.test.vitest.mts +108 -2
- package/tests/secret-store.test.vitest.mts +64 -3
- package/tests/secrets.test.vitest.mts +53 -0
- package/tests/security-alerts.test.vitest.mts +10 -0
- package/tests/server.test.vitest.mts +187 -7
- package/tests/smoke.test.vitest.mts +177 -0
- package/tests/system.test.vitest.mts +355 -0
- package/tests/verb-helpers.test.vitest.mts +46 -0
- package/.configs/fix-headers.json +0 -3
- package/.configs/vitest.config.mjs +0 -44
- package/.github/dependabot.yml +0 -111
- package/.github/workflows/branch-retention.yml +0 -44
- package/.github/workflows/bundle-size.yml +0 -52
- package/.github/workflows/ci.yml +0 -389
- package/.github/workflows/cla.yml +0 -66
- package/.github/workflows/codeql.yml +0 -87
- package/.github/workflows/dependabot-auto-merge.yml +0 -62
- package/.github/workflows/dependabot-recreate.yml +0 -67
- package/.github/workflows/dependency-review.yml +0 -46
- package/.github/workflows/feature-pr.yml +0 -75
- package/.github/workflows/hotfix-redirector.yml +0 -67
- package/.github/workflows/hotfixes-release.yml +0 -70
- package/.github/workflows/labeler.yml +0 -48
- package/.github/workflows/master-commit-audit.yml +0 -56
- package/.github/workflows/member-auto-merge.yml +0 -75
- package/.github/workflows/next-release.yml +0 -76
- package/.github/workflows/next-reset.yml +0 -50
- package/.github/workflows/pr-notify.yml +0 -44
- package/.github/workflows/pr-title-normalizer.yml +0 -52
- package/.github/workflows/provenance.yml +0 -51
- package/.github/workflows/publish.yml +0 -146
- package/.github/workflows/release-merge.yml +0 -104
- package/.github/workflows/release-notify.yml +0 -41
- package/.github/workflows/scorecard.yml +0 -64
- package/.github/workflows/stale.yml +0 -50
- package/.github/workflows/tag-health.yml +0 -70
- package/.github/workflows/update-major-version-tags.yml +0 -94
- package/.github/workflows/v4-bootstrap.yml +0 -115
- package/.github/workflows/welcome.yml +0 -42
package/deploy/install.mjs
CHANGED
|
@@ -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-
|
|
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
|
*
|
|
@@ -20,7 +20,7 @@
|
|
|
20
20
|
* sudo node deploy/install.mjs [agent-user] [flags]
|
|
21
21
|
*
|
|
22
22
|
* Prerequisite: node >= 20. Idempotent: re-run to upgrade (config regenerated by
|
|
23
|
-
*
|
|
23
|
+
* layering, existing secrets kept). Interactive by default; pass flags plus
|
|
24
24
|
* --non-interactive for a single unattended command. See --help and
|
|
25
25
|
* docs/deploy-architecture.md.
|
|
26
26
|
*/
|
|
@@ -32,12 +32,16 @@ import {
|
|
|
32
32
|
ensureServiceUser, createAgentUser, addUserToGroup,
|
|
33
33
|
pointNodeSymlinks, currentNodeBin, ensureDirs, installUnit,
|
|
34
34
|
enableService, restartService, serviceActive, deployDist, writeConfigFile,
|
|
35
|
-
trySystemdCredDecrypt, systemdCredsEncrypt, ensureCredsDir, writeCredsDropin, removeCredsDropin
|
|
35
|
+
trySystemdCredDecrypt, systemdCredsEncrypt, ensureCredsDir, writeCredsDropin, removeCredsDropin, ensureDropDir
|
|
36
36
|
} from "./lib/system.mjs";
|
|
37
37
|
import { promptLine, promptHidden, confirm, choose } from "./lib/prompt.mjs";
|
|
38
|
-
import {
|
|
39
|
-
import { writeSecretsFile, readSecretsFile, removeSecretsFile, referencedCredNames, detectSecretsProvider } from "./lib/secret-store.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";
|
|
40
42
|
import { smokeTest } from "./lib/smoke.mjs";
|
|
43
|
+
import { createNpmToken } from "./lib/npm-setup.mjs";
|
|
44
|
+
import { hostname } from "node:os";
|
|
41
45
|
|
|
42
46
|
const REPO = dirname(dirname(fileURLToPath(import.meta.url)));
|
|
43
47
|
const SVC_USER = "gh-broker";
|
|
@@ -71,52 +75,74 @@ Flags:
|
|
|
71
75
|
(also positional; defaults to $SUDO_USER)
|
|
72
76
|
--secrets-provider <p> systemd-creds | file (skips the prompt)
|
|
73
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
|
|
74
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)
|
|
75
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.
|
|
76
118
|
-h, --help this help
|
|
77
119
|
|
|
78
|
-
|
|
79
|
-
|
|
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.
|
|
80
124
|
Example (unattended):
|
|
81
|
-
sudo
|
|
82
|
-
/usr/local/bin/node deploy/install.mjs --agent-user repo \\
|
|
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 \\
|
|
83
127
|
--secrets-provider systemd-creds --non-interactive`;
|
|
84
128
|
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
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);
|
|
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);
|
|
105
137
|
}
|
|
106
|
-
return o;
|
|
107
138
|
}
|
|
108
139
|
|
|
109
|
-
const envSecretKey = (name) => "GH_BROKER_SECRET_" + name.toUpperCase().replace(/[^A-Z0-9]/g, "_");
|
|
110
|
-
|
|
111
140
|
async function main() {
|
|
112
|
-
const opts =
|
|
141
|
+
const opts = parseArgsOrDie(process.argv.slice(2));
|
|
113
142
|
if (opts.help) {
|
|
114
143
|
log(HELP);
|
|
115
144
|
return;
|
|
116
145
|
}
|
|
117
|
-
if (opts.provider && opts.provider !== "systemd-creds" && opts.provider !== "file") {
|
|
118
|
-
die(`--secrets-provider must be "systemd-creds" or "file"`);
|
|
119
|
-
}
|
|
120
146
|
const importTokens = opts.secretsFile ? readJson(opts.secretsFile) : {};
|
|
121
147
|
// Must be a plain JSON object of { "<name>": "<token>" } — an array parses as an
|
|
122
148
|
// "object" but yields no tokens, so reject it explicitly rather than importing nothing.
|
|
@@ -124,6 +150,15 @@ async function main() {
|
|
|
124
150
|
die(`--secrets-file: ${opts.secretsFile} must be a JSON object of { "<name>": "<token>" }`);
|
|
125
151
|
}
|
|
126
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
|
+
|
|
127
162
|
if (!isRoot()) die("must run as root: sudo node deploy/install.mjs [agent-user] [flags]");
|
|
128
163
|
if (process.platform !== "linux") {
|
|
129
164
|
die(`only Linux is implemented today (platform: ${process.platform}). The path/secret/service seams are ready; the impls are not.`);
|
|
@@ -196,15 +231,53 @@ async function main() {
|
|
|
196
231
|
log(` node symlink -> ${currentNodeBin()} (${linked.join(", ")})`);
|
|
197
232
|
|
|
198
233
|
// --- config: merge(example, current) ---
|
|
199
|
-
step("config (
|
|
234
|
+
step("config (example + deployed + --config + flags)");
|
|
200
235
|
const example = readJson(join(REPO, "config.example.json"));
|
|
201
236
|
if (!example) die("config.example.json missing or invalid");
|
|
202
237
|
const current = readJson(paths.config); // null on first install
|
|
203
|
-
|
|
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;
|
|
204
276
|
|
|
205
277
|
// --- secrets: operator chooses the backend, then tokens are stored in it ---
|
|
206
278
|
step("secrets");
|
|
207
279
|
const needed = referencedCredNames(merged);
|
|
280
|
+
const optional = new Set(optionalCredNames(merged));
|
|
208
281
|
const existingFile = readSecretsFile(paths.secrets); // {} if none
|
|
209
282
|
const credsDir = join(configDir, "creds"); // systemd-creds location
|
|
210
283
|
const priorProvider = merged.secrets?.provider ?? "systemd-creds";
|
|
@@ -237,77 +310,69 @@ async function main() {
|
|
|
237
310
|
);
|
|
238
311
|
}
|
|
239
312
|
log(` backend: ${provider}`);
|
|
240
|
-
|
|
313
|
+
try {
|
|
314
|
+
merged.secrets = secretsBlock(provider, merged.secrets);
|
|
315
|
+
} catch (err) {
|
|
316
|
+
die(err.message);
|
|
317
|
+
}
|
|
241
318
|
|
|
242
319
|
// The App private key (if config.app is ever set) is a multi-line PEM the
|
|
243
320
|
// single-line prompt can't accept — it must be supplied out-of-band.
|
|
244
321
|
const appKeyName = merged.app?.privateKey?.startsWith("cred:") ? merged.app.privateKey.slice("cred:".length) : undefined;
|
|
245
322
|
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
};
|
|
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
|
+
});
|
|
280
356
|
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
//
|
|
289
|
-
|
|
290
|
-
if (
|
|
291
|
-
|
|
292
|
-
|
|
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)`);
|
|
293
369
|
}
|
|
294
|
-
|
|
295
|
-
|
|
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);
|
|
370
|
+
} else {
|
|
371
|
+
writeSecretsFile(paths.secrets, await plan.storeFile(), { gid: requireGid(SVC_GROUP) });
|
|
372
|
+
removeCredsDropin(); // no systemd-creds delivery under the file provider
|
|
308
373
|
}
|
|
309
|
-
|
|
310
|
-
|
|
374
|
+
} catch (err) {
|
|
375
|
+
die(err.message);
|
|
311
376
|
}
|
|
312
377
|
writeConfigFile(paths.config, merged, SVC_GROUP);
|
|
313
378
|
|
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
|
+
* @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
|
-
*
|
|
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
|
-
*
|
|
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
|
|
23
|
-
* - `tools` (the allowlist) is a SECURITY control
|
|
24
|
-
* removal
|
|
25
|
-
*
|
|
26
|
-
* -
|
|
27
|
-
*
|
|
28
|
-
*
|
|
29
|
-
*
|
|
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
|
-
*
|
|
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:
|
|
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
|
+
}
|