@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.
- package/LICENSE +202 -0
- package/README.md +279 -2
- package/config.example.json +294 -0
- package/config.sample.json +45 -0
- package/deploy/README.md +150 -0
- package/deploy/gh-broker.service +57 -0
- package/deploy/install.mjs +409 -0
- package/deploy/install.sh +29 -0
- package/deploy/lib/config-merge.mjs +214 -0
- 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/prompt.mjs +98 -0
- package/deploy/lib/secret-store.mjs +132 -0
- package/deploy/lib/smoke.mjs +137 -0
- package/deploy/lib/system.mjs +246 -0
- package/dist/index.mjs +24406 -0
- package/dist/index.mjs.map +7 -0
- package/docs/TOOLS.md +538 -0
- package/docs/agent-user-model.md +89 -0
- package/docs/changelog/v0/v0.2.0.md +26 -0
- package/docs/changelog/v0/v0.2.1.md +23 -0
- package/docs/changelog/v0/v0.2.2.md +23 -0
- package/docs/changelog/v0/v0.2.3.md +33 -0
- package/docs/changelog/v0/v0.2.4.md +25 -0
- package/docs/changelog/v0/v0.2.5.md +29 -0
- package/docs/changelog/v0/v0.3.0.md +84 -0
- package/docs/changelog/v0/v0.3.1.md +30 -0
- package/docs/changelog/v0/v0.3.2.md +24 -0
- package/docs/changelog/v0/v0.3.3.md +28 -0
- package/docs/changelog/v0/v0.4.0.md +58 -0
- package/docs/changelog/v0/v0.4.1.md +33 -0
- package/docs/changelog/v0/v0.4.2.md +45 -0
- package/docs/changelog/v0/v0.5.0.md +39 -0
- package/docs/changelog/v0/v0.5.1.md +28 -0
- package/docs/changelog/v0/v0.6.0.md +52 -0
- package/docs/changelog/v0/v0.6.1.md +48 -0
- package/docs/changelog/v0/v0.7.0.md +71 -0
- package/docs/changelog/v0/v0.8.0.md +115 -0
- package/docs/deploy-architecture.md +84 -0
- package/package.json +71 -5
- package/src/appauth.ts +56 -0
- package/src/audit.ts +34 -0
- package/src/availability.ts +42 -0
- package/src/catalog.ts +182 -0
- package/src/config.ts +157 -0
- package/src/index.ts +107 -0
- package/src/paths.ts +60 -0
- package/src/router.ts +299 -0
- package/src/secrets.ts +167 -0
- package/src/server.ts +91 -0
- package/src/types.ts +128 -0
- package/src/upstream.ts +85 -0
- 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/issue_labels.ts +329 -0
- package/src/verbs/link_issue.ts +84 -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 +212 -0
- package/src/verbs/repo-tier.ts +90 -0
- package/src/verbs/report_ci_status.ts +358 -0
- package/src/verbs/report_release_prs.ts +238 -0
- package/src/verbs/report_security_alerts.ts +442 -0
- package/src/verbs/report_unlabeled.ts +194 -0
- package/src/verbs/rest-client.ts +181 -0
- package/src/verbs/review_thread.ts +138 -0
- package/tests/appauth.test.vitest.mts +76 -0
- package/tests/audit.test.vitest.mts +48 -0
- package/tests/catalog.test.vitest.mts +234 -0
- package/tests/config-merge.test.vitest.mts +325 -0
- package/tests/config.test.vitest.mts +388 -0
- 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 +362 -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 +57 -0
- package/tests/prompt.test.vitest.mts +128 -0
- package/tests/release-prs.test.vitest.mts +247 -0
- package/tests/rest-client.test.vitest.mts +115 -0
- package/tests/router.test.vitest.mts +505 -0
- package/tests/run-vitest.mjs +51 -0
- package/tests/secret-store.test.vitest.mts +167 -0
- package/tests/secrets.test.vitest.mts +208 -0
- package/tests/security-alerts.test.vitest.mts +338 -0
- package/tests/server.test.vitest.mts +200 -0
- package/tests/smoke.test.vitest.mts +177 -0
- package/tests/system.test.vitest.mts +355 -0
- package/tests/upstream.test.vitest.mts +91 -0
- package/tests/verb-helpers.test.vitest.mts +377 -0
- package/tests/verbs.test.vitest.mts +624 -0
- 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
|
+
}
|