@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,143 @@
|
|
|
1
|
+
/**
|
|
2
|
+
*
|
|
3
|
+
* @Project: @cldmv/gh-broker
|
|
4
|
+
* @Filename: /deploy/lib/credentials.mjs
|
|
5
|
+
* @Date: 2026-10-31T12:53:32-07:00 (1793476412)
|
|
6
|
+
* @Author: Nate Corcoran <CLDMV>
|
|
7
|
+
* @Email: <Shinrai@users.noreply.github.com>
|
|
8
|
+
* -----
|
|
9
|
+
* @Last modified by: Nate Corcoran <CLDMV> (Shinrai@users.noreply.github.com)
|
|
10
|
+
* @Last modified time: 2026-10-31T12:58:15-07:00 (1793476695)
|
|
11
|
+
* -----
|
|
12
|
+
* @Copyright: Copyright (c) 2013-2026 Catalyzed Motivation Inc. All rights reserved.
|
|
13
|
+
*
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Where each credential's value comes from, and what gets stored, for the installer's
|
|
18
|
+
* secrets step. Kept out of install.mjs (which needs root and a systemd host) so the
|
|
19
|
+
* decisions are testable; the filesystem and systemd operations are passed in.
|
|
20
|
+
*
|
|
21
|
+
* Precedence for a value: explicit (--secret, then --secrets-file, then env
|
|
22
|
+
* GH_BROKER_SECRET_<NAME>) → a newly created npm token (when this run creates one) → the
|
|
23
|
+
* plaintext secrets file → decrypting an existing .cred → an interactive prompt. An explicit
|
|
24
|
+
* value, or a newly created token, replaces a stored one (rotation); otherwise a stored value
|
|
25
|
+
* is kept. Optional credentials (their tools are hidden without them) can be skipped and never
|
|
26
|
+
* fail the install. Every problem throws; install.mjs reports it and exits.
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
import { envSecretKey } from "./install-args.mjs";
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* @param {object} o
|
|
33
|
+
* @param {string[]} o.needed every cred name the config references
|
|
34
|
+
* @param {Set<string>} o.optional the ones that may be missing
|
|
35
|
+
* @param {Record<string, string>} o.cliSecrets --secret values
|
|
36
|
+
* @param {Record<string, unknown>} o.imported --secrets-file contents ({} when none)
|
|
37
|
+
* @param {Record<string, string | undefined>} o.env
|
|
38
|
+
* @param {Record<string, string>} o.existingFile current plaintext secrets file ({} when none)
|
|
39
|
+
* @param {(name: string) => string | undefined} o.decrypt an existing .cred's value, if any
|
|
40
|
+
* @param {(name: string) => boolean} o.mints whether this run creates `name` (the npm token)
|
|
41
|
+
* @param {(name: string) => Promise<string>} o.mint
|
|
42
|
+
* @param {string | undefined} o.appKeyName the App private key (multi-line; can't be prompted)
|
|
43
|
+
* @param {boolean} o.nonInteractive
|
|
44
|
+
* @param {(q: string) => Promise<string>} o.promptHidden
|
|
45
|
+
* @param {(m: string) => void} o.log
|
|
46
|
+
*/
|
|
47
|
+
export function credentialPlan(o) {
|
|
48
|
+
const unknown = Object.keys(o.cliSecrets).filter((n) => !o.needed.includes(n));
|
|
49
|
+
if (unknown.length) throw new Error(`--secret names no credential this config uses: ${unknown.join(", ")} (it uses: ${o.needed.join(", ")})`);
|
|
50
|
+
|
|
51
|
+
const explicit = (name) => {
|
|
52
|
+
for (const v of [o.cliSecrets[name], o.imported?.[name], o.env[envSecretKey(name)]]) {
|
|
53
|
+
if (typeof v === "string" && v) return v;
|
|
54
|
+
}
|
|
55
|
+
return undefined;
|
|
56
|
+
};
|
|
57
|
+
const minting = (name) => o.mints(name) && explicit(name) === undefined;
|
|
58
|
+
/** A new value is supplied for this run, so a stored one is replaced. */
|
|
59
|
+
const replaces = (name) => explicit(name) !== undefined || minting(name);
|
|
60
|
+
|
|
61
|
+
/** The value for `name`, or undefined for a skipped optional credential. */
|
|
62
|
+
const resolve = async (name) => {
|
|
63
|
+
const given = explicit(name);
|
|
64
|
+
if (given) return given;
|
|
65
|
+
if (minting(name)) {
|
|
66
|
+
if (o.nonInteractive) throw new Error(`creating the npm token needs a browser login and prompts — run interactively, or supply an existing token via --secret, --secrets-file or env ${envSecretKey(name)}`);
|
|
67
|
+
return o.mint(name);
|
|
68
|
+
}
|
|
69
|
+
const fromFile = o.existingFile[name];
|
|
70
|
+
if (typeof fromFile === "string" && fromFile) return fromFile;
|
|
71
|
+
const dec = o.decrypt(name);
|
|
72
|
+
if (dec) {
|
|
73
|
+
o.log(` ${name}: migrated from systemd-creds`);
|
|
74
|
+
return dec;
|
|
75
|
+
}
|
|
76
|
+
if (o.optional.has(name)) {
|
|
77
|
+
if (o.nonInteractive) return undefined;
|
|
78
|
+
return (await o.promptHidden(` ${name} (input hidden; Enter to skip — its tools stay hidden): `)) || undefined;
|
|
79
|
+
}
|
|
80
|
+
if (o.nonInteractive) throw new Error(`missing credential "${name}" — provide via --secret, --secrets-file or env ${envSecretKey(name)}`);
|
|
81
|
+
if (name === o.appKeyName) {
|
|
82
|
+
throw new Error(`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)}`);
|
|
83
|
+
}
|
|
84
|
+
const entered = await o.promptHidden(` ${name} (input hidden): `);
|
|
85
|
+
if (!entered) throw new Error(`empty value for "${name}"`);
|
|
86
|
+
return entered;
|
|
87
|
+
};
|
|
88
|
+
|
|
89
|
+
const skipped = (name) => o.log(` ${name}: not stored — its tools stay hidden until it is (re-run the installer to add it)`);
|
|
90
|
+
const replacedNote = (name) => {
|
|
91
|
+
if (minting(name)) o.log(` note: the previous ${name} is still valid until it expires — revoke it in your npm account's Access Tokens settings`);
|
|
92
|
+
};
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* systemd-creds backend. Returns the names that end up stored: only those go into the
|
|
96
|
+
* LoadCredentialEncrypted= drop-in, since a line for a missing file stops the unit.
|
|
97
|
+
* @param {{ exists: (name: string) => boolean, encrypt: (name: string, value: string) => void }} io
|
|
98
|
+
*/
|
|
99
|
+
const storeSystemd = async (io) => {
|
|
100
|
+
const stored = [];
|
|
101
|
+
for (const name of o.needed) {
|
|
102
|
+
const existed = io.exists(name);
|
|
103
|
+
// A plaintext entry (file → creds migration) or a new value re-encrypts; otherwise keep.
|
|
104
|
+
if (existed && !o.existingFile[name] && !replaces(name)) {
|
|
105
|
+
o.log(` ${name}: existing systemd-creds kept`);
|
|
106
|
+
stored.push(name);
|
|
107
|
+
continue;
|
|
108
|
+
}
|
|
109
|
+
// Only a missing optional cred can resolve to nothing here: a re-encrypt always has a
|
|
110
|
+
// value (the explicit one, the new npm token, or the plaintext entry).
|
|
111
|
+
const value = await resolve(name);
|
|
112
|
+
if (value === undefined) {
|
|
113
|
+
skipped(name);
|
|
114
|
+
continue;
|
|
115
|
+
}
|
|
116
|
+
io.encrypt(name, value);
|
|
117
|
+
stored.push(name);
|
|
118
|
+
o.log(` ${name}: ${existed ? "rotated" : "encrypted"} (systemd-creds)`);
|
|
119
|
+
if (existed) replacedNote(name);
|
|
120
|
+
}
|
|
121
|
+
return stored;
|
|
122
|
+
};
|
|
123
|
+
|
|
124
|
+
/** file backend: the token map to write. */
|
|
125
|
+
const storeFile = async () => {
|
|
126
|
+
const tokens = { ...o.existingFile };
|
|
127
|
+
for (const name of o.needed) {
|
|
128
|
+
if (tokens[name] && !replaces(name)) continue;
|
|
129
|
+
const had = Boolean(tokens[name]);
|
|
130
|
+
// As above, only a missing optional cred can resolve to nothing.
|
|
131
|
+
const value = await resolve(name);
|
|
132
|
+
if (value === undefined) {
|
|
133
|
+
skipped(name);
|
|
134
|
+
continue;
|
|
135
|
+
}
|
|
136
|
+
tokens[name] = value;
|
|
137
|
+
if (had) replacedNote(name);
|
|
138
|
+
}
|
|
139
|
+
return tokens;
|
|
140
|
+
};
|
|
141
|
+
|
|
142
|
+
return { explicit, replaces, resolve, storeSystemd, storeFile };
|
|
143
|
+
}
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
/**
|
|
2
|
+
*
|
|
3
|
+
* @Project: @cldmv/gh-broker
|
|
4
|
+
* @Filename: /deploy/lib/install-args.mjs
|
|
5
|
+
* @Date: 2026-10-31T12:31:29-07:00 (1793475089)
|
|
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:47-07:00 (1793518847)
|
|
11
|
+
* -----
|
|
12
|
+
* @Copyright: Copyright (c) 2013-2026 Catalyzed Motivation Inc. All rights reserved.
|
|
13
|
+
*
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* The installer's command-line parsing, kept apart from install.mjs (which needs root and
|
|
18
|
+
* a systemd host) so it can be tested. Every problem throws; install.mjs turns that into
|
|
19
|
+
* its usual fatal message.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
import { assertSafeCredName } from "./secret-store.mjs";
|
|
23
|
+
|
|
24
|
+
export const NPM_SCOPE = /^@[a-z0-9][a-z0-9._~-]*$/;
|
|
25
|
+
export const NPM_ORG = /^[a-z0-9][a-z0-9._~-]*$/;
|
|
26
|
+
/** A GitHub owner (user or org) name, lowercase, as config.owners keys must be. */
|
|
27
|
+
export const GH_OWNER = /^[a-z0-9](?:[a-z0-9-]{0,38})$/;
|
|
28
|
+
const GH_LOGIN = /^[A-Za-z0-9](?:[A-Za-z0-9-]{0,38})$/;
|
|
29
|
+
const IMAGE_GLOB = /^[a-z0-9*](?:[a-z0-9._*-]*[a-z0-9*])?(?:\/[a-z0-9*](?:[a-z0-9._*-]*[a-z0-9*])?)*$/;
|
|
30
|
+
|
|
31
|
+
/** env var that supplies the token for cred `name`: GH_BROKER_SECRET_<NAME>. */
|
|
32
|
+
export const envSecretKey = (name) => "GH_BROKER_SECRET_" + name.toUpperCase().replace(/[^A-Z0-9]/g, "_");
|
|
33
|
+
|
|
34
|
+
export function parseArgs(argv) {
|
|
35
|
+
const o = { positional: [], nonInteractive: false, createAgentUser: false, npm: false, npmScopes: [], npmOrgs: [], cliSecrets: {} };
|
|
36
|
+
// Consume the value after a value-taking flag, failing if it's missing or is
|
|
37
|
+
// itself a flag — so `--agent-user --secrets-provider file` can't silently take
|
|
38
|
+
// "--secrets-provider" as the username and proceed with an unintended user.
|
|
39
|
+
const value = (i, flag) => {
|
|
40
|
+
const v = argv[i];
|
|
41
|
+
if (v === undefined || v.startsWith("-")) throw new Error(`${flag} requires a value (see --help)`);
|
|
42
|
+
return v;
|
|
43
|
+
};
|
|
44
|
+
for (let i = 0; i < argv.length; i++) {
|
|
45
|
+
const a = argv[i];
|
|
46
|
+
if (a === "-h" || a === "--help") o.help = true;
|
|
47
|
+
else if (a === "-y" || a === "--non-interactive") o.nonInteractive = true;
|
|
48
|
+
else if (a === "--create-agent-user") o.createAgentUser = true;
|
|
49
|
+
else if (a === "--agent-user") o.agentUser = value(++i, a);
|
|
50
|
+
else if (a === "--secrets-provider") o.provider = value(++i, a);
|
|
51
|
+
else if (a === "--secrets-file") o.secretsFile = value(++i, a);
|
|
52
|
+
else if (a === "--secret") {
|
|
53
|
+
const pair = value(++i, a);
|
|
54
|
+
const eq = pair.indexOf("=");
|
|
55
|
+
if (eq <= 0 || eq === pair.length - 1) throw new Error("--secret takes <cred-name>=<token> (see --help)");
|
|
56
|
+
o.cliSecrets[assertSafeCredName(pair.slice(0, eq))] = pair.slice(eq + 1);
|
|
57
|
+
} else if (a === "--npm") o.npm = true;
|
|
58
|
+
else if (a === "--npm-scope") {
|
|
59
|
+
const s = value(++i, a);
|
|
60
|
+
if (!NPM_SCOPE.test(s)) throw new Error(`--npm-scope must be a lowercase npm scope like "@cldmv", got "${s}"`);
|
|
61
|
+
o.npmScopes.push(s);
|
|
62
|
+
} else if (a === "--npm-org") {
|
|
63
|
+
const org = value(++i, a);
|
|
64
|
+
if (!NPM_ORG.test(org)) throw new Error(`--npm-org must be a lowercase npm org name like "cldmv", got "${org}"`);
|
|
65
|
+
o.npmOrgs.push(org);
|
|
66
|
+
} else if (a === "--npm-token-days") {
|
|
67
|
+
const days = value(++i, a);
|
|
68
|
+
if (!/^[1-9][0-9]{0,2}$/.test(days)) throw new Error("--npm-token-days must be a whole number of days");
|
|
69
|
+
o.npmTokenDays = Number(days);
|
|
70
|
+
} else if (a === "--config") o.configFile = value(++i, a);
|
|
71
|
+
else if (a === "--owner") {
|
|
72
|
+
const org = value(++i, a);
|
|
73
|
+
if (!GH_OWNER.test(org)) throw new Error(`--owner must be a lowercase GitHub owner name like "acme", got "${org}"`);
|
|
74
|
+
(o.owners ??= []).push(org);
|
|
75
|
+
} else if (a === "--installation-id") {
|
|
76
|
+
const pair = value(++i, a);
|
|
77
|
+
const eq = pair.indexOf("=");
|
|
78
|
+
const org = pair.slice(0, eq);
|
|
79
|
+
const id = pair.slice(eq + 1);
|
|
80
|
+
if (eq <= 0 || !GH_OWNER.test(org) || !/^[1-9][0-9]{0,14}$/.test(id)) throw new Error("--installation-id takes <owner>=<numeric id> (see --help)");
|
|
81
|
+
(o.installationIds ??= {})[org] = Number(id);
|
|
82
|
+
} else if (a === "--default-owner") {
|
|
83
|
+
const org = value(++i, a);
|
|
84
|
+
if (!GH_OWNER.test(org)) throw new Error(`--default-owner must be a lowercase GitHub owner name like "acme", got "${org}"`);
|
|
85
|
+
o.defaultOwner = org;
|
|
86
|
+
} else if (a === "--listen") {
|
|
87
|
+
const addr = value(++i, a);
|
|
88
|
+
if (!/^unix:\/.+/.test(addr)) throw new Error(`--listen must be a "unix:<absolute path>" address, got "${addr}"`);
|
|
89
|
+
o.listen = addr;
|
|
90
|
+
} else if (a === "--upstream") {
|
|
91
|
+
const url = value(++i, a);
|
|
92
|
+
if (!/^https?:\/\/\S+$/.test(url)) throw new Error(`--upstream must be an http(s) URL, got "${url}"`);
|
|
93
|
+
o.upstream = url;
|
|
94
|
+
} else if (a === "--app-id") {
|
|
95
|
+
const id = value(++i, a);
|
|
96
|
+
if (!/^[1-9][0-9]{0,14}$/.test(id)) throw new Error("--app-id must be the numeric GitHub App id");
|
|
97
|
+
o.appId = Number(id);
|
|
98
|
+
} else if (a === "--app-private-key") o.appPrivateKey = assertSafeCredName(value(++i, a).replace(/^cred:/, ""));
|
|
99
|
+
else if (a === "--secrets-path") {
|
|
100
|
+
const path = value(++i, a);
|
|
101
|
+
if (!path.startsWith("/")) throw new Error(`--secrets-path must be an absolute path, got "${path}"`);
|
|
102
|
+
o.secretsPath = path;
|
|
103
|
+
} else if (a === "--npm-registry") {
|
|
104
|
+
const url = value(++i, a);
|
|
105
|
+
if (!/^https:\/\/\S+$/.test(url)) throw new Error(`--npm-registry must be an https URL, got "${url}"`);
|
|
106
|
+
o.npmRegistry = url;
|
|
107
|
+
} else if (a === "--ghcr-owner") {
|
|
108
|
+
const org = value(++i, a);
|
|
109
|
+
if (!GH_OWNER.test(org)) throw new Error(`--ghcr-owner must be a lowercase GitHub owner name like "acme", got "${org}"`);
|
|
110
|
+
o.ghcrOwner = org;
|
|
111
|
+
} else if (a === "--ghcr-username") {
|
|
112
|
+
const login = value(++i, a);
|
|
113
|
+
if (!GH_LOGIN.test(login)) throw new Error(`--ghcr-username must be a GitHub login, got "${login}"`);
|
|
114
|
+
o.ghcrUsername = login;
|
|
115
|
+
} else if (a === "--ghcr-image") {
|
|
116
|
+
const img = value(++i, a);
|
|
117
|
+
if (img.length > 255 || !IMAGE_GLOB.test(img)) throw new Error(`--ghcr-image must be a lowercase image name or glob like "app" or "app-*", got "${img}"`);
|
|
118
|
+
(o.ghcrImages ??= []).push(img);
|
|
119
|
+
} else if (a === "--ghcr-drop-dir") {
|
|
120
|
+
const dir = value(++i, a);
|
|
121
|
+
if (!dir.startsWith("/")) throw new Error(`--ghcr-drop-dir must be an absolute path, got "${dir}"`);
|
|
122
|
+
o.ghcrDropDir = dir;
|
|
123
|
+
} else if (a === "--ghcr-max-archive-bytes") {
|
|
124
|
+
const n = value(++i, a);
|
|
125
|
+
if (!/^[1-9][0-9]{0,15}$/.test(n) || !Number.isSafeInteger(Number(n))) throw new Error("--ghcr-max-archive-bytes must be a positive whole number of bytes");
|
|
126
|
+
o.ghcrMaxArchiveBytes = Number(n);
|
|
127
|
+
} else if (a === "--protect-release-tags") o.protectReleaseTags = true;
|
|
128
|
+
else if (a === "--no-protect-release-tags") o.protectReleaseTags = false;
|
|
129
|
+
else if (a.startsWith("-")) throw new Error(`unknown flag: ${a} (see --help)`);
|
|
130
|
+
else o.positional.push(a);
|
|
131
|
+
}
|
|
132
|
+
if (o.provider !== undefined && o.provider !== "systemd-creds" && o.provider !== "file") {
|
|
133
|
+
throw new Error(`--secrets-provider must be "systemd-creds" or "file"`);
|
|
134
|
+
}
|
|
135
|
+
if (o.npmRegistry !== undefined && !o.npm) throw new Error("--npm-registry only applies together with --npm");
|
|
136
|
+
if (o.appPrivateKey !== undefined && o.appId === undefined) throw new Error("--app-private-key needs --app-id");
|
|
137
|
+
if (o.ghcrOwner !== undefined && (o.ghcrUsername === undefined || !o.ghcrImages)) {
|
|
138
|
+
throw new Error("--ghcr-owner needs --ghcr-username and at least one --ghcr-image");
|
|
139
|
+
}
|
|
140
|
+
if (o.ghcrOwner === undefined && (o.ghcrUsername !== undefined || o.ghcrImages)) {
|
|
141
|
+
throw new Error("--ghcr-username and --ghcr-image need --ghcr-owner (several GHCR owners go in a --config file)");
|
|
142
|
+
}
|
|
143
|
+
return o;
|
|
144
|
+
}
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
/**
|
|
2
|
+
*
|
|
3
|
+
* @Project: @cldmv/gh-broker
|
|
4
|
+
* @Filename: /deploy/lib/npm-setup.mjs
|
|
5
|
+
* @Date: 2026-10-31T10:58:54-07:00 (1793469534)
|
|
6
|
+
* @Author: Nate Corcoran <CLDMV>
|
|
7
|
+
* @Email: <Shinrai@users.noreply.github.com>
|
|
8
|
+
* -----
|
|
9
|
+
* @Last modified by: Nate Corcoran <CLDMV> (Shinrai@users.noreply.github.com)
|
|
10
|
+
* @Last modified time: 2026-10-31T12:58:17-07:00 (1793476697)
|
|
11
|
+
* -----
|
|
12
|
+
* @Copyright: Copyright (c) 2013-2026 Catalyzed Motivation Inc. All rights reserved.
|
|
13
|
+
*
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Creates the npm granular access token npm_scaffold uses, the way the npm CLI would, so the
|
|
18
|
+
* installer can drive it end to end:
|
|
19
|
+
*
|
|
20
|
+
* 1. web login POST /-/v1/login → show the login URL, poll the done URL until approved
|
|
21
|
+
* (what `npm login --auth-type=web` does). Yields a short-lived session.
|
|
22
|
+
* 2. token create POST /-/npm/v1/tokens with the account password and an OTP, both
|
|
23
|
+
* prompted for and held only for that request (what `npm token create`
|
|
24
|
+
* does). Scope read-write, an expiry, and NO 2FA bypass, so the token
|
|
25
|
+
* cannot write anything without a fresh OTP.
|
|
26
|
+
* 3. cleanup DELETE /-/user/token/<session>, on every path (what `npm logout` does).
|
|
27
|
+
*
|
|
28
|
+
* Nothing here logs or returns the password, the OTP or the session token; the new token is
|
|
29
|
+
* returned to the caller, which stores it in the secret backend.
|
|
30
|
+
*/
|
|
31
|
+
|
|
32
|
+
export const DEFAULT_NPM_REGISTRY = "https://registry.npmjs.org";
|
|
33
|
+
export const DEFAULT_TOKEN_DAYS = 90;
|
|
34
|
+
const MAX_ATTEMPTS = 3;
|
|
35
|
+
|
|
36
|
+
const isHttpsUrl = (u) => {
|
|
37
|
+
try {
|
|
38
|
+
return new URL(u).protocol === "https:";
|
|
39
|
+
} catch {
|
|
40
|
+
return false;
|
|
41
|
+
}
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
45
|
+
|
|
46
|
+
async function readJson(res) {
|
|
47
|
+
const text = await res.text();
|
|
48
|
+
try {
|
|
49
|
+
return JSON.parse(text);
|
|
50
|
+
} catch {
|
|
51
|
+
return { _raw: text.slice(0, 300) };
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const detail = (body) => {
|
|
56
|
+
const msg = [body?.error, body?.message, body?.reason, body?._raw].find((v) => typeof v === "string" && v);
|
|
57
|
+
return msg ? `: ${msg.slice(0, 300)}` : "";
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
/** npm's own signal for "this request needs a (valid) OTP": a 401 naming otp. */
|
|
61
|
+
export function wantsOtp(res, body) {
|
|
62
|
+
if (res.status !== 401) return false;
|
|
63
|
+
const auth = (res.headers.get("www-authenticate") ?? "").toLowerCase();
|
|
64
|
+
return auth.includes("otp") || /one-time pass/i.test(JSON.stringify(body ?? ""));
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* @param {object} o
|
|
69
|
+
* @param {string} [o.registry]
|
|
70
|
+
* @param {string[]} o.scopes e.g. ["@cldmv"]
|
|
71
|
+
* @param {string[]} [o.orgs] npm orgs to also grant read-write on (org permission)
|
|
72
|
+
* @param {string} o.tokenName shown in the npm token list
|
|
73
|
+
* @param {number} [o.days] token lifetime
|
|
74
|
+
* @param {(q: string) => Promise<string>} o.promptHidden
|
|
75
|
+
* @param {(m: string) => void} o.log
|
|
76
|
+
* @param {typeof fetch} [o.fetch]
|
|
77
|
+
* @param {number} [o.pollTimeoutMs] how long to wait for the browser approval
|
|
78
|
+
* @returns {Promise<{ token: string, expires?: string }>}
|
|
79
|
+
*/
|
|
80
|
+
export async function createNpmToken(o) {
|
|
81
|
+
const registry = (o.registry ?? DEFAULT_NPM_REGISTRY).replace(/\/+$/, "");
|
|
82
|
+
const doFetch = o.fetch ?? globalThis.fetch;
|
|
83
|
+
const days = o.days ?? DEFAULT_TOKEN_DAYS;
|
|
84
|
+
const session = await webLogin(registry, doFetch, o);
|
|
85
|
+
try {
|
|
86
|
+
return await createToken(registry, doFetch, session, { ...o, days });
|
|
87
|
+
} finally {
|
|
88
|
+
try {
|
|
89
|
+
const res = await doFetch(`${registry}/-/user/token/${encodeURIComponent(session)}`, {
|
|
90
|
+
method: "DELETE",
|
|
91
|
+
headers: { authorization: `Bearer ${session}`, "npm-command": "logout" }
|
|
92
|
+
});
|
|
93
|
+
o.log(res.status >= 200 && res.status < 300 ? " npm login session revoked" : ` note: could not revoke the npm login session (HTTP ${res.status}); it expires on its own`);
|
|
94
|
+
} catch (err) {
|
|
95
|
+
o.log(` note: could not revoke the npm login session (${err.message}); it expires on its own`);
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
async function webLogin(registry, doFetch, o) {
|
|
101
|
+
const res = await doFetch(`${registry}/-/v1/login`, {
|
|
102
|
+
method: "POST",
|
|
103
|
+
headers: { "content-type": "application/json", accept: "application/json", "npm-auth-type": "web", "npm-command": "login" },
|
|
104
|
+
body: "{}"
|
|
105
|
+
});
|
|
106
|
+
const body = await readJson(res);
|
|
107
|
+
if (res.status < 200 || res.status >= 300 || !isHttpsUrl(body.loginUrl) || !isHttpsUrl(body.doneUrl)) {
|
|
108
|
+
throw new Error(`npm web login could not start (HTTP ${res.status})${detail(body)}`);
|
|
109
|
+
}
|
|
110
|
+
o.log(` Log in to npm in a browser (as the account that owns ${o.scopes.join(", ")}):\n\n ${body.loginUrl}\n\n Waiting for the login to be approved…`);
|
|
111
|
+
const deadline = Date.now() + (o.pollTimeoutMs ?? 10 * 60_000);
|
|
112
|
+
for (;;) {
|
|
113
|
+
const poll = await doFetch(body.doneUrl, { method: "GET", headers: { accept: "application/json", "npm-command": "login" } });
|
|
114
|
+
const content = await readJson(poll);
|
|
115
|
+
if (poll.status === 200) {
|
|
116
|
+
if (typeof content.token !== "string" || !content.token) throw new Error("npm web login finished without a session");
|
|
117
|
+
o.log(" npm login approved");
|
|
118
|
+
return content.token;
|
|
119
|
+
}
|
|
120
|
+
if (poll.status !== 202) throw new Error(`npm web login failed (HTTP ${poll.status})${detail(content)}`);
|
|
121
|
+
if (Date.now() > deadline) throw new Error("npm web login timed out waiting for approval");
|
|
122
|
+
const retry = Number(poll.headers.get("retry-after"));
|
|
123
|
+
await sleep(Number.isFinite(retry) && retry > 0 ? retry * 1000 : 1000);
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
async function createToken(registry, doFetch, session, o) {
|
|
128
|
+
let password = await o.promptHidden(" npm account password (input hidden, used once, not stored): ");
|
|
129
|
+
let otp = await o.promptHidden(" npm one-time password (input hidden): ");
|
|
130
|
+
const request = {
|
|
131
|
+
name: o.tokenName,
|
|
132
|
+
scopes: o.scopes,
|
|
133
|
+
packages_and_scopes_permission: "read-write",
|
|
134
|
+
...(o.orgs?.length ? { orgs: o.orgs, orgs_permission: "read-write" } : {}),
|
|
135
|
+
expires: o.days
|
|
136
|
+
};
|
|
137
|
+
for (let attempt = 1; ; attempt++) {
|
|
138
|
+
const res = await doFetch(`${registry}/-/npm/v1/tokens`, {
|
|
139
|
+
method: "POST",
|
|
140
|
+
headers: {
|
|
141
|
+
authorization: `Bearer ${session}`,
|
|
142
|
+
"content-type": "application/json",
|
|
143
|
+
accept: "application/json",
|
|
144
|
+
"npm-command": "token",
|
|
145
|
+
...(otp ? { "npm-otp": otp } : {})
|
|
146
|
+
},
|
|
147
|
+
body: JSON.stringify({ ...request, password })
|
|
148
|
+
});
|
|
149
|
+
const body = await readJson(res);
|
|
150
|
+
if (res.status >= 200 && res.status < 300) {
|
|
151
|
+
if (typeof body.token !== "string" || !body.token) throw new Error("npm created a token but did not return it");
|
|
152
|
+
return { token: body.token, ...(typeof body.expires === "string" ? { expires: body.expires } : {}) };
|
|
153
|
+
}
|
|
154
|
+
if (attempt >= MAX_ATTEMPTS) throw new Error(`npm token creation failed (HTTP ${res.status})${detail(body)}`);
|
|
155
|
+
if (wantsOtp(res, body)) {
|
|
156
|
+
otp = await o.promptHidden(" npm rejected the one-time password (expired?). New one-time password (input hidden): ");
|
|
157
|
+
} else if (res.status === 401 || res.status === 403) {
|
|
158
|
+
password = await o.promptHidden(" npm rejected the password. npm account password (input hidden): ");
|
|
159
|
+
otp = await o.promptHidden(" npm one-time password (input hidden): ");
|
|
160
|
+
} else {
|
|
161
|
+
throw new Error(`npm token creation failed (HTTP ${res.status})${detail(body)}`);
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
}
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
/**
|
|
2
|
+
*
|
|
3
|
+
* @Project: @cldmv/gh-broker
|
|
4
|
+
* @Filename: /deploy/lib/prompt.mjs
|
|
5
|
+
* @Date: 2026-09-06T10:05:35-07:00 (1788714335)
|
|
6
|
+
* @Author: Nate Corcoran <CLDMV>
|
|
7
|
+
* @Email: <Shinrai@users.noreply.github.com>
|
|
8
|
+
* -----
|
|
9
|
+
* @Last modified by: Nate Corcoran <CLDMV> (Shinrai@users.noreply.github.com)
|
|
10
|
+
* @Last modified time: 2026-10-02T11:30:58-07:00 (1790965858)
|
|
11
|
+
* -----
|
|
12
|
+
* @Copyright: Copyright (c) 2013-2026 Catalyzed Motivation Inc. All rights reserved.
|
|
13
|
+
*
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import { stdin, stdout } from "node:process";
|
|
17
|
+
|
|
18
|
+
// Control codes, by number, so no literal control characters live in this file.
|
|
19
|
+
const LF = 10; // Enter
|
|
20
|
+
const CR = 13; // Enter
|
|
21
|
+
const EOT = 4; // Ctrl-D
|
|
22
|
+
const ETX = 3; // Ctrl-C
|
|
23
|
+
const DEL = 127; // Backspace
|
|
24
|
+
const BS = 8; // Backspace
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Raw-mode line reader. `echo` controls whether typed characters are shown:
|
|
28
|
+
* visible prompts echo them, secret prompts don't. Handles Enter, Ctrl-C (abort),
|
|
29
|
+
* Ctrl-D, and Backspace. Falls back to non-raw when stdin isn't a TTY.
|
|
30
|
+
*/
|
|
31
|
+
function readLine(question, echo) {
|
|
32
|
+
return new Promise((resolve, reject) => {
|
|
33
|
+
stdout.write(question);
|
|
34
|
+
let input = "";
|
|
35
|
+
const onData = (chunk) => {
|
|
36
|
+
const s = chunk.toString("utf8");
|
|
37
|
+
for (const ch of s) {
|
|
38
|
+
const code = ch.charCodeAt(0);
|
|
39
|
+
if (code === LF || code === CR || code === EOT) {
|
|
40
|
+
cleanup();
|
|
41
|
+
stdout.write("\n");
|
|
42
|
+
return resolve(input.trim());
|
|
43
|
+
}
|
|
44
|
+
if (code === ETX) {
|
|
45
|
+
cleanup();
|
|
46
|
+
stdout.write("\n");
|
|
47
|
+
return reject(new Error("aborted"));
|
|
48
|
+
}
|
|
49
|
+
if (code === DEL || code === BS) {
|
|
50
|
+
if (input.length > 0) {
|
|
51
|
+
input = input.slice(0, -1);
|
|
52
|
+
if (echo) stdout.write("\b \b");
|
|
53
|
+
}
|
|
54
|
+
continue;
|
|
55
|
+
}
|
|
56
|
+
input += ch;
|
|
57
|
+
if (echo) stdout.write(ch);
|
|
58
|
+
}
|
|
59
|
+
};
|
|
60
|
+
const cleanup = () => {
|
|
61
|
+
stdin.removeListener("data", onData);
|
|
62
|
+
if (stdin.isTTY) stdin.setRawMode(false);
|
|
63
|
+
stdin.pause();
|
|
64
|
+
};
|
|
65
|
+
if (stdin.isTTY) stdin.setRawMode(true);
|
|
66
|
+
stdin.resume();
|
|
67
|
+
stdin.on("data", onData);
|
|
68
|
+
});
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** Ask a visible question; typed characters are echoed. Returns the trimmed answer. */
|
|
72
|
+
export function promptLine(question) {
|
|
73
|
+
return readLine(question, true);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** Ask for a secret; typed characters are NOT echoed. Never prints the value. */
|
|
77
|
+
export function promptHidden(question) {
|
|
78
|
+
return readLine(question, false);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** Yes/no confirmation. Defaults to no unless the answer starts with y/Y. */
|
|
82
|
+
export async function confirm(question) {
|
|
83
|
+
const a = await promptLine(`${question} [y/N] `);
|
|
84
|
+
return /^y(es)?$/i.test(a);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Present numbered options ({ key, label }) and return the chosen key.
|
|
89
|
+
* Empty input picks the 1-based default `def`.
|
|
90
|
+
*/
|
|
91
|
+
export async function choose(question, options, def = 1) {
|
|
92
|
+
stdout.write(`${question}\n`);
|
|
93
|
+
options.forEach((o, i) => stdout.write(` ${i + 1}) ${o.label}\n`));
|
|
94
|
+
const a = await promptLine(`Choose [${def}]: `);
|
|
95
|
+
const n = a === "" ? def : parseInt(a, 10);
|
|
96
|
+
const idx = Number.isInteger(n) && n >= 1 && n <= options.length ? n - 1 : def - 1;
|
|
97
|
+
return options[idx].key;
|
|
98
|
+
}
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
/**
|
|
2
|
+
*
|
|
3
|
+
* @Project: @cldmv/gh-broker
|
|
4
|
+
* @Filename: /deploy/lib/secret-store.mjs
|
|
5
|
+
* @Date: 2026-09-06T10:05:35-07:00 (1788714335)
|
|
6
|
+
* @Author: Nate Corcoran <CLDMV>
|
|
7
|
+
* @Email: <Shinrai@users.noreply.github.com>
|
|
8
|
+
* -----
|
|
9
|
+
* @Last modified by: Nate Corcoran <CLDMV> (Shinrai@users.noreply.github.com)
|
|
10
|
+
* @Last modified time: 2026-10-31T12:12:11-07:00 (1793473931)
|
|
11
|
+
* -----
|
|
12
|
+
* @Copyright: Copyright (c) 2013-2026 Catalyzed Motivation Inc. All rights reserved.
|
|
13
|
+
*
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import { writeFileSync, chmodSync, chownSync, readFileSync, existsSync, statSync, rmSync } from "node:fs";
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Write the secrets map ({ "<name>": "<token>" }) to `path` as JSON, mode 0640,
|
|
20
|
+
* owned by uid:gid (root:<service-group>) so the service user reads it and the
|
|
21
|
+
* agent user (not in that group, dir 0750) cannot. The file provider counterpart
|
|
22
|
+
* to src/secrets.ts. Never logs a value.
|
|
23
|
+
*/
|
|
24
|
+
export function writeSecretsFile(path, secrets, { uid = 0, gid, mode = 0o640 } = {}) {
|
|
25
|
+
const json = JSON.stringify(secrets, null, "\t") + "\n";
|
|
26
|
+
writeFileSync(path, json, { mode });
|
|
27
|
+
chmodSync(path, mode);
|
|
28
|
+
if (gid !== undefined) chownSync(path, uid, gid);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Read an existing secrets file so an upgrade preserves tokens the operator isn't
|
|
33
|
+
* re-entering. Returns {} if the file is absent or unreadable.
|
|
34
|
+
*/
|
|
35
|
+
export function readSecretsFile(path) {
|
|
36
|
+
if (!existsSync(path)) return {};
|
|
37
|
+
try {
|
|
38
|
+
const v = JSON.parse(readFileSync(path, "utf8"));
|
|
39
|
+
if (!v || typeof v !== "object" || Array.isArray(v)) return {};
|
|
40
|
+
// Keep only non-empty string values — a non-string (number/object) must never
|
|
41
|
+
// be treated as a token (it would crash encryption or be written back invalid).
|
|
42
|
+
const out = {};
|
|
43
|
+
for (const [name, val] of Object.entries(v)) if (typeof val === "string" && val.length > 0) out[name] = val;
|
|
44
|
+
return out;
|
|
45
|
+
} catch {
|
|
46
|
+
return {};
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Remove a plaintext secrets file — used when the operator switches to the
|
|
52
|
+
* systemd-creds provider, so the tokens don't linger on disk and defeat
|
|
53
|
+
* "encrypted at rest". Best-effort zero-overwrite before unlink. No-op if absent.
|
|
54
|
+
*/
|
|
55
|
+
export function removeSecretsFile(path) {
|
|
56
|
+
if (!existsSync(path)) return;
|
|
57
|
+
try {
|
|
58
|
+
writeFileSync(path, Buffer.alloc(statSync(path).size, 0));
|
|
59
|
+
} catch {
|
|
60
|
+
// overwrite is best-effort; unlink still happens below
|
|
61
|
+
}
|
|
62
|
+
rmSync(path, { force: true });
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const SAFE_CRED_NAME = /^[A-Za-z0-9._-]+$/;
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Constrain a credential name to a safe charset — it becomes a `.cred` filename
|
|
69
|
+
* and is interpolated into a systemd drop-in line written as root, so a name with
|
|
70
|
+
* path separators, "..", colons, or control characters must be rejected.
|
|
71
|
+
*/
|
|
72
|
+
export function assertSafeCredName(name) {
|
|
73
|
+
if (name === "." || name === ".." || !SAFE_CRED_NAME.test(name)) {
|
|
74
|
+
throw new Error(`gh-broker: unsafe credential name "${name}" (allowed: letters, digits, ".", "_", "-")`);
|
|
75
|
+
}
|
|
76
|
+
return name;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* The distinct cred names a config's owners, app and ghcr owners reference (strips the "cred:"
|
|
81
|
+
* prefix). Validates each name — this is the single source feeding the encrypted
|
|
82
|
+
* `.cred` filenames and the systemd drop-in, so hardening here covers both.
|
|
83
|
+
*/
|
|
84
|
+
export function referencedCredNames(config) {
|
|
85
|
+
const names = new Set();
|
|
86
|
+
const add = (ref) => {
|
|
87
|
+
if (typeof ref === "string" && ref.startsWith("cred:")) names.add(assertSafeCredName(ref.slice("cred:".length)));
|
|
88
|
+
};
|
|
89
|
+
for (const owner of Object.values(config.owners ?? {})) {
|
|
90
|
+
add(owner.read);
|
|
91
|
+
add(owner.write);
|
|
92
|
+
}
|
|
93
|
+
if (config.app) add(config.app.privateKey);
|
|
94
|
+
if (config.npm) add(config.npm.token);
|
|
95
|
+
for (const owner of Object.values(config.ghcr?.owners ?? {})) add(owner.credential);
|
|
96
|
+
return [...names];
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* The referenced cred names that are OPTIONAL: the broker hides the tools that need them
|
|
101
|
+
* when they aren't stored (src/availability.ts) instead of refusing to start, so the
|
|
102
|
+
* installer may skip them: the npm token and the GHCR credentials. The owners' PATs and
|
|
103
|
+
* the App key stay required.
|
|
104
|
+
*/
|
|
105
|
+
export function optionalCredNames(config) {
|
|
106
|
+
const names = [];
|
|
107
|
+
const add = (ref) => {
|
|
108
|
+
if (typeof ref === "string" && ref.startsWith("cred:")) names.push(assertSafeCredName(ref.slice("cred:".length)));
|
|
109
|
+
};
|
|
110
|
+
add(config.npm?.token);
|
|
111
|
+
for (const owner of Object.values(config.ghcr?.owners ?? {})) add(owner.credential);
|
|
112
|
+
return names;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Works out which secrets backend an EXISTING install already uses, so a re-run keeps it
|
|
117
|
+
* instead of asking again. Precedence: the provider recorded in the deployed config, then
|
|
118
|
+
* what is actually on disk (encrypted `.cred` files → systemd-creds; tokens in the secrets
|
|
119
|
+
* file → file). Returns undefined when it is a first install, or when both backends have
|
|
120
|
+
* credentials and nothing is recorded (ambiguous — the caller should ask).
|
|
121
|
+
*
|
|
122
|
+
* @param {{ configured?: unknown, credNames?: string[], fileTokenNames?: string[] }} found
|
|
123
|
+
* @returns {"systemd-creds" | "file" | undefined}
|
|
124
|
+
*/
|
|
125
|
+
export function detectSecretsProvider({ configured, credNames = [], fileTokenNames = [] } = {}) {
|
|
126
|
+
if (configured === "systemd-creds" || configured === "file") return configured;
|
|
127
|
+
const hasCreds = credNames.length > 0;
|
|
128
|
+
const hasFile = fileTokenNames.length > 0;
|
|
129
|
+
if (hasCreds && !hasFile) return "systemd-creds";
|
|
130
|
+
if (hasFile && !hasCreds) return "file";
|
|
131
|
+
return undefined;
|
|
132
|
+
}
|