@cldmv/gh-broker 0.8.0 → 0.10.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 (106) hide show
  1. package/README.md +48 -13
  2. package/config.example.json +86 -3
  3. package/config.sample.json +1 -1
  4. package/deploy/README.md +58 -18
  5. package/deploy/cli.mjs +38 -0
  6. package/deploy/gh-broker.service +9 -2
  7. package/deploy/install.mjs +100 -41
  8. package/deploy/lib/agent-cli.mjs +166 -0
  9. package/deploy/lib/agents.mjs +128 -0
  10. package/deploy/lib/cli.mjs +72 -0
  11. package/deploy/lib/config-merge.mjs +29 -8
  12. package/deploy/lib/install-args.mjs +3 -2
  13. package/deploy/lib/mcp-config.mjs +211 -0
  14. package/deploy/lib/otp-cli.mjs +136 -0
  15. package/deploy/lib/otp.mjs +325 -0
  16. package/deploy/lib/secret-store.mjs +1 -0
  17. package/deploy/lib/system.mjs +24 -12
  18. package/deploy/real-deps.mjs +102 -0
  19. package/dist/index.mjs +5171 -1686
  20. package/dist/index.mjs.map +4 -4
  21. package/package.json +14 -3
  22. package/docs/TOOLS.md +0 -538
  23. package/docs/agent-user-model.md +0 -89
  24. package/docs/changelog/v0/v0.2.0.md +0 -26
  25. package/docs/changelog/v0/v0.2.1.md +0 -23
  26. package/docs/changelog/v0/v0.2.2.md +0 -23
  27. package/docs/changelog/v0/v0.2.3.md +0 -33
  28. package/docs/changelog/v0/v0.2.4.md +0 -25
  29. package/docs/changelog/v0/v0.2.5.md +0 -29
  30. package/docs/changelog/v0/v0.3.0.md +0 -84
  31. package/docs/changelog/v0/v0.3.1.md +0 -30
  32. package/docs/changelog/v0/v0.3.2.md +0 -24
  33. package/docs/changelog/v0/v0.3.3.md +0 -28
  34. package/docs/changelog/v0/v0.4.0.md +0 -58
  35. package/docs/changelog/v0/v0.4.1.md +0 -33
  36. package/docs/changelog/v0/v0.4.2.md +0 -45
  37. package/docs/changelog/v0/v0.5.0.md +0 -39
  38. package/docs/changelog/v0/v0.5.1.md +0 -28
  39. package/docs/changelog/v0/v0.6.0.md +0 -52
  40. package/docs/changelog/v0/v0.6.1.md +0 -48
  41. package/docs/changelog/v0/v0.7.0.md +0 -71
  42. package/docs/changelog/v0/v0.8.0.md +0 -115
  43. package/docs/deploy-architecture.md +0 -84
  44. package/src/appauth.ts +0 -56
  45. package/src/audit.ts +0 -34
  46. package/src/availability.ts +0 -42
  47. package/src/catalog.ts +0 -182
  48. package/src/config.ts +0 -157
  49. package/src/index.ts +0 -107
  50. package/src/paths.ts +0 -60
  51. package/src/router.ts +0 -299
  52. package/src/secrets.ts +0 -167
  53. package/src/server.ts +0 -91
  54. package/src/types.ts +0 -128
  55. package/src/upstream.ts +0 -85
  56. package/src/verbs/ghcr/archive.ts +0 -314
  57. package/src/verbs/ghcr/config.ts +0 -142
  58. package/src/verbs/ghcr/jobs.ts +0 -141
  59. package/src/verbs/ghcr/push.ts +0 -192
  60. package/src/verbs/ghcr/registry.ts +0 -275
  61. package/src/verbs/ghcr/tar.ts +0 -203
  62. package/src/verbs/ghcr_push.ts +0 -264
  63. package/src/verbs/issue_labels.ts +0 -329
  64. package/src/verbs/link_issue.ts +0 -84
  65. package/src/verbs/npm-registry.ts +0 -168
  66. package/src/verbs/npm-tarball.ts +0 -76
  67. package/src/verbs/npm_scaffold.ts +0 -325
  68. package/src/verbs/release-analysis.ts +0 -212
  69. package/src/verbs/repo-tier.ts +0 -90
  70. package/src/verbs/report_ci_status.ts +0 -358
  71. package/src/verbs/report_release_prs.ts +0 -238
  72. package/src/verbs/report_security_alerts.ts +0 -442
  73. package/src/verbs/report_unlabeled.ts +0 -194
  74. package/src/verbs/rest-client.ts +0 -181
  75. package/src/verbs/review_thread.ts +0 -138
  76. package/tests/appauth.test.vitest.mts +0 -76
  77. package/tests/audit.test.vitest.mts +0 -48
  78. package/tests/catalog.test.vitest.mts +0 -234
  79. package/tests/config-merge.test.vitest.mts +0 -325
  80. package/tests/config.test.vitest.mts +0 -388
  81. package/tests/credentials.test.vitest.mts +0 -127
  82. package/tests/ghcr-archive.test.vitest.mts +0 -439
  83. package/tests/ghcr-config.test.vitest.mts +0 -96
  84. package/tests/ghcr-push.test.vitest.mts +0 -526
  85. package/tests/ghcr-registry.test.vitest.mts +0 -206
  86. package/tests/helpers/ghcr-fixtures.mts +0 -373
  87. package/tests/install-args.test.vitest.mts +0 -152
  88. package/tests/issue-labels.test.vitest.mts +0 -362
  89. package/tests/npm-scaffold.test.vitest.mts +0 -479
  90. package/tests/npm-setup.test.vitest.mts +0 -196
  91. package/tests/paths.test.vitest.mts +0 -57
  92. package/tests/prompt.test.vitest.mts +0 -128
  93. package/tests/release-prs.test.vitest.mts +0 -247
  94. package/tests/rest-client.test.vitest.mts +0 -115
  95. package/tests/router.test.vitest.mts +0 -505
  96. package/tests/run-vitest.mjs +0 -51
  97. package/tests/secret-store.test.vitest.mts +0 -167
  98. package/tests/secrets.test.vitest.mts +0 -208
  99. package/tests/security-alerts.test.vitest.mts +0 -338
  100. package/tests/server.test.vitest.mts +0 -200
  101. package/tests/smoke.test.vitest.mts +0 -177
  102. package/tests/system.test.vitest.mts +0 -355
  103. package/tests/upstream.test.vitest.mts +0 -91
  104. package/tests/verb-helpers.test.vitest.mts +0 -377
  105. package/tests/verbs.test.vitest.mts +0 -624
  106. package/tsconfig.json +0 -21
