@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
|
@@ -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
|
+
}
|
|
@@ -7,7 +7,7 @@
|
|
|
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-
|
|
10
|
+
* @Last modified time: 2026-10-31T12:12:11-07:00 (1793473931)
|
|
11
11
|
* -----
|
|
12
12
|
* @Copyright: Copyright (c) 2013-2026 Catalyzed Motivation Inc. All rights reserved.
|
|
13
13
|
*
|
|
@@ -77,7 +77,7 @@ export function assertSafeCredName(name) {
|
|
|
77
77
|
}
|
|
78
78
|
|
|
79
79
|
/**
|
|
80
|
-
* The distinct cred names a config's owners
|
|
80
|
+
* The distinct cred names a config's owners, app and ghcr owners reference (strips the "cred:"
|
|
81
81
|
* prefix). Validates each name — this is the single source feeding the encrypted
|
|
82
82
|
* `.cred` filenames and the systemd drop-in, so hardening here covers both.
|
|
83
83
|
*/
|
|
@@ -91,9 +91,27 @@ export function referencedCredNames(config) {
|
|
|
91
91
|
add(owner.write);
|
|
92
92
|
}
|
|
93
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);
|
|
94
96
|
return [...names];
|
|
95
97
|
}
|
|
96
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
|
+
|
|
97
115
|
/**
|
|
98
116
|
* Works out which secrets backend an EXISTING install already uses, so a re-run keeps it
|
|
99
117
|
* instead of asking again. Precedence: the provider recorded in the deployed config, then
|
package/deploy/lib/system.mjs
CHANGED
|
@@ -7,14 +7,14 @@
|
|
|
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-
|
|
10
|
+
* @Last modified time: 2026-10-31T23:26:29-07:00 (1793514389)
|
|
11
11
|
* -----
|
|
12
12
|
* @Copyright: Copyright (c) 2013-2026 Catalyzed Motivation Inc. All rights reserved.
|
|
13
13
|
*
|
|
14
14
|
*/
|
|
15
15
|
|
|
16
16
|
import { spawnSync } from "node:child_process";
|
|
17
|
-
import { existsSync, mkdirSync, chmodSync, chownSync, writeFileSync, cpSync, rmSync, symlinkSync, realpathSync } from "node:fs";
|
|
17
|
+
import { existsSync, mkdirSync, chmodSync, chownSync, lstatSync, writeFileSync, cpSync, rmSync, symlinkSync, realpathSync } from "node:fs";
|
|
18
18
|
import { join, dirname } from "node:path";
|
|
19
19
|
|
|
20
20
|
/** spawnSync wrapper: throws on non-zero unless opts.allowFail. Returns the result. */
|
|
@@ -171,9 +171,33 @@ export function currentNodeBin() {
|
|
|
171
171
|
function mkdirp(path, mode, uid, gid) {
|
|
172
172
|
if (!existsSync(path)) mkdirSync(path, { recursive: true, mode });
|
|
173
173
|
chmodSync(path, mode);
|
|
174
|
+
// Every caller passes a resolved uid and gid (requireUid/requireGid throw otherwise), so the skipped-chown branch is unreachable.
|
|
175
|
+
/* v8 ignore else */
|
|
174
176
|
if (uid !== undefined && gid !== undefined) chownSync(path, uid, gid);
|
|
175
177
|
}
|
|
176
178
|
|
|
179
|
+
/**
|
|
180
|
+
* Create the GHCR drop directory: `2750 <agentUser>:<svcGroup>`. The agent (owner) writes
|
|
181
|
+
* archives there; the setgid bit gives every new file the service group, so the broker can
|
|
182
|
+
* read a group-readable archive while other users can't see in. A missing parent is created
|
|
183
|
+
* `0755 root:root`; an existing parent is never chmod-ed. Refuses a symlink at the path.
|
|
184
|
+
*/
|
|
185
|
+
export function ensureDropDir(path, agentUser, svcGroup) {
|
|
186
|
+
const parent = dirname(path);
|
|
187
|
+
if (!existsSync(parent)) mkdirSync(parent, { recursive: true, mode: 0o755 });
|
|
188
|
+
let st;
|
|
189
|
+
try {
|
|
190
|
+
st = lstatSync(path); // lstat: a symlink (even a dangling one) is seen as itself
|
|
191
|
+
} catch {
|
|
192
|
+
st = undefined;
|
|
193
|
+
}
|
|
194
|
+
if (!st) mkdirSync(path, { mode: 0o750 });
|
|
195
|
+
else if (st.isSymbolicLink()) throw new Error(`gh-broker install: ${path} is a symlink — refusing to use it as the GHCR drop dir`);
|
|
196
|
+
else if (!st.isDirectory()) throw new Error(`gh-broker install: ${path} exists and is not a directory`);
|
|
197
|
+
chownSync(path, requireUid(agentUser), requireGid(svcGroup));
|
|
198
|
+
chmodSync(path, 0o2750); // after chown: chown clears the setgid bit
|
|
199
|
+
}
|
|
200
|
+
|
|
177
201
|
/** Create /etc/gh-broker (0750 root:group) and /opt/gh-broker (0755 svcUser:group). */
|
|
178
202
|
export function ensureDirs(configDir, appDir, svcUser, svcGroup) {
|
|
179
203
|
const gid = requireGid(svcGroup);
|