@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.
Files changed (90) hide show
  1. package/README.md +134 -40
  2. package/config.example.json +21 -8
  3. package/config.sample.json +45 -0
  4. package/deploy/README.md +73 -15
  5. package/deploy/install.mjs +166 -101
  6. package/deploy/install.sh +0 -0
  7. package/deploy/lib/config-merge.mjs +174 -16
  8. package/deploy/lib/credentials.mjs +143 -0
  9. package/deploy/lib/install-args.mjs +144 -0
  10. package/deploy/lib/npm-setup.mjs +164 -0
  11. package/deploy/lib/secret-store.mjs +20 -2
  12. package/deploy/lib/system.mjs +26 -2
  13. package/dist/index.mjs +1638 -202
  14. package/dist/index.mjs.map +4 -4
  15. package/docs/TOOLS.md +192 -1
  16. package/docs/changelog/v0/v0.8.0.md +115 -0
  17. package/docs/deploy-architecture.md +13 -11
  18. package/package.json +7 -7
  19. package/src/availability.ts +42 -0
  20. package/src/catalog.ts +18 -1
  21. package/src/config.ts +29 -2
  22. package/src/router.ts +43 -2
  23. package/src/secrets.ts +34 -4
  24. package/src/types.ts +25 -1
  25. package/src/verbs/ghcr/archive.ts +314 -0
  26. package/src/verbs/ghcr/config.ts +142 -0
  27. package/src/verbs/ghcr/jobs.ts +141 -0
  28. package/src/verbs/ghcr/push.ts +192 -0
  29. package/src/verbs/ghcr/registry.ts +275 -0
  30. package/src/verbs/ghcr/tar.ts +203 -0
  31. package/src/verbs/ghcr_push.ts +264 -0
  32. package/src/verbs/npm-registry.ts +168 -0
  33. package/src/verbs/npm-tarball.ts +76 -0
  34. package/src/verbs/npm_scaffold.ts +325 -0
  35. package/src/verbs/release-analysis.ts +3 -1
  36. package/tests/catalog.test.vitest.mts +55 -1
  37. package/tests/config-merge.test.vitest.mts +275 -8
  38. package/tests/config.test.vitest.mts +75 -1
  39. package/tests/credentials.test.vitest.mts +127 -0
  40. package/tests/ghcr-archive.test.vitest.mts +439 -0
  41. package/tests/ghcr-config.test.vitest.mts +96 -0
  42. package/tests/ghcr-push.test.vitest.mts +526 -0
  43. package/tests/ghcr-registry.test.vitest.mts +206 -0
  44. package/tests/helpers/ghcr-fixtures.mts +373 -0
  45. package/tests/install-args.test.vitest.mts +152 -0
  46. package/tests/issue-labels.test.vitest.mts +15 -0
  47. package/tests/npm-scaffold.test.vitest.mts +479 -0
  48. package/tests/npm-setup.test.vitest.mts +196 -0
  49. package/tests/paths.test.vitest.mts +5 -0
  50. package/tests/prompt.test.vitest.mts +128 -0
  51. package/tests/release-prs.test.vitest.mts +10 -0
  52. package/tests/router.test.vitest.mts +108 -2
  53. package/tests/secret-store.test.vitest.mts +64 -3
  54. package/tests/secrets.test.vitest.mts +53 -0
  55. package/tests/security-alerts.test.vitest.mts +10 -0
  56. package/tests/server.test.vitest.mts +187 -7
  57. package/tests/smoke.test.vitest.mts +177 -0
  58. package/tests/system.test.vitest.mts +355 -0
  59. package/tests/verb-helpers.test.vitest.mts +46 -0
  60. package/.configs/fix-headers.json +0 -3
  61. package/.configs/vitest.config.mjs +0 -44
  62. package/.github/dependabot.yml +0 -111
  63. package/.github/workflows/branch-retention.yml +0 -44
  64. package/.github/workflows/bundle-size.yml +0 -52
  65. package/.github/workflows/ci.yml +0 -389
  66. package/.github/workflows/cla.yml +0 -66
  67. package/.github/workflows/codeql.yml +0 -87
  68. package/.github/workflows/dependabot-auto-merge.yml +0 -62
  69. package/.github/workflows/dependabot-recreate.yml +0 -67
  70. package/.github/workflows/dependency-review.yml +0 -46
  71. package/.github/workflows/feature-pr.yml +0 -75
  72. package/.github/workflows/hotfix-redirector.yml +0 -67
  73. package/.github/workflows/hotfixes-release.yml +0 -70
  74. package/.github/workflows/labeler.yml +0 -48
  75. package/.github/workflows/master-commit-audit.yml +0 -56
  76. package/.github/workflows/member-auto-merge.yml +0 -75
  77. package/.github/workflows/next-release.yml +0 -76
  78. package/.github/workflows/next-reset.yml +0 -50
  79. package/.github/workflows/pr-notify.yml +0 -44
  80. package/.github/workflows/pr-title-normalizer.yml +0 -52
  81. package/.github/workflows/provenance.yml +0 -51
  82. package/.github/workflows/publish.yml +0 -146
  83. package/.github/workflows/release-merge.yml +0 -104
  84. package/.github/workflows/release-notify.yml +0 -41
  85. package/.github/workflows/scorecard.yml +0 -64
  86. package/.github/workflows/stale.yml +0 -50
  87. package/.github/workflows/tag-health.yml +0 -70
  88. package/.github/workflows/update-major-version-tags.yml +0 -94
  89. package/.github/workflows/v4-bootstrap.yml +0 -115
  90. package/.github/workflows/welcome.yml +0 -42
@@ -0,0 +1,325 @@
1
+ /**
2
+ *
3
+ * @Project: @cldmv/gh-broker
4
+ * @Filename: /src/verbs/npm_scaffold.ts
5
+ * @Date: 2026-10-31T10:57:49-07:00 (1793469469)
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:19-07:00 (1793476699)
11
+ * -----
12
+ * @Copyright: Copyright (c) 2013-2026 Catalyzed Motivation Inc. All rights reserved.
13
+ *
14
+ */
15
+
16
+ import type { Octokit } from "@octokit/core";
17
+ import { NpmOtpRequired, type NpmRegistry } from "./npm-registry.ts";
18
+ import { buildTarball, tarballDigests } from "./npm-tarball.ts";
19
+
20
+ /**
21
+ * Custom verb: npm_scaffold — create a new npm package and register its GitHub Actions
22
+ * trusted publisher, so the repo's own publish workflow can release it from then on.
23
+ *
24
+ * Why it exists: npm only accepts a trusted publisher on a package that already exists, and
25
+ * the first publish needs a human's 2FA. The broker holds a granular access token created
26
+ * WITHOUT 2FA bypass (by the installer), and the user supplies a one-time password for each
27
+ * call, which the agent passes through. The token alone can do nothing here.
28
+ *
29
+ * Guardrails, all checked before any npm write:
30
+ * - the package is under a scope listed in config.npm.scopes;
31
+ * - the repo belongs to the routed owner, is not archived, its package.json on the default
32
+ * branch names this package, and the publish workflow file exists there;
33
+ * - the package does not exist yet, or exists only as this verb's own placeholder (resume).
34
+ * An existing real package is refused, so the verb can never publish a version of one.
35
+ *
36
+ * The placeholder is built here from fixed fields (no agent-supplied files): a package.json
37
+ * marked `scaffoldedBy: "gh-broker"` and a README pointing at the repo. Access follows the
38
+ * repo's visibility (public repo → public package), the rule the CI publish also uses.
39
+ *
40
+ * Every npm write needs its own one-time password, so each call makes exactly one write:
41
+ * the first call publishes the placeholder and returns `needs_otp` for the trusted
42
+ * publisher; the second call (with the next code) adds it. A refused or expired code is
43
+ * not an error either: the result is `needs_otp` naming what is left.
44
+ */
45
+
46
+ export const PLACEHOLDER_VERSION = "0.0.0-stage";
47
+ export const SCAFFOLD_MARKER = "gh-broker";
48
+ const DEFAULT_WORKFLOW = "publish.yml";
49
+ const INPUT_KEYS = new Set(["owner", "repo", "package", "otp", "workflow"]);
50
+ const PACKAGE_NAME = /^@[a-z0-9][a-z0-9._~-]*\/[a-z0-9][a-z0-9._~-]*$/;
51
+
52
+ export interface NpmScaffoldInput {
53
+ owner: string;
54
+ repo: string;
55
+ /** Scoped package name, e.g. "@cldmv/new-thing". */
56
+ package: string;
57
+ /** One-time password from the user's authenticator. */
58
+ otp: string;
59
+ /** The repo's publish workflow file name. Defaults to publish.yml. */
60
+ workflow?: string;
61
+ }
62
+
63
+ export const NPM_SCAFFOLD_SCHEMA = {
64
+ type: "object" as const,
65
+ properties: {
66
+ owner: { type: "string", description: "Repository owner (a configured owner)." },
67
+ repo: { type: "string", description: "Repository whose package.json names the package." },
68
+ package: { type: "string", description: 'Scoped package name, e.g. "@cldmv/new-thing". Must be under a scope in config.npm.scopes.' },
69
+ otp: { type: "string", description: "One-time password the user read from their authenticator. Ask the user for it right before calling; codes expire in about 30 seconds." },
70
+ workflow: { type: "string", description: 'The repo\'s publish workflow file in .github/workflows. Default "publish.yml".' }
71
+ },
72
+ required: ["owner", "repo", "package", "otp"],
73
+ additionalProperties: false
74
+ };
75
+
76
+ export type NpmScaffoldStep = "publish" | "trust";
77
+
78
+ export interface NpmScaffoldResult {
79
+ status: "done" | "needs_otp";
80
+ package: string;
81
+ repository: string;
82
+ workflow: string;
83
+ access: "public" | "restricted";
84
+ /** Steps this call completed. */
85
+ done: NpmScaffoldStep[];
86
+ /** Steps still to do: only set with needs_otp. */
87
+ remaining?: NpmScaffoldStep[];
88
+ /** Steps skipped because an earlier call already completed them. */
89
+ skipped?: NpmScaffoldStep[];
90
+ version: string;
91
+ url: string;
92
+ trustId?: string;
93
+ message: string;
94
+ }
95
+
96
+ export interface NpmScaffoldDeps {
97
+ /** Read access to the repo (contents + metadata). */
98
+ octokit: Octokit;
99
+ registry: NpmRegistry;
100
+ scopes: string[];
101
+ }
102
+
103
+ const fail = (msg: string): never => {
104
+ throw new Error(`npm_scaffold: ${msg}`);
105
+ };
106
+
107
+ function validate(input: unknown): Required<NpmScaffoldInput> {
108
+ if (typeof input !== "object" || input === null || Array.isArray(input)) fail("arguments must be an object");
109
+ const a = input as Record<string, unknown>;
110
+ const unknown = Object.keys(a).filter((k) => !INPUT_KEYS.has(k));
111
+ if (unknown.length) fail(`unknown argument(s): ${unknown.join(", ")} — expected ${[...INPUT_KEYS].join(", ")}`);
112
+ if (typeof a.owner !== "string" || !/^[A-Za-z0-9-]+$/.test(a.owner)) fail('"owner" must be a GitHub owner name');
113
+ if (typeof a.repo !== "string" || !/^[A-Za-z0-9._-]+$/.test(a.repo)) fail('"repo" must be a repository name');
114
+ if (typeof a.package !== "string" || a.package.length > 214 || !PACKAGE_NAME.test(a.package)) fail('"package" must be a lowercase scoped npm package name, e.g. "@cldmv/new-thing"');
115
+ // Never echo the value: it is a live credential.
116
+ if (typeof a.otp !== "string" || !/^[0-9]{6,8}$/.test(a.otp)) fail('"otp" must be the 6-8 digit code from the authenticator');
117
+ if (a.workflow !== undefined && (typeof a.workflow !== "string" || !/^[A-Za-z0-9._-]+\.ya?ml$/.test(a.workflow))) {
118
+ fail('"workflow" must be a workflow file name in .github/workflows, e.g. "publish.yml"');
119
+ }
120
+ return {
121
+ owner: a.owner as string,
122
+ repo: a.repo as string,
123
+ package: a.package as string,
124
+ otp: a.otp as string,
125
+ workflow: (a.workflow as string | undefined) ?? DEFAULT_WORKFLOW
126
+ };
127
+ }
128
+
129
+ function statusOf(err: unknown): number | undefined {
130
+ return (err as { status?: number })?.status;
131
+ }
132
+
133
+ async function repoFile(octokit: Octokit, owner: string, repo: string, path: string, ref: string): Promise<string | undefined> {
134
+ try {
135
+ const res = await octokit.request("GET /repos/{owner}/{repo}/contents/{path}", { owner, repo, path, ref });
136
+ const d = res.data as { content?: string; encoding?: string; type?: string };
137
+ if (d.type !== "file" || typeof d.content !== "string") return undefined;
138
+ return d.encoding === "base64" ? Buffer.from(d.content, "base64").toString("utf8") : d.content;
139
+ } catch (err) {
140
+ if (statusOf(err) === 404) return undefined;
141
+ throw err;
142
+ }
143
+ }
144
+
145
+ interface RepoFacts {
146
+ repository: string;
147
+ isPrivate: boolean;
148
+ license?: string;
149
+ description?: string;
150
+ }
151
+
152
+ async function checkRepo(octokit: Octokit, args: Required<NpmScaffoldInput>): Promise<RepoFacts> {
153
+ let info: { full_name?: string; private?: boolean; archived?: boolean; default_branch?: string };
154
+ try {
155
+ info = (await octokit.request("GET /repos/{owner}/{repo}", { owner: args.owner, repo: args.repo })).data as typeof info;
156
+ } catch (err) {
157
+ if (statusOf(err) === 404) fail(`repository ${args.owner}/${args.repo} not found`);
158
+ throw err;
159
+ }
160
+ const asked = `${args.owner}/${args.repo}`;
161
+ const repository = info.full_name ?? asked;
162
+ // GitHub answers a renamed or transferred repo with a redirect, which the client follows.
163
+ // The trusted publisher must name the repo that was routed and checked, so refuse that.
164
+ if (repository.toLowerCase() !== asked.toLowerCase()) fail(`${asked} redirects to ${repository} (renamed or transferred) — call with the repository's current owner and name`);
165
+ if (info.archived) fail(`repository ${repository} is archived`);
166
+ const ref = info.default_branch ?? "HEAD";
167
+ const raw = await repoFile(octokit, args.owner, args.repo, "package.json", ref);
168
+ if (raw === undefined) fail(`${repository} has no package.json on ${ref}`);
169
+ let pkg: { name?: unknown; license?: unknown; description?: unknown; private?: unknown };
170
+ try {
171
+ pkg = JSON.parse(raw as string) as typeof pkg;
172
+ } catch {
173
+ return fail(`${repository}'s package.json on ${ref} is not valid JSON`);
174
+ }
175
+ if (pkg.name !== args.package) fail(`${repository}'s package.json on ${ref} names ${JSON.stringify(pkg.name)}, not "${args.package}"`);
176
+ if (pkg.private === true) fail(`${repository}'s package.json is marked "private": true, so it is not meant to be published`);
177
+ if ((await repoFile(octokit, args.owner, args.repo, `.github/workflows/${args.workflow}`, ref)) === undefined) {
178
+ fail(`${repository} has no .github/workflows/${args.workflow} on ${ref} — the trusted publisher must name the workflow that publishes`);
179
+ }
180
+ return {
181
+ repository,
182
+ isPrivate: info.private !== false,
183
+ ...(typeof pkg.license === "string" ? { license: pkg.license } : {}),
184
+ ...(typeof pkg.description === "string" ? { description: pkg.description } : {})
185
+ };
186
+ }
187
+
188
+ /** The registry document for the placeholder's first publish (the shape libnpmpublish sends). */
189
+ export function placeholderDocument(name: string, facts: RepoFacts, access: "public" | "restricted", registry: string) {
190
+ const repoUrl = `https://github.com/${facts.repository}`;
191
+ const manifest = {
192
+ name,
193
+ version: PLACEHOLDER_VERSION,
194
+ description: `Placeholder for ${name}. Releases are published from ${repoUrl} by CI.`,
195
+ license: facts.license ?? "UNLICENSED",
196
+ repository: { type: "git", url: `git+${repoUrl}.git` },
197
+ homepage: `${repoUrl}#readme`,
198
+ scaffoldedBy: SCAFFOLD_MARKER
199
+ };
200
+ const readme = `# ${name}\n\nThis version is a placeholder that reserves the package name so the repository's release workflow can publish it with npm trusted publishing.\n\n${facts.description ? `${facts.description}\n\n` : ""}Source and real releases: ${repoUrl}\n`;
201
+ const tarball = buildTarball([
202
+ { path: "package.json", content: JSON.stringify(manifest, null, "\t") + "\n" },
203
+ { path: "README.md", content: readme }
204
+ ]);
205
+ const { integrity, shasum } = tarballDigests(tarball);
206
+ const tarballName = `${name}-${PLACEHOLDER_VERSION}.tgz`;
207
+ const unscoped = name.split("/")[1]!;
208
+ return {
209
+ _id: name,
210
+ name,
211
+ description: manifest.description,
212
+ "dist-tags": { latest: PLACEHOLDER_VERSION },
213
+ access,
214
+ versions: {
215
+ [PLACEHOLDER_VERSION]: {
216
+ ...manifest,
217
+ readme,
218
+ _id: `${name}@${PLACEHOLDER_VERSION}`,
219
+ dist: { integrity, shasum, tarball: `${registry}/${name}/-/${unscoped}-${PLACEHOLDER_VERSION}.tgz` }
220
+ }
221
+ },
222
+ _attachments: { [tarballName]: { content_type: "application/octet-stream", data: tarball.toString("base64"), length: tarball.length } }
223
+ };
224
+ }
225
+
226
+ export async function npmScaffold(input: unknown, deps: NpmScaffoldDeps): Promise<NpmScaffoldResult> {
227
+ const args = validate(input);
228
+ const scope = args.package.split("/")[0]!;
229
+ if (!deps.scopes.includes(scope)) fail(`scope ${scope} is not in config.npm.scopes (${deps.scopes.join(", ")})`);
230
+
231
+ const facts = await checkRepo(deps.octokit, args);
232
+ const access = facts.isPrivate ? "restricted" : "public";
233
+ const base = {
234
+ package: args.package,
235
+ repository: facts.repository,
236
+ workflow: args.workflow,
237
+ access,
238
+ version: PLACEHOLDER_VERSION,
239
+ url: `https://www.npmjs.com/package/${args.package}`
240
+ } as const;
241
+
242
+ const existing = await deps.registry.packument(args.package);
243
+ if (!existing) {
244
+ try {
245
+ await deps.registry.publish(placeholderDocument(args.package, facts, access, deps.registry.registry), args.package, args.otp);
246
+ } catch (err) {
247
+ if (err instanceof NpmOtpRequired) {
248
+ return { ...base, status: "needs_otp", done: [], remaining: ["publish", "trust"], message: "npm refused the one-time password before publishing; nothing was written. Call again with a fresh code." };
249
+ }
250
+ throw err;
251
+ }
252
+ // That code is spent: every npm write needs its own one-time password, so don't
253
+ // send it again (a guaranteed refusal that counts as a failed 2FA attempt).
254
+ return {
255
+ ...base,
256
+ status: "needs_otp",
257
+ done: ["publish"],
258
+ remaining: ["trust"],
259
+ message: "The placeholder is published. Each npm write needs its own one-time password: call again with the next code (wait for the authenticator to change) to add the trusted publisher."
260
+ };
261
+ }
262
+
263
+ // The package exists: carry on only if it is this verb's own placeholder (a resumed call).
264
+ const versions = Object.keys(existing.versions ?? {});
265
+ const placeholder = existing.versions?.[PLACEHOLDER_VERSION];
266
+ const ours = versions.length === 1 && versions[0] === PLACEHOLDER_VERSION && placeholder?.scaffoldedBy === SCAFFOLD_MARKER;
267
+ if (!ours) fail(`${args.package} already exists on the registry (${versions.length} version(s)) and was not created by npm_scaffold — set up its trusted publisher on npmjs.com instead`);
268
+ // The placeholder records the repo it was made for; the trusted publisher may only go there.
269
+ const madeFor = (placeholder?.repository as { url?: unknown } | undefined)?.url;
270
+ if (typeof madeFor !== "string" || madeFor.toLowerCase() !== `git+https://github.com/${facts.repository}.git`.toLowerCase()) {
271
+ fail(`${args.package}'s placeholder was published for ${typeof madeFor === "string" ? madeFor : "an unrecorded repository"}, not ${facts.repository}`);
272
+ }
273
+
274
+ let out: Awaited<ReturnType<NpmRegistry["addGithubTrust"]>>;
275
+ try {
276
+ out = await deps.registry.addGithubTrust(args.package, { repository: facts.repository, file: args.workflow }, args.otp);
277
+ } catch (err) {
278
+ if (err instanceof NpmOtpRequired) {
279
+ return {
280
+ ...base,
281
+ status: "needs_otp",
282
+ done: [],
283
+ remaining: ["trust"],
284
+ skipped: ["publish"],
285
+ message: "The placeholder is published, but npm refused the one-time password for the trusted publisher (expired or already used). Call again with a new code to finish."
286
+ };
287
+ }
288
+ throw err;
289
+ }
290
+
291
+ let alreadyThere = false;
292
+ if (out.conflict) {
293
+ // npm already has a trusted publisher for this package. Only call it done if it is the
294
+ // one asked for. The code was spent on the POST, so the list may need another one.
295
+ const access = `https://www.npmjs.com/package/${args.package}/access`;
296
+ let configs: Awaited<ReturnType<NpmRegistry["listTrust"]>>;
297
+ try {
298
+ configs = await deps.registry.listTrust(args.package);
299
+ } catch (err) {
300
+ if (err instanceof NpmOtpRequired) fail(`${args.package} already has a trusted publisher, and npm needs another one-time password to show which; check it at ${access}`);
301
+ throw err;
302
+ }
303
+ const claimsOf = (c: Record<string, unknown>) => (c.claims ?? {}) as { repository?: unknown; workflow_ref?: { file?: unknown } };
304
+ const matches = configs.find((c) => {
305
+ const cl = claimsOf(c);
306
+ return c.type === "github" && typeof cl.repository === "string" && cl.repository.toLowerCase() === facts.repository.toLowerCase() && cl.workflow_ref?.file === args.workflow;
307
+ });
308
+ if (!matches) {
309
+ const existingList = configs.map((c) => `${String(c.type)} ${String(claimsOf(c).repository)} (${String(claimsOf(c).workflow_ref?.file)})`).join("; ") || "unreadable";
310
+ fail(`${args.package} already has a different trusted publisher: ${existingList}. Change it at ${access}`);
311
+ }
312
+ alreadyThere = true;
313
+ out = { config: matches, conflict: true };
314
+ }
315
+
316
+ const trustId = typeof out.config?.id === "string" ? out.config.id : undefined;
317
+ return {
318
+ ...base,
319
+ status: "done",
320
+ done: alreadyThere ? [] : ["trust"],
321
+ skipped: alreadyThere ? ["publish", "trust"] : ["publish"],
322
+ ...(trustId ? { trustId } : {}),
323
+ message: `${args.package} exists with a trusted publisher for ${facts.repository} (.github/workflows/${args.workflow}); its next release can publish through OIDC.`
324
+ };
325
+ }
@@ -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-04T16:41:24-07:00 (1791157284)
10
+ * @Last modified time: 2026-10-31T23:26:36-07:00 (1793514396)
11
11
  * -----