@@ -1,479 +0,0 @@
1
- import { describe, it, expect, vi } from "vitest";
2
- import { gunzipSync } from "node:zlib";
3
- import { createHash } from "node:crypto";
4
- import { npmScaffold, placeholderDocument, NPM_SCAFFOLD_SCHEMA, PLACEHOLDER_VERSION, SCAFFOLD_MARKER } from "../src/verbs/npm_scaffold.ts";
5
- import { NpmRegistry, NpmOtpRequired, NpmHttpError, escapePackageName, type FetchLike } from "../src/verbs/npm-registry.ts";
6
- import { buildTarball, tarballDigests } from "../src/verbs/npm-tarball.ts";
7
-
8
- const TOKEN = "npm_SECRETTOKENVALUE";
9
- const OTP = "123456";
10
- const OTP2 = "654321";
11
- const PKG = "@cldmv/new-thing";
12
-
13
- /** Read the ustar entries back out of a gzipped tarball. */
14
- function untar(tgz: Buffer): Record<string, string> {
15
- const tar = gunzipSync(tgz);
16
- const out: Record<string, string> = {};
17
- for (let off = 0; off + 512 <= tar.length; ) {
18
- const head = tar.subarray(off, off + 512);
19
- if (head.every((b) => b === 0)) break;
20
- const name = head.subarray(0, 100).toString("ascii").replace(/\0.*$/, "");
21
- const size = parseInt(head.subarray(124, 136).toString("ascii").replace(/\0.*$/, ""), 8);
22
- const stored = parseInt(head.subarray(148, 156).toString("ascii").replace(/[\0 ].*$/, ""), 8);
23
- const copy = Buffer.from(head);
24
- copy.fill(0x20, 148, 156);
25
- expect(copy.reduce((s, b) => s + b, 0)).toBe(stored);
26
- out[name] = tar.subarray(off + 512, off + 512 + size).toString("utf8");
27
- off += 512 + Math.ceil(size / 512) * 512;
28
- }
29
- return out;
30
- }
31
-
32
- type RegistryOpts = {
33
- existing?: Record<string, unknown>;
34
- /** Codes the registry accepts; each works once. */
35
- otps?: string[];
36
- trustStatus?: number;
37
- publishStatus?: number;
38
- lookupStatus?: number;
39
- /** Trusted publishers already on the package: a POST then answers 409. */
40
- existingTrust?: any[];
41
- /** GET of the trust list wants an OTP (it is behind otplease in the npm CLI). */
42
- trustListNeedsOtp?: boolean;
43
- /** POST-trust reply body instead of the created configs (e.g. one without an id). */
44
- trustCreatedBody?: string;
45
- /** Raw GET-trust reply instead of the list. */
46
- trustListReply?: { status: number; body: string };
47
- };
48
-
49
- function fakeRegistry(opts: RegistryOpts = {}) {
50
- const packages = new Map<string, any>(Object.entries(opts.existing ?? {}));
51
- const valid = [...(opts.otps ?? [OTP])];
52
- const trust: any[] = [...(opts.existingTrust ?? [])];
53
- const calls: Array<{ method: string; url: string; headers: Record<string, string>; body?: any }> = [];
54
- const otpRefused = () => ({ status: 401, headers: { get: (n: string) => (n === "www-authenticate" ? "OTP" : null) }, text: async () => '{"error":"You must provide a one-time pass."}' });
55
- const reply = (status: number, body: unknown) => ({ status, headers: { get: () => null }, text: async () => (typeof body === "string" ? body : JSON.stringify(body)) });
56
- const fetch = vi.fn<FetchLike>(async (url, init) => {
57
- calls.push({ method: init.method, url, headers: init.headers, ...(init.body ? { body: JSON.parse(init.body) } : {}) });
58
- expect(init.headers.authorization).toBe(`Bearer ${TOKEN}`);
59
- const path = new URL(url).pathname;
60
- const useOtp = () => {
61
- const code = init.headers["npm-otp"];
62
- const at = code ? valid.indexOf(code) : -1;
63
- if (at === -1) return false;
64
- valid.splice(at, 1);
65
- return true;
66
- };
67
- if (init.method === "GET" && path === `/${escapePackageName(PKG)}`) {
68
- // Read from the primary, not a cache, as npm does before a write.
69
- expect(new URL(url).searchParams.get("write")).toBe("true");
70
- if (opts.lookupStatus) return reply(opts.lookupStatus, { error: "boom" });
71
- const p = packages.get(PKG);
72
- return p ? reply(200, p) : reply(404, { error: "Not found" });
73
- }
74
- if (init.method === "PUT" && path === `/${escapePackageName(PKG)}`) {
75
- if (!useOtp()) return otpRefused();
76
- if (opts.publishStatus) return reply(opts.publishStatus, { error: "You do not have permission to publish" });
77
- const doc = JSON.parse(init.body!);
78
- packages.set(PKG, { name: doc.name, versions: doc.versions, "dist-tags": doc["dist-tags"] });
79
- return reply(200, { ok: true });
80
- }
81
- if (init.method === "POST" && path === `/-/package/${escapePackageName(PKG)}/trust`) {
82
- if (!useOtp()) return otpRefused();
83
- if (opts.trustStatus) return reply(opts.trustStatus, { error: "bad request" });
84
- if (trust.length) return reply(409, { error: "a trusted publisher already exists" });
85
- const body = JSON.parse(init.body!);
86
- trust.push(...body);
87
- if (opts.trustCreatedBody !== undefined) return reply(201, opts.trustCreatedBody);
88
- return reply(201, body.map((b: any, i: number) => ({ ...b, id: `trust-${i + 1}` })));
89
- }
90
- if (init.method === "GET" && path === `/-/package/${escapePackageName(PKG)}/trust`) {
91
- if (opts.trustListNeedsOtp && !useOtp()) return otpRefused();
92
- if (opts.trustListReply) return reply(opts.trustListReply.status, opts.trustListReply.body);
93
- return reply(200, trust);
94
- }
95
- throw new Error(`unexpected ${init.method} ${url}`);
96
- });
97
- return { registry: new NpmRegistry({ token: TOKEN, fetch }), fetch, calls, packages, trust };
98
- }
99
-
100
- type RepoOpts = { private?: boolean; archived?: boolean; pkg?: unknown; pkgRaw?: string; workflow?: boolean; missing?: boolean; pkgIsDir?: boolean; plainEncoding?: boolean; bareRepo?: boolean };
101
-
102
- function fakeGitHub(o: RepoOpts = {}) {
103
- const notFound = () => Object.assign(new Error("Not Found"), { status: 404 });
104
- const file = (s: string) => (o.plainEncoding ? { data: { type: "file", encoding: "utf-8", content: s } } : { data: { type: "file", encoding: "base64", content: Buffer.from(s).toString("base64") } });
105
- const request = vi.fn(async (route: string, p: any) => {
106
- if (route === "GET /repos/{owner}/{repo}") {
107
- if (o.missing) throw notFound();
108
- if (o.bareRepo) return { data: { private: false } };
109
- return { data: { full_name: "CLDMV/new-thing", private: o.private ?? false, archived: o.archived ?? false, default_branch: "master" } };
110
- }
111
- if (route === "GET /repos/{owner}/{repo}/contents/{path}") {
112
- expect(p.ref).toBe(o.bareRepo ? "HEAD" : "master");
113
- if (p.path === "package.json") {
114
- if (o.pkgIsDir) return { data: [{ type: "file", name: "x" }] };
115
- if (o.pkg === undefined && o.pkgRaw === undefined) return file(JSON.stringify({ name: PKG, license: "Apache-2.0", description: "A new thing." }));
116
- if (o.pkg === null) throw notFound();
117
- return file(o.pkgRaw ?? JSON.stringify(o.pkg));
118
- }
119
- if (p.path === ".github/workflows/publish.yml" || p.path === ".github/workflows/release.yaml") {
120
- if (o.workflow === false) throw notFound();
121
- return file("name: publish\n");
122
- }
123
- throw notFound();
124
- }
125
- throw new Error(`unexpected route ${route}`);
126
- });
127
- return { octokit: { request } as any, request };
128
- }
129
-
130
- const args = (extra: Record<string, unknown> = {}) => ({ owner: "cldmv", repo: "new-thing", package: PKG, otp: OTP, ...extra });
131
- const deps = (gh: ReturnType<typeof fakeGitHub>, reg: ReturnType<typeof fakeRegistry>, scopes = ["@cldmv"]) => ({ octokit: gh.octokit, registry: reg.registry, scopes });
132
-
133
- describe("buildTarball", () => {
134
- it("writes package/-prefixed ustar entries with valid checksums, gzipped", () => {
135
- const files = untar(buildTarball([{ path: "package.json", content: '{"name":"x"}' }, { path: "README.md", content: "# x\n".repeat(200) }]));
136
- expect(Object.keys(files)).toEqual(["package/package.json", "package/README.md"]);
137
- expect(files["package/package.json"]).toBe('{"name":"x"}');
138
- expect(files["package/README.md"]).toBe("# x\n".repeat(200));
139
- });
140
-
141
- it("is reproducible (fixed mtime)", () => {
142
- const f = [{ path: "a.txt", content: "a" }];
143
- expect(buildTarball(f).equals(buildTarball(f))).toBe(true);
144
- });
145
-
146
- it("refuses paths it cannot store", () => {
147
- expect(() => buildTarball([{ path: "x".repeat(100), content: "" }])).toThrow(/unsupported path/);
148
- expect(() => buildTarball([{ path: "naïve.txt", content: "" }])).toThrow(/unsupported path/);
149
- });
150
-
151
- it("computes the sha512 integrity and sha1 shasum of the tarball", () => {
152
- const tgz = buildTarball([{ path: "a", content: "a" }]);
153
- const d = tarballDigests(tgz);
154
- expect(d.integrity).toBe(`sha512-${createHash("sha512").update(tgz).digest("base64")}`);
155
- expect(d.shasum).toBe(createHash("sha1").update(tgz).digest("hex"));
156
- });
157
- });
158
-
159
- describe("placeholderDocument", () => {
160
- it("builds the first-publish document with a marked placeholder manifest and matching tarball", () => {
161
- const doc = placeholderDocument(PKG, { repository: "CLDMV/new-thing", isPrivate: false, license: "Apache-2.0" }, "public", "https://registry.npmjs.org");
162
- expect(doc["dist-tags"]).toEqual({ latest: PLACEHOLDER_VERSION });
163
- expect(doc.access).toBe("public");
164
- const v = doc.versions[PLACEHOLDER_VERSION]!;
165
- expect(v.scaffoldedBy).toBe(SCAFFOLD_MARKER);
166
- expect(v.license).toBe("Apache-2.0");
167
- expect(v.repository).toEqual({ type: "git", url: "git+https://github.com/CLDMV/new-thing.git" });
168
- expect(v.dist.tarball).toBe(`https://registry.npmjs.org/${PKG}/-/new-thing-${PLACEHOLDER_VERSION}.tgz`);
169
- const att = doc._attachments[`${PKG}-${PLACEHOLDER_VERSION}.tgz`]!;
170
- const tgz = Buffer.from(att.data, "base64");
171
- expect(att.length).toBe(tgz.length);
172
- expect(v.dist.integrity).toBe(tarballDigests(tgz).integrity);
173
- const files = untar(tgz);
174
- expect(JSON.parse(files["package/package.json"]!)).toMatchObject({ name: PKG, version: PLACEHOLDER_VERSION, scaffoldedBy: SCAFFOLD_MARKER });
175
- expect(files["package/README.md"]).toContain("https://github.com/CLDMV/new-thing");
176
- // Only the two broker-built files: nothing else ships.
177
- expect(Object.keys(files)).toEqual(["package/package.json", "package/README.md"]);
178
- });
179
-
180
- it("falls back to UNLICENSED when the repo names no license", () => {
181
- const doc = placeholderDocument(PKG, { repository: "CLDMV/new-thing", isPrivate: true }, "restricted", "https://registry.npmjs.org");
182
- expect(doc.versions[PLACEHOLDER_VERSION]!.license).toBe("UNLICENSED");
183
- expect(doc.access).toBe("restricted");
184
- });
185
- });
186
-
187
- describe("npm_scaffold", () => {
188
- it("advertises a strict schema", () => {
189
- expect(NPM_SCAFFOLD_SCHEMA.required).toEqual(["owner", "repo", "package", "otp"]);
190
- expect(NPM_SCAFFOLD_SCHEMA.additionalProperties).toBe(false);
191
- });
192
-
193
- it("publishes the placeholder, then adds the trusted publisher on a second call with the next code", async () => {
194
- const reg = fakeRegistry({ otps: [OTP, OTP2] });
195
- const first = await npmScaffold(args(), deps(fakeGitHub(), reg));
196
- expect(first).toMatchObject({ status: "needs_otp", done: ["publish"], remaining: ["trust"], access: "public", repository: "CLDMV/new-thing", version: PLACEHOLDER_VERSION });
197
- expect(first.message).toMatch(/needs its own one-time password/);
198
- // The spent code is never sent again: no trust request on the first call.
199
- expect(reg.calls.filter((c) => c.url.endsWith("/trust"))).toHaveLength(0);
200
- const put = reg.calls.find((c) => c.method === "PUT")!;
201
- expect(put.headers["npm-otp"]).toBe(OTP);
202
- expect(put.body.access).toBe("public");
203
-
204
- const second = await npmScaffold(args({ otp: OTP2 }), deps(fakeGitHub(), reg));
205
- expect(second).toMatchObject({ status: "done", done: ["trust"], skipped: ["publish"], workflow: "publish.yml", trustId: "trust-1" });
206
- expect(second.url).toBe(`https://www.npmjs.com/package/${PKG}`);
207
- expect(reg.trust).toEqual([{ type: "github", claims: { repository: "CLDMV/new-thing", workflow_ref: { file: "publish.yml" } }, permissions: ["createPackage"] }]);
208
- expect(reg.calls.find((c) => c.url.endsWith("/trust"))!.headers["npm-otp"]).toBe(OTP2);
209
- expect(reg.calls.filter((c) => c.method === "PUT")).toHaveLength(1);
210
- });
211
-
212
- it("uses restricted access for a private repo", async () => {
213
- const reg = fakeRegistry({ otps: [OTP] });
214
- const out = await npmScaffold(args(), deps(fakeGitHub({ private: true }), reg));
215
- expect(out.access).toBe("restricted");
216
- expect(reg.calls.find((c) => c.method === "PUT")!.body.access).toBe("restricted");
217
- });
218
-
219
- it("returns needs_otp when the trust code is refused, and finishes with a new one", async () => {
220
- const reg = fakeRegistry({ otps: [OTP, OTP2] });
221
- await npmScaffold(args(), deps(fakeGitHub(), reg));
222
- const refused = await npmScaffold(args({ otp: "999999" }), deps(fakeGitHub(), reg));
223
- expect(refused).toMatchObject({ status: "needs_otp", done: [], skipped: ["publish"], remaining: ["trust"] });
224
- expect(refused.message).toMatch(/refused the one-time password for the trusted publisher/);
225
- const done = await npmScaffold(args({ otp: OTP2 }), deps(fakeGitHub(), reg));
226
- expect(done).toMatchObject({ status: "done", done: ["trust"] });
227
- });
228
-
229
- it("returns needs_otp with nothing written when the code is refused before publishing", async () => {
230
- const reg = fakeRegistry({ otps: [] });
231
- const out = await npmScaffold(args(), deps(fakeGitHub(), reg));
232
- expect(out).toMatchObject({ status: "needs_otp", done: [], remaining: ["publish", "trust"] });
233
- expect(reg.packages.size).toBe(0);
234
- });
235
-
236
- const placeholderFor = (repository: string) => ({ name: PKG, versions: placeholderDocument(PKG, { repository, isPrivate: false }, "public", "https://registry.npmjs.org").versions });
237
- const trustFor = (repository: string, file = "publish.yml") => ({ id: "t-old", type: "github", claims: { repository, workflow_ref: { file } }, permissions: ["createPackage"] });
238
-
239
- it("treats the same trusted publisher already being configured as done", async () => {
240
- const reg = fakeRegistry({ existing: { [PKG]: placeholderFor("CLDMV/new-thing") }, existingTrust: [trustFor("cldmv/NEW-thing")] });
241
- const out = await npmScaffold(args(), deps(fakeGitHub(), reg));
242
- expect(out).toMatchObject({ status: "done", done: [], skipped: ["publish", "trust"], trustId: "t-old" });
243
- // The list was read without sending the code again.
244
- expect(reg.calls.find((c) => c.method === "GET" && c.url.endsWith("/trust"))!.headers["npm-otp"]).toBeUndefined();
245
- });
246
-
247
- it.each([
248
- ["another repo", trustFor("CLDMV/other")],
249
- ["another workflow", trustFor("CLDMV/new-thing", "release.yml")],
250
- ["another provider", { ...trustFor("CLDMV/new-thing"), type: "gitlab" }]
251
- ])("refuses when a different trusted publisher exists (%s)", async (_, existing) => {
252
- const reg = fakeRegistry({ existing: { [PKG]: placeholderFor("CLDMV/new-thing") }, existingTrust: [existing] });
253
- await expect(npmScaffold(args(), deps(fakeGitHub(), reg))).rejects.toThrow(/already has a different trusted publisher: .*Change it at https:\/\/www\.npmjs\.com\/package\/@cldmv\/new-thing\/access/);
254
- });
255
-
256
- it("describes an existing trust entry that has no claims", async () => {
257
- const reg = fakeRegistry({ existing: { [PKG]: placeholderFor("CLDMV/new-thing") }, existingTrust: [{ id: "t", type: "github" }] });
258
- await expect(npmScaffold(args(), deps(fakeGitHub(), reg))).rejects.toThrow(/different trusted publisher: github undefined \(undefined\)/);
259
- });
260
-
261
- it("finishes without a trust id when npm's reply carries none", async () => {
262
- const reg = fakeRegistry({ existing: { [PKG]: placeholderFor("CLDMV/new-thing") }, trustCreatedBody: "[{}]" });
263
- const out = await npmScaffold(args(), deps(fakeGitHub(), reg));
264
- expect(out).toMatchObject({ status: "done", done: ["trust"] });
265
- expect(out).not.toHaveProperty("trustId");
266
- });
267
-
268
- it("refuses when npm wants another code to show the existing trusted publisher", async () => {
269
- const reg = fakeRegistry({ existing: { [PKG]: placeholderFor("CLDMV/new-thing") }, existingTrust: [trustFor("CLDMV/new-thing")], trustListNeedsOtp: true });
270
- await expect(npmScaffold(args(), deps(fakeGitHub(), reg))).rejects.toThrow(/already has a trusted publisher, and npm needs another one-time password to show which/);
271
- });
272
-
273
- it("passes a trust-list failure other than an OTP request through", async () => {
274
- const reg = fakeRegistry({ existing: { [PKG]: placeholderFor("CLDMV/new-thing") }, existingTrust: [trustFor("CLDMV/new-thing")], trustListReply: { status: 500, body: "" } });
275
- await expect(npmScaffold(args(), deps(fakeGitHub(), reg))).rejects.toThrow(/trusted publisher lookup failed \(HTTP 500\)/);
276
- });
277
-
278
- it("names an unreadable existing configuration rather than failing obscurely", async () => {
279
- const reg = fakeRegistry({ existing: { [PKG]: placeholderFor("CLDMV/new-thing") }, existingTrust: [trustFor("x")], trustListReply: { status: 200, body: "[]" } });
280
- await expect(npmScaffold(args(), deps(fakeGitHub(), reg))).rejects.toThrow(/already has a different trusted publisher: unreadable/);
281
- });
282
-
283
- it("refuses to add a trusted publisher for a different repo than the placeholder was made for", async () => {
284
- const reg = fakeRegistry({ existing: { [PKG]: placeholderFor("CLDMV/other-repo") } });
285
- await expect(npmScaffold(args(), deps(fakeGitHub(), reg))).rejects.toThrow(/placeholder was published for git\+https:\/\/github\.com\/CLDMV\/other-repo\.git, not CLDMV\/new-thing/);
286
- expect(reg.calls.some((c) => c.url.endsWith("/trust"))).toBe(false);
287
- });
288
-
289
- it("refuses a placeholder with no recorded repository", async () => {
290
- const doc = placeholderFor("CLDMV/new-thing");
291
- delete (doc.versions[PLACEHOLDER_VERSION] as any).repository;
292
- await expect(npmScaffold(args(), deps(fakeGitHub(), fakeRegistry({ existing: { [PKG]: doc } })))).rejects.toThrow(/published for an unrecorded repository/);
293
- });
294
-
295
- it("refuses a repo that GitHub redirects to another owner or name", async () => {
296
- const gh = fakeGitHub();
297
- const real = gh.request.getMockImplementation()!;
298
- gh.request.mockImplementation(async (r: string, p: any) => (r === "GET /repos/{owner}/{repo}" ? { data: { full_name: "someone/new-thing", private: false, default_branch: "master" } } : real(r, p)));
299
- const reg = fakeRegistry();
300
- await expect(npmScaffold(args(), deps(gh, reg))).rejects.toThrow(/cldmv\/new-thing redirects to someone\/new-thing \(renamed or transferred\)/);
301
- expect(reg.fetch).not.toHaveBeenCalled();
302
- });
303
-
304
- it("refuses a package that already exists and was not created by npm_scaffold", async () => {
305
- const reg = fakeRegistry({ existing: { [PKG]: { name: PKG, versions: { "1.0.0": {}, "1.1.0": {} } } } });
306
- await expect(npmScaffold(args(), deps(fakeGitHub(), reg))).rejects.toThrow(/already exists on the registry \(2 version\(s\)\) and was not created by npm_scaffold/);
307
- expect(reg.calls.every((c) => c.method === "GET")).toBe(true);
308
- });
309
-
310
- it("refuses an existing single 0.0.0-stage version that lacks the scaffold marker", async () => {
311
- const reg = fakeRegistry({ existing: { [PKG]: { name: PKG, versions: { [PLACEHOLDER_VERSION]: { name: PKG } } } } });
312
- await expect(npmScaffold(args(), deps(fakeGitHub(), reg))).rejects.toThrow(/was not created by npm_scaffold/);
313
- });
314
-
315
- it("refuses a scope that is not configured, before any request", async () => {
316
- const gh = fakeGitHub();
317
- const reg = fakeRegistry();
318
- await expect(npmScaffold(args({ package: "@other/new-thing" }), deps(gh, reg))).rejects.toThrow(/scope @other is not in config\.npm\.scopes/);
319
- expect(gh.request).not.toHaveBeenCalled();
320
- expect(reg.fetch).not.toHaveBeenCalled();
321
- });
322
-
323
- it.each([
324
- [{ missing: true }, /repository cldmv\/new-thing not found/],
325
- [{ archived: true }, /is archived/],
326
- [{ pkg: null }, /has no package\.json on master/],
327
- [{ pkgRaw: "{nope" }, /is not valid JSON/],
328
- [{ pkg: { name: "@cldmv/other" } }, /names "@cldmv\/other", not "@cldmv\/new-thing"/],
329
- [{ pkg: { name: PKG, private: true } }, /marked "private": true/],
330
- [{ workflow: false }, /has no \.github\/workflows\/publish\.yml/]
331
- ] as Array<[RepoOpts, RegExp]>)("refuses when the repo does not match (%o) without touching npm", async (repo, msg) => {
332
- const reg = fakeRegistry();
333
- await expect(npmScaffold(args(), deps(fakeGitHub(repo), reg))).rejects.toThrow(msg);
334
- expect(reg.fetch).not.toHaveBeenCalled();
335
- });
336
-
337
- it("treats a package.json that is not a file as missing", async () => {
338
- await expect(npmScaffold(args(), deps(fakeGitHub({ pkgIsDir: true }), fakeRegistry()))).rejects.toThrow(/has no package\.json/);
339
- });
340
-
341
- it("reads repo files that are not base64-encoded", async () => {
342
- const out = await npmScaffold(args(), deps(fakeGitHub({ plainEncoding: true }), fakeRegistry()));
343
- expect(out).toMatchObject({ status: "needs_otp", done: ["publish"] });
344
- });
345
-
346
- it("falls back to owner/repo and HEAD when GitHub omits full_name and default_branch, and to no license or description", async () => {
347
- const reg = fakeRegistry();
348
- const gh = fakeGitHub({ bareRepo: true, pkg: { name: PKG } });
349
- const out = await npmScaffold(args(), deps(gh, reg));
350
- expect(out.repository).toBe("cldmv/new-thing");
351
- const v = reg.calls.find((c) => c.method === "PUT")!.body.versions[PLACEHOLDER_VERSION];
352
- expect(v.license).toBe("UNLICENSED");
353
- expect(v.readme).not.toContain("undefined");
354
- });
355
-
356
- it("refuses an existing package document that lists no versions", async () => {
357
- const reg = fakeRegistry({ existing: { [PKG]: { name: PKG } } });
358
- await expect(npmScaffold(args(), deps(fakeGitHub(), reg))).rejects.toThrow(/\(0 version\(s\)\) and was not created by npm_scaffold/);
359
- });
360
-
361
- it("honours a custom workflow file name", async () => {
362
- const reg = fakeRegistry({ otps: [OTP, OTP2] });
363
- await npmScaffold(args({ workflow: "release.yaml" }), deps(fakeGitHub(), reg));
364
- const out = await npmScaffold(args({ workflow: "release.yaml", otp: OTP2 }), deps(fakeGitHub(), reg));
365
- expect(out.workflow).toBe("release.yaml");
366
- expect(reg.trust[0].claims.workflow_ref.file).toBe("release.yaml");
367
- });
368
-
369
- it.each([
370
- [null, /arguments must be an object/],
371
- [{ ...args(), extra: 1 }, /unknown argument\(s\): extra/],
372
- [{ ...args(), owner: "bad/owner" }, /"owner"/],
373
- [{ ...args(), repo: "../x" }, /"repo"/],
374
- [{ ...args(), package: "unscoped" }, /"package"/],
375
- [{ ...args(), package: "@CLDMV/Upper" }, /"package"/],
376
- [{ ...args(), otp: "12ab56" }, /"otp"/],
377
- [{ ...args(), otp: 123456 }, /"otp"/],
378
- [{ ...args(), workflow: "../publish.yml" }, /"workflow"/],
379
- [{ ...args(), workflow: "publish.sh" }, /"workflow"/]
380
- ] as Array<[unknown, RegExp]>)("validates arguments strictly (%#)", async (input, msg) => {
381
- const gh = fakeGitHub();
382
- await expect(npmScaffold(input, deps(gh, fakeRegistry()))).rejects.toThrow(msg);
383
- expect(gh.request).not.toHaveBeenCalled();
384
- });
385
-
386
- it("never puts the OTP or the token in a result or an error", async () => {
387
- const ok = await npmScaffold(args(), deps(fakeGitHub(), fakeRegistry({ otps: [OTP] })));
388
- const needs = await npmScaffold(args(), deps(fakeGitHub(), fakeRegistry({ otps: [] })));
389
- const errors: string[] = [];
390
- for (const reg of [fakeRegistry({ publishStatus: 403 }), fakeRegistry({ lookupStatus: 500 })]) {
391
- await npmScaffold(args(), deps(fakeGitHub(), reg)).catch((e: Error) => errors.push(e.message));
392
- }
393
- await npmScaffold({ ...args(), otp: "12345x" }, deps(fakeGitHub(), fakeRegistry())).catch((e: Error) => errors.push(e.message));
394
- expect(errors).toHaveLength(3);
395
- for (const text of [JSON.stringify(ok), JSON.stringify(needs), ...errors]) {
396
- expect(text).not.toContain(OTP);
397
- expect(text).not.toContain("12345x");
398
- expect(text).not.toContain(TOKEN);
399
- }
400
- });
401
-
402
- it("passes GitHub errors other than 404 through unchanged", async () => {
403
- const boom = Object.assign(new Error("rate limited"), { status: 403 });
404
- for (const route of ["GET /repos/{owner}/{repo}", "GET /repos/{owner}/{repo}/contents/{path}"]) {
405
- const gh = fakeGitHub();
406
- const real = gh.request.getMockImplementation()!;
407
- gh.request.mockImplementation(async (r: string, p: any) => {
408
- if (r === route) throw boom;
409
- return real(r, p);
410
- });
411
- await expect(npmScaffold(args(), deps(gh, fakeRegistry()))).rejects.toBe(boom);
412
- }
413
- });
414
-
415
- it("passes a trust failure that is not an OTP refusal through, after the publish", async () => {
416
- const reg = fakeRegistry({ otps: [OTP, OTP2], trustStatus: 400 });
417
- await npmScaffold(args(), deps(fakeGitHub(), reg));
418
- await expect(npmScaffold(args({ otp: OTP2 }), deps(fakeGitHub(), reg))).rejects.toThrow(/trusted publisher setup failed \(HTTP 400\)/);
419
- expect(reg.packages.has(PKG)).toBe(true);
420
- });
421
-
422
- it("surfaces a registry refusal as an HTTP error with npm's message", async () => {
423
- await expect(npmScaffold(args(), deps(fakeGitHub(), fakeRegistry({ publishStatus: 403 })))).rejects.toThrow(/npm: publish failed \(HTTP 403\): You do not have permission to publish/);
424
- });
425
- });
426
-
427
- describe("NpmRegistry", () => {
428
- const res = (status: number, body: string, headers: Record<string, string> = {}) => ({ status, headers: { get: (n: string) => headers[n] ?? null }, text: async () => body });
429
-
430
- it("detects an OTP refusal from the body when the header does not say so", async () => {
431
- const reg = new NpmRegistry({ token: TOKEN, fetch: async () => res(401, "This operation requires a one-time password") });
432
- await expect(reg.publish({}, PKG, OTP)).rejects.toBeInstanceOf(NpmOtpRequired);
433
- });
434
-
435
- it("treats a plain 401 as an HTTP error, not an OTP request", async () => {
436
- const reg = new NpmRegistry({ token: TOKEN, fetch: async () => res(401, '{"error":"bad token"}') });
437
- await expect(reg.publish({}, PKG, OTP)).rejects.toBeInstanceOf(NpmHttpError);
438
- });
439
-
440
- it("trims a trailing slash from the registry and reports a non-JSON error body", async () => {
441
- const seen: string[] = [];
442
- const reg = new NpmRegistry({ token: TOKEN, registry: "https://npm.example.test/", fetch: async (url) => (seen.push(url), res(502, "Bad Gateway")) });
443
- await expect(reg.packument(PKG)).rejects.toThrow(/package lookup failed \(HTTP 502\): Bad Gateway/);
444
- expect(seen).toEqual([`https://npm.example.test/${escapePackageName(PKG)}?write=true`]);
445
- expect(reg.registry).toBe("https://npm.example.test");
446
- });
447
-
448
- it("reads a trust list that is a single object, and refuses one that is not JSON", async () => {
449
- const one = new NpmRegistry({ token: TOKEN, fetch: async () => res(200, '{"id":"t1","type":"github"}') });
450
- await expect(one.listTrust(PKG, OTP)).resolves.toEqual([{ id: "t1", type: "github" }]);
451
- const bad = new NpmRegistry({ token: TOKEN, fetch: async () => res(200, "<html>") });
452
- await expect(bad.listTrust(PKG)).rejects.toThrow(/trusted publisher lookup failed \(HTTP 200\): response is not JSON/);
453
- });
454
-
455
- it("accepts a trust response that is not JSON", async () => {
456
- const reg = new NpmRegistry({ token: TOKEN, fetch: async () => res(204, "") });
457
- await expect(reg.addGithubTrust(PKG, { repository: "CLDMV/x", file: "publish.yml" }, OTP)).resolves.toEqual({ conflict: false });
458
- });
459
-
460
- it("reports an HTTP error with an empty body by status alone", async () => {
461
- const reg = new NpmRegistry({ token: TOKEN, fetch: async () => res(500, "") });
462
- await expect(reg.packument(PKG)).rejects.toThrow(/^npm: package lookup failed \(HTTP 500\)$/);
463
- });
464
-
465
- it("takes the trust config from an object response, and none from an empty or non-object one", async () => {
466
- const claims = { repository: "CLDMV/x", file: "publish.yml" };
467
- const one = new NpmRegistry({ token: TOKEN, fetch: async () => res(201, '{"id":"t-9","type":"github"}') });
468
- await expect(one.addGithubTrust(PKG, claims, OTP)).resolves.toEqual({ config: { id: "t-9", type: "github" }, conflict: false });
469
- for (const body of ["[]", '"ok"']) {
470
- const reg = new NpmRegistry({ token: TOKEN, fetch: async () => res(201, body) });
471
- await expect(reg.addGithubTrust(PKG, claims, OTP)).resolves.toEqual({ config: undefined, conflict: false });
472
- }
473
- });
474
-
475
- it("fails a trust setup the registry rejects", async () => {
476
- const reg = new NpmRegistry({ token: TOKEN, fetch: async () => res(400, '{"message":"workflow not found"}') });
477
- await expect(reg.addGithubTrust(PKG, { repository: "CLDMV/x", file: "publish.yml" }, OTP)).rejects.toThrow(/trusted publisher setup failed \(HTTP 400\): workflow not found/);
478
- });
479
- });