@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,264 @@
1
+ /**
2
+ *
3
+ * @Project: @cldmv/gh-broker
4
+ * @Filename: /src/verbs/ghcr_push.ts
5
+ * @Date: 2026-10-31T11:02:57-07:00 (1793469777)
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-31T13:14:41-07:00 (1793477681)
11
+ * -----
12
+ * @Copyright: Copyright (c) 2013-2026 Catalyzed Motivation Inc. All rights reserved.
13
+ *
14
+ */
15
+
16
+ /**
17
+ * Custom verbs: ghcr_push, ghcr_push_status, ghcr_tags — push container images to GHCR
18
+ * (ghcr.io/<owner>/<image>) without the agent ever holding a registry credential.
19
+ *
20
+ * The agent writes a `docker save` archive into the drop directory and calls `ghcr_push`.
21
+ * The broker checks the call against `config.ghcr` (owner configured, image on the owner's
22
+ * allowlist, tag well-formed, archive really inside the drop directory, release tag not
23
+ * already taken), exchanges the owner's classic PAT for a registry token, and pushes in a
24
+ * background job. `ghcr_push_status` reports that job; `ghcr_tags` lists an allowlisted
25
+ * image's tags so a push can be verified.
26
+ *
27
+ * What the verbs can't do, by construction: talk to any registry but ghcr.io, push outside
28
+ * `ghcr.io/<owner>/<allowlisted image>`, delete anything, change visibility, or read a
29
+ * file outside the drop directory. No result, error, audit line or log line carries the
30
+ * PAT or the registry token (registry.ts `clean()`).
31
+ *
32
+ * The handlers are the real contract: the MCP server doesn't validate against the
33
+ * advertised schemas, so every key and type is checked here before any network call.
34
+ */
35
+
36
+ import type { SecretStore } from "../types.ts";
37
+ import { audit } from "../audit.ts";
38
+ import { imageAllowed, type GhcrConfig, type GhcrOwnerConfig } from "./ghcr/config.ts";
39
+ import { openArchive, planImage } from "./ghcr/archive.ts";
40
+ import { Registry, GHCR_BASE } from "./ghcr/registry.ts";
41
+ import { pushImage, RELEASE_TAG } from "./ghcr/push.ts";
42
+ import { JobQueue, type Job } from "./ghcr/jobs.ts";
43
+
44
+ /** OCI repository path component; `/` separates components. */
45
+ const IMAGE = /^[a-z0-9]+(?:(?:\.|_|__|-+)[a-z0-9]+)*(?:\/[a-z0-9]+(?:(?:\.|_|__|-+)[a-z0-9]+)*)*$/;
46
+ /** OCI tag grammar. */
47
+ const TAG = /^[A-Za-z0-9_][A-Za-z0-9._-]{0,127}$/;
48
+ const JOB_ID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/;
49
+
50
+ const PUSH_KEYS = new Set(["owner", "image", "tag", "archive"]);
51
+ const STATUS_KEYS = new Set(["owner", "job"]);
52
+ const TAGS_KEYS = new Set(["owner", "image"]);
53
+
54
+ export interface GhcrPushInput {
55
+ owner: string;
56
+ image: string;
57
+ tag: string;
58
+ archive: string;
59
+ }
60
+ export interface GhcrPushStatusInput {
61
+ owner: string;
62
+ job?: string;
63
+ }
64
+ export interface GhcrTagsInput {
65
+ owner: string;
66
+ image: string;
67
+ }
68
+
69
+ function strictArgs(verb: string, input: unknown, keys: Set<string>): Record<string, unknown> {
70
+ if (typeof input !== "object" || input === null || Array.isArray(input)) throw new Error(`${verb}: arguments must be an object`);
71
+ const a = input as Record<string, unknown>;
72
+ const unknown = Object.keys(a).filter((k) => !keys.has(k));
73
+ if (unknown.length) throw new Error(`${verb}: unknown argument(s): ${unknown.join(", ")} — expected ${[...keys].join(", ")}`);
74
+ if (typeof a.owner !== "string" || !a.owner.trim()) throw new Error(`${verb}: "owner" must be a non-empty string`);
75
+ return a;
76
+ }
77
+
78
+ export interface GhcrServiceOptions {
79
+ /** Registry origin — tests only; production is always https://ghcr.io. */
80
+ base?: string;
81
+ queue?: JobQueue;
82
+ }
83
+
84
+ export class GhcrService {
85
+ private readonly cfg?: GhcrConfig;
86
+ private readonly secrets: SecretStore;
87
+ private readonly base: string;
88
+ readonly queue: JobQueue;
89
+
90
+ constructor(cfg: GhcrConfig | undefined, secrets: SecretStore, opts: GhcrServiceOptions = {}) {
91
+ this.cfg = cfg;
92
+ this.secrets = secrets;
93
+ this.base = opts.base ?? GHCR_BASE;
94
+ this.queue = opts.queue ?? new JobQueue();
95
+ }
96
+
97
+ private config(verb: string): GhcrConfig {
98
+ if (!this.cfg) throw new Error(`${verb}: GHCR is not configured — add a "ghcr" block to the config (see docs/TOOLS.md#ghcr_push)`);
99
+ return this.cfg;
100
+ }
101
+
102
+ private ownerConfig(verb: string, owner: string): GhcrOwnerConfig {
103
+ const o = this.config(verb).owners[owner.toLowerCase()];
104
+ if (!o) throw new Error(`${verb}: owner "${owner}" has no GHCR configuration (config.ghcr.owners)`);
105
+ if (!this.secrets.has(o.credential)) throw new Error(`${verb}: owner "${owner}"'s GHCR credential "${o.credential.replace(/^cred:/, "")}" is not stored (re-run the installer to add it)`);
106
+ return o;
107
+ }
108
+
109
+ private image(verb: string, owner: string, oc: GhcrOwnerConfig, raw: unknown): string {
110
+ if (typeof raw !== "string" || !raw) throw new Error(`${verb}: "image" must be a non-empty string`);
111
+ const lower = raw.toLowerCase();
112
+ if (lower.startsWith("ghcr.io/") || lower.startsWith(`${owner}/`)) {
113
+ throw new Error(`${verb}: "image" is the name under ghcr.io/${owner}/ — pass e.g. "hoot", not "${raw}"`);
114
+ }
115
+ if (!IMAGE.test(raw) || raw.length + owner.length + 1 > 255) throw new Error(`${verb}: "image" "${raw}" is not a valid image name (lowercase [a-z0-9], separators . _ __ -, "/" between segments)`);
116
+ if (!imageAllowed(oc, raw)) throw new Error(`${verb}: ghcr.io/${owner}/${raw} is not on the allowlist (config.ghcr.owners.${owner}.images: ${oc.images.join(", ")})`);
117
+ return raw;
118
+ }
119
+
120
+ private registry(owner: string, oc: GhcrOwnerConfig, image: string): Registry {
121
+ return new Registry({ base: this.base, username: oc.username, password: this.secrets.resolve(oc.credential), repository: `${owner}/${image}` });
122
+ }
123
+
124
+ async push(input: unknown): Promise<Omit<Job, "progress" | "result">> {
125
+ const verb = "ghcr_push";
126
+ const a = strictArgs(verb, input, PUSH_KEYS);
127
+ const owner = (a.owner as string).toLowerCase();
128
+ const cfg = this.config(verb);
129
+ const oc = this.ownerConfig(verb, owner);
130
+ const image = this.image(verb, owner, oc, a.image);
131
+ if (typeof a.tag !== "string" || !TAG.test(a.tag)) throw new Error(`${verb}: "tag" must be a valid tag ([A-Za-z0-9_][A-Za-z0-9._-]{0,127})`);
132
+ const tag = a.tag;
133
+
134
+ const archive = openArchive(a.archive, cfg.dropDir, cfg.maxArchiveBytes);
135
+ let handedOff = false;
136
+ let registry: Registry | undefined;
137
+ try {
138
+ const plan = planImage(archive.fd, archive.size);
139
+ registry = this.registry(owner, oc, image);
140
+ const ref = `${registry.host}/${owner}/${image}`;
141
+ // First network calls: proves the credential can push here, and refuses a taken release tag
142
+ // before the job is queued (push.ts re-checks right before the manifest PUT).
143
+ await registry.login("push,pull");
144
+ if (cfg.protectReleaseTags && RELEASE_TAG.test(tag) && (await registry.manifestExists(tag))) {
145
+ throw new Error(`${verb}: tag ${tag} already exists on ${ref} and release tags are protected — push a new version instead`);
146
+ }
147
+
148
+ const reg = registry;
149
+ const job = this.queue.submit(
150
+ { owner, image: ref, tag, archive: archive.path, archiveBytes: archive.size, format: plan.format },
151
+ {
152
+ run: (progress) => pushImage(reg, archive.fd, plan, tag, { protectReleaseTags: cfg.protectReleaseTags, onProgress: progress }),
153
+ describeError: (err) => reg.clean((err as Error)?.message ?? String(err)),
154
+ finally: (done) => {
155
+ archive.close();
156
+ audit({
157
+ tool: verb,
158
+ owner,
159
+ mode: "write",
160
+ repo: ref,
161
+ decision: "custom",
162
+ ok: done.state === "succeeded",
163
+ detail:
164
+ done.state === "succeeded"
165
+ ? `job=${done.job} tag=${tag} digest=${done.result?.digest} size=${done.result?.size} uploaded=${done.result?.blobsUploaded} skipped=${done.result?.blobsSkipped} archive=${archive.path}`
166
+ : `job=${done.job} tag=${tag} archive=${archive.path} error=${done.error}`
167
+ });
168
+ }
169
+ }
170
+ );
171
+ handedOff = true;
172
+ audit({ tool: verb, owner, mode: "write", repo: ref, decision: "custom", ok: true, detail: `job=${job.job} queued tag=${tag} archive=${archive.path} bytes=${archive.size} format=${plan.format} layers=${plan.layers.length}` });
173
+ return { ...job };
174
+ } catch (err) {
175
+ // Anything thrown before hand-off may come from the registry: scrub it like a job error.
176
+ if (!registry) throw err; // archive/plan errors carry no secret
177
+ throw new Error(registry.clean((err as Error)?.message ?? String(err)));
178
+ } finally {
179
+ if (!handedOff) archive.close();
180
+ }
181
+ }
182
+
183
+ status(input: unknown): Job | { jobs: Job[] } {
184
+ const verb = "ghcr_push_status";
185
+ const a = strictArgs(verb, input, STATUS_KEYS);
186
+ const owner = (a.owner as string).toLowerCase();
187
+ this.ownerConfig(verb, owner);
188
+ if (a.job === undefined) return { jobs: this.queue.list(owner) };
189
+ if (typeof a.job !== "string" || !JOB_ID.test(a.job)) throw new Error(`${verb}: "job" must be a job id returned by ghcr_push`);
190
+ const job = this.queue.get(a.job);
191
+ // A job of another owner reads as unknown: status never crosses owners.
192
+ if (!job || job.owner !== owner) throw new Error(`${verb}: no job ${a.job} for owner ${owner} (jobs are kept in memory; a broker restart forgets them)`);
193
+ return job;
194
+ }
195
+
196
+ async tags(input: unknown): Promise<{ image: string; exists: boolean; tags: string[] }> {
197
+ const verb = "ghcr_tags";
198
+ const a = strictArgs(verb, input, TAGS_KEYS);
199
+ const owner = (a.owner as string).toLowerCase();
200
+ const oc = this.ownerConfig(verb, owner);
201
+ const image = this.image(verb, owner, oc, a.image);
202
+ const registry = this.registry(owner, oc, image);
203
+ try {
204
+ await registry.login("pull");
205
+ const tags = await registry.listTags();
206
+ return { image: `${registry.host}/${owner}/${image}`, exists: tags !== undefined, tags: tags ?? [] };
207
+ } catch (err) {
208
+ throw new Error(registry.clean((err as Error)?.message ?? String(err)));
209
+ }
210
+ }
211
+ }
212
+
213
+ const owner = { type: "string", description: "registry owner (org/user login) — must be configured in config.ghcr.owners" };
214
+ const image = { type: "string", description: 'image name under ghcr.io/<owner>/, e.g. "hoot" — must match config.ghcr.owners.<owner>.images' };
215
+
216
+ export const GHCR_PUSH_SCHEMA = {
217
+ type: "object" as const,
218
+ required: ["owner", "image", "tag", "archive"],
219
+ additionalProperties: false,
220
+ properties: {
221
+ owner,
222
+ image,
223
+ tag: { type: "string", pattern: "^[A-Za-z0-9_][A-Za-z0-9._-]{0,127}$", description: "tag to push, e.g. \"1.4.0\" or \"latest\"" },
224
+ archive: {
225
+ type: "string",
226
+ description: "a `docker save` archive (docker-archive or OCI layout) inside the configured drop directory — absolute, or relative to it. Must be a regular, group-readable file; symlinks and hard links are refused."
227
+ }
228
+ }
229
+ };
230
+
231
+ export const GHCR_PUSH_STATUS_SCHEMA = {
232
+ type: "object" as const,
233
+ required: ["owner"],
234
+ additionalProperties: false,
235
+ properties: {
236
+ owner,
237
+ job: { type: "string", description: "job id returned by ghcr_push; omit to list this owner's recent jobs" }
238
+ }
239
+ };
240
+
241
+ export const GHCR_TAGS_SCHEMA = {
242
+ type: "object" as const,
243
+ required: ["owner", "image"],
244
+ additionalProperties: false,
245
+ properties: { owner, image }
246
+ };
247
+
248
+ /** Advertised description + schema per GHCR verb, keyed by tool name (catalog.ts). */
249
+ export const GHCR_CATALOG: Record<string, { description: string; inputSchema: { type: "object"; [k: string]: unknown } }> = {
250
+ ghcr_push: {
251
+ description:
252
+ "Push a container image to ghcr.io/<owner>/<image>:<tag> from a `docker save` archive in the broker's drop directory, without holding a registry token. The image must be on config.ghcr's allowlist; an existing release tag (1.2.3 / v1.2.3) is never overwritten while protectReleaseTags is on. Starts a background job and returns its id at once — poll ghcr_push_status. Runs on the owner's GHCR credential (classic PAT, write:packages).",
253
+ inputSchema: GHCR_PUSH_SCHEMA
254
+ },
255
+ ghcr_push_status: {
256
+ description:
257
+ "Progress of a ghcr_push job (phase, blobs uploaded/skipped, bytes sent), then its final manifest digest or error. Omit job to list this owner's recent jobs. Jobs are kept in memory; a broker restart forgets them.",
258
+ inputSchema: GHCR_PUSH_STATUS_SCHEMA
259
+ },
260
+ ghcr_tags: {
261
+ description: "List the tags of an allowlisted image on ghcr.io/<owner>/<image>, e.g. to verify a push landed. Runs on the owner's GHCR credential (pull scope).",
262
+ inputSchema: GHCR_TAGS_SCHEMA
263
+ }
264
+ };
@@ -0,0 +1,168 @@
1
+ /**
2
+ *
3
+ * @Project: @cldmv/gh-broker
4
+ * @Filename: /src/verbs/npm-registry.ts
5
+ * @Date: 2026-10-31T10:56:47-07:00 (1793469407)
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:18-07:00 (1793476698)
11
+ * -----
12
+ * @Copyright: Copyright (c) 2013-2026 Catalyzed Motivation Inc. All rights reserved.
13
+ *
14
+ */
15
+
16
+ /**
17
+ * The npm registry calls npm_scaffold makes, as plain HTTP requests (the same ones the npm CLI
18
+ * sends; see its npm-registry-fetch, libnpmpublish and `npm trust` sources). The token and OTP
19
+ * only ever travel in request headers: no error message, result or log line built here
20
+ * contains either.
21
+ */
22
+
23
+ export const DEFAULT_REGISTRY = "https://registry.npmjs.org";
24
+
25
+ export type FetchLike = (url: string, init: { method: string; headers: Record<string, string>; body?: string }) => Promise<{
26
+ status: number;
27
+ headers: { get(name: string): string | null };
28
+ text(): Promise<string>;
29
+ }>;
30
+
31
+ /** The registry wants a (fresh) one-time password for this request. */
32
+ export class NpmOtpRequired extends Error {
33
+ constructor(what: string) {
34
+ super(`npm: ${what} needs a one-time password (missing, expired or already used)`);
35
+ this.name = "NpmOtpRequired";
36
+ }
37
+ }
38
+
39
+ export class NpmHttpError extends Error {
40
+ readonly status: number;
41
+ constructor(what: string, status: number, detail: string) {
42
+ super(`npm: ${what} failed (HTTP ${status})${detail ? `: ${detail}` : ""}`);
43
+ this.name = "NpmHttpError";
44
+ this.status = status;
45
+ }
46
+ }
47
+
48
+ /** `@scope/name` → `@scope%2fname`, the form registry URLs use. */
49
+ export function escapePackageName(name: string): string {
50
+ return name.replace("/", "%2f");
51
+ }
52
+
53
+ /** The registry's own message, kept short; never echoes request headers. */
54
+ function errorDetail(body: string): string {
55
+ try {
56
+ const j = JSON.parse(body) as { error?: unknown; message?: unknown; reason?: unknown };
57
+ const msg = [j.error, j.message, j.reason].find((v) => typeof v === "string" && v);
58
+ if (typeof msg === "string") return msg.slice(0, 300);
59
+ } catch {
60
+ /* not JSON */
61
+ }
62
+ return body.trim().slice(0, 300);
63
+ }
64
+
65
+ export interface PackumentVersion {
66
+ scaffoldedBy?: unknown;
67
+ [k: string]: unknown;
68
+ }
69
+
70
+ export interface Packument {
71
+ name?: string;
72
+ versions?: Record<string, PackumentVersion>;
73
+ "dist-tags"?: Record<string, string>;
74
+ }
75
+
76
+ export interface TrustConfig {
77
+ id?: string;
78
+ [k: string]: unknown;
79
+ }
80
+
81
+ export class NpmRegistry {
82
+ private readonly base: string;
83
+ private readonly token: string;
84
+ private readonly fetchImpl: FetchLike;
85
+
86
+ constructor(opts: { registry?: string; token: string; fetch?: FetchLike }) {
87
+ this.base = (opts.registry ?? DEFAULT_REGISTRY).replace(/\/+$/, "");
88
+ this.token = opts.token;
89
+ this.fetchImpl = opts.fetch ?? (globalThis.fetch as unknown as FetchLike);
90
+ }
91
+
92
+ get registry(): string {
93
+ return this.base;
94
+ }
95
+
96
+ private async send(what: string, method: string, path: string, opts: { otp?: string; body?: unknown } = {}): Promise<{ status: number; body: string }> {
97
+ const headers: Record<string, string> = {
98
+ authorization: `Bearer ${this.token}`,
99
+ accept: "application/json",
100
+ "user-agent": "gh-broker (npm_scaffold)"
101
+ };
102
+ if (opts.otp) headers["npm-otp"] = opts.otp;
103
+ if (opts.body !== undefined) headers["content-type"] = "application/json";
104
+ const res = await this.fetchImpl(`${this.base}${path}`, {
105
+ method,
106
+ headers,
107
+ ...(opts.body !== undefined ? { body: JSON.stringify(opts.body) } : {})
108
+ });
109
+ const body = await res.text();
110
+ if (res.status === 401) {
111
+ const auth = (res.headers.get("www-authenticate") ?? "").toLowerCase();
112
+ if (auth.includes("otp") || /one-time pass/i.test(body)) throw new NpmOtpRequired(what);
113
+ }
114
+ return { status: res.status, body };
115
+ }
116
+
117
+ /**
118
+ * The package's registry document, or undefined when it does not exist (or is not visible to
119
+ * the token). `?write=true` reads from the primary rather than a cache, as npm itself does
120
+ * before a write: a stale 404 here is the one thing that could let a placeholder land on a
121
+ * package that already exists.
122
+ */
123
+ async packument(name: string): Promise<Packument | undefined> {
124
+ const res = await this.send("package lookup", "GET", `/${escapePackageName(name)}?write=true`);
125
+ if (res.status === 404) return undefined;
126
+ if (res.status !== 200) throw new NpmHttpError("package lookup", res.status, errorDetail(res.body));
127
+ return JSON.parse(res.body) as Packument;
128
+ }
129
+
130
+ /** First publish of a package: PUT of the full document with the tarball attached. */
131
+ async publish(document: unknown, name: string, otp: string): Promise<void> {
132
+ const res = await this.send("publish", "PUT", `/${escapePackageName(name)}`, { otp, body: document });
133
+ if (res.status < 200 || res.status >= 300) throw new NpmHttpError("publish", res.status, errorDetail(res.body));
134
+ }
135
+
136
+ /**
137
+ * Adds a GitHub Actions trusted publisher. A 409 means the package already has a trusted
138
+ * publisher, which may or may not be this one, so it is reported as `conflict` for the
139
+ * caller to check against listGithubTrust rather than as success.
140
+ */
141
+ async addGithubTrust(name: string, claims: { repository: string; file: string }, otp: string): Promise<{ config?: TrustConfig; conflict: boolean }> {
142
+ const body = [{ type: "github", claims: { repository: claims.repository, workflow_ref: { file: claims.file } }, permissions: ["createPackage"] }];
143
+ const res = await this.send("trusted publisher setup", "POST", `/-/package/${escapePackageName(name)}/trust`, { otp, body });
144
+ if (res.status === 409) return { conflict: true };
145
+ if (res.status < 200 || res.status >= 300) throw new NpmHttpError("trusted publisher setup", res.status, errorDetail(res.body));
146
+ let parsed: unknown;
147
+ try {
148
+ parsed = JSON.parse(res.body);
149
+ } catch {
150
+ return { conflict: false };
151
+ }
152
+ const first = Array.isArray(parsed) ? parsed[0] : parsed;
153
+ return { config: first && typeof first === "object" ? (first as TrustConfig) : undefined, conflict: false };
154
+ }
155
+
156
+ /** The package's trusted-publisher configurations (`npm trust list`). May need an OTP. */
157
+ async listTrust(name: string, otp?: string): Promise<TrustConfig[]> {
158
+ const res = await this.send("trusted publisher lookup", "GET", `/-/package/${escapePackageName(name)}/trust`, otp ? { otp } : {});
159
+ if (res.status !== 200) throw new NpmHttpError("trusted publisher lookup", res.status, errorDetail(res.body));
160
+ let parsed: unknown;
161
+ try {
162
+ parsed = JSON.parse(res.body);
163
+ } catch {
164
+ throw new NpmHttpError("trusted publisher lookup", res.status, "response is not JSON");
165
+ }
166
+ return (Array.isArray(parsed) ? parsed : [parsed]).filter((c): c is TrustConfig => Boolean(c) && typeof c === "object");
167
+ }
168
+ }
@@ -0,0 +1,76 @@
1
+ /**
2
+ *
3
+ * @Project: @cldmv/gh-broker
4
+ * @Filename: /src/verbs/npm-tarball.ts
5
+ * @Date: 2026-10-31T10:56:28-07:00 (1793469388)
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-31T11:04:31-07:00 (1793469871)
11
+ * -----
12
+ * @Copyright: Copyright (c) 2013-2026 Catalyzed Motivation Inc. All rights reserved.
13
+ *
14
+ */
15
+
16
+ /**
17
+ * A minimal npm package tarball: gzip over a POSIX ustar archive whose entries sit under
18
+ * `package/`, the layout `npm pack` produces. It exists so npm_scaffold can publish a
19
+ * placeholder built entirely by the broker (no agent-supplied files) without pulling in a
20
+ * tar dependency. Only regular files, only short ASCII paths: exactly what a placeholder needs.
21
+ */
22
+
23
+ import { createHash } from "node:crypto";
24
+ import { gzipSync } from "node:zlib";
25
+
26
+ /** npm pack's fixed mtime (1985-10-26T08:15:00Z), so the tarball is reproducible. */
27
+ const MTIME = 499162500;
28
+ const BLOCK = 512;
29
+
30
+ export interface TarFile {
31
+ /** Path inside `package/`, e.g. "package.json". */
32
+ path: string;
33
+ content: string;
34
+ }
35
+
36
+ function header(name: string, size: number): Buffer {
37
+ const h = Buffer.alloc(BLOCK, 0);
38
+ const put = (value: string, offset: number, length: number): void => {
39
+ h.write(value, offset, length, "ascii");
40
+ };
41
+ const octal = (n: number, length: number): string => n.toString(8).padStart(length - 1, "0") + "\0";
42
+ put(name, 0, 100);
43
+ put(octal(0o644, 8), 100, 8);
44
+ put(octal(0, 8), 108, 8);
45
+ put(octal(0, 8), 116, 8);
46
+ put(octal(size, 12), 124, 12);
47
+ put(octal(MTIME, 12), 136, 12);
48
+ put(" ", 148, 8); // checksum field counts as spaces while summing
49
+ put("0", 156, 1); // regular file
50
+ put("ustar\0", 257, 6);
51
+ put("00", 263, 2);
52
+ let sum = 0;
53
+ for (const byte of h) sum += byte;
54
+ put(sum.toString(8).padStart(6, "0") + "\0 ", 148, 8);
55
+ return h;
56
+ }
57
+
58
+ export function buildTarball(files: TarFile[]): Buffer {
59
+ const parts: Buffer[] = [];
60
+ for (const f of files) {
61
+ const name = `package/${f.path}`;
62
+ if (!/^[\x20-\x7e]+$/.test(name) || name.length > 100) throw new Error(`npm-tarball: unsupported path "${f.path}"`);
63
+ const body = Buffer.from(f.content, "utf8");
64
+ parts.push(header(name, body.length), body, Buffer.alloc((BLOCK - (body.length % BLOCK)) % BLOCK, 0));
65
+ }
66
+ parts.push(Buffer.alloc(BLOCK * 2, 0));
67
+ return gzipSync(Buffer.concat(parts), { level: 9 });
68
+ }
69
+
70
+ /** The `dist` integrity fields the registry expects for a tarball. */
71
+ export function tarballDigests(tarball: Buffer): { integrity: string; shasum: string } {
72
+ return {
73
+ integrity: `sha512-${createHash("sha512").update(tarball).digest("base64")}`,
74
+ shasum: createHash("sha1").update(tarball).digest("hex")
75
+ };
76
+ }