12
12
  * @Copyright: Copyright (c) 2013-2026 Catalyzed Motivation Inc. All rights reserved.
13
13
  *
@@ -135,6 +135,8 @@ export function assessFreshness(version: string, changelog: string, commits: Rel
135
135
  const missing = commits.filter((c) => !mentioned.has(c.number));
136
136
  const missingUserFacing = missing.filter((c) => !INTERNAL_TYPES.has(c.type)).map(({ number, title }) => ({ number, title }));
137
137
  const missingInternal = missing.filter((c) => INTERNAL_TYPES.has(c.type)).map(({ number, title }) => ({ number, title }));
138
+ // String.prototype.split always returns at least one element, so the "" fallback is unreachable; it only satisfies noUncheckedIndexedAccess.
139
+ /* v8 ignore next */
138
140
  const firstLine = changelog.split("\n", 1)[0] ?? "";
139
141
  const headingMatchesVersion = new RegExp(`v?${version.replace(/\./g, "\\.")}\\b`).test(firstLine);
140
142
  const readmeMentionsVersion = readme !== undefined && new RegExp(`Latest:\\s*v?${version.replace(/\./g, "\\.")}\\b`).test(readme);
@@ -11,7 +11,7 @@ describe("buildCatalog", () => {
11
11
  let errSpy: ReturnType<typeof vi.spyOn>;
12
12
 
13
13
  beforeEach(() => {
14
- secrets = { resolve: vi.fn(() => "tok") };
14
+ secrets = { resolve: vi.fn(() => "tok"), has: vi.fn(() => true) };
15
15
  upstream = { listToolsFor: vi.fn() };
16
16
  errSpy = vi.spyOn(console, "error").mockImplementation(() => {});
17
17
  });
@@ -74,6 +74,26 @@ describe("buildCatalog", () => {
74
74
  expect(catalog[0]?.inputSchema).toMatchObject({ type: "object", required: ["owner"] });
75
75
  });
76
76
 
77
+ it("does not expose npm_scaffold without an npm block, or when its token is not stored", async () => {
78
+ upstream.listToolsFor.mockResolvedValue([]);
79
+ const rules = [{ name: "npm_scaffold", mode: "write", custom: true }];
80
+ expect(await buildCatalog(makeConfig(rules), secrets as any, upstream as any)).toEqual([]);
81
+ expect(errSpy).toHaveBeenCalledWith(expect.stringMatching(/"npm_scaffold" is not exposed: the npm integration is not configured/));
82
+ secrets.has.mockReturnValue(false);
83
+ const noToken = { ...makeConfig(rules), npm: { token: "cred:npm-token", scopes: ["@cldmv"] } };
84
+ expect(await buildCatalog(noToken, secrets as any, upstream as any)).toEqual([]);
85
+ expect(errSpy).toHaveBeenCalledWith(expect.stringMatching(/"npm_scaffold" is not exposed: its credential "npm-token" is not stored/));
86
+ });
87
+
88
+ it("advertises npm_scaffold with its local schema", async () => {
89
+ upstream.listToolsFor.mockResolvedValue([]);
90
+ const catalog = await buildCatalog({ ...makeConfig([{ name: "npm_scaffold", mode: "write", custom: true }]), npm: { token: "cred:npm-token", scopes: ["@cldmv"] } }, secrets as any, upstream as any);
91
+ expect(catalog).toHaveLength(1);
92
+ expect(catalog[0]).toMatchObject({ name: "npm_scaffold" });
93
+ expect(catalog[0]?.description).toMatch(/one-time password/);
94
+ expect(catalog[0]?.inputSchema).toMatchObject({ type: "object", required: ["owner", "repo", "package", "otp"] });
95
+ });
96
+
77
97
  it("advertises issue_labels with its local schema", async () => {
78
98
  upstream.listToolsFor.mockResolvedValue([]);
79
99
  const config = makeConfig([{ name: "issue_labels", mode: "write", custom: true }]);
@@ -104,6 +124,12 @@ describe("buildCatalog", () => {
104
124
  expect(without[0]?.description).toMatch(/pass the full set/);
105
125
  });
106
126
 
127
+ it("warns on issue_write even when the upstream tool carries no description", async () => {
128
+ upstream.listToolsFor.mockResolvedValue([{ name: "issue_write", inputSchema: { type: "object" } }]);
129
+ const catalog = await buildCatalog(makeConfig([{ name: "issue_write", mode: "write" }]), secrets as any, upstream as any);
130
+ expect(catalog[0]?.description).toMatch(/^WARNING \(gh-broker\): "labels" REPLACES/);
131
+ });
132
+
107
133
  it("still warns on issue_write when the upstream fetch fails (minimal schema)", async () => {
108
134
  upstream.listToolsFor.mockRejectedValue(new Error("down"));
109
135
  const catalog = await buildCatalog(makeConfig([{ name: "issue_write", mode: "write" }]), secrets as any, upstream as any);
@@ -177,4 +203,32 @@ describe("buildCatalog", () => {
177
203
  expect(catalog.map((t) => t.name)).toEqual(["get_me"]);
178
204
  expect(errSpy).toHaveBeenCalledWith(expect.stringMatching(/timed out after 15ms/));
179
205
  });
206
+
207
+ it("advertises ghcr_push / ghcr_push_status / ghcr_tags with their local schemas", async () => {
208
+ upstream.listToolsFor.mockResolvedValue([]);
209
+ const ghcrTools = [
210
+ { name: "ghcr_push", mode: "write", custom: true },
211
+ { name: "ghcr_push_status", mode: "read", custom: true },
212
+ { name: "ghcr_tags", mode: "read", custom: true }
213
+ ];
214
+ const ghcr = { owners: { cldmv: { credential: "cred:cldmv-packages" }, other: { credential: "cred:other-packages" } } };
215
+ const config = { ...makeConfig(ghcrTools), ghcr };
216
+ // Exposed as soon as any one GHCR owner's credential is stored.
217
+ secrets.has.mockImplementation((ref: string) => ref === "cred:other-packages");
218
+ const catalog = await buildCatalog(config, secrets as any, upstream as any);
219
+ expect(catalog.map((t) => t.name)).toEqual(["ghcr_push", "ghcr_push_status", "ghcr_tags"]);
220
+ expect(catalog[0]?.inputSchema).toMatchObject({ required: ["owner", "image", "tag", "archive"], additionalProperties: false });
221
+ expect(catalog[0]?.description).toMatch(/background job/);
222
+ });
223
+
224
+ it("does not expose the GHCR tools without config.ghcr, or while no GHCR credential is stored", async () => {
225
+ upstream.listToolsFor.mockResolvedValue([]);
226
+ const rules = [{ name: "ghcr_push", mode: "write", custom: true }];
227
+ expect(await buildCatalog(makeConfig(rules), secrets as any, upstream as any)).toEqual([]);
228
+ expect(errSpy).toHaveBeenCalledWith(expect.stringMatching(/"ghcr_push" is not exposed: GHCR pushes are not configured/));
229
+ secrets.has.mockReturnValue(false);
230
+ const config = { ...makeConfig(rules), ghcr: { owners: { cldmv: { credential: "cred:cldmv-packages" } } } };
231
+ expect(await buildCatalog(config, secrets as any, upstream as any)).toEqual([]);
232
+ expect(errSpy).toHaveBeenCalledWith(expect.stringMatching(/no GHCR credential is stored \("cldmv-packages"; re-run the installer/));
233
+ });
180
234
  });