@cldmv/gh-broker 0.8.0 → 0.9.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 (92) hide show
  1. package/README.md +40 -10
  2. package/config.example.json +17 -1
  3. package/deploy/README.md +3 -2
  4. package/deploy/install.mjs +5 -3
  5. package/dist/index.mjs +4261 -1642
  6. package/dist/index.mjs.map +4 -4
  7. package/package.json +12 -2
  8. package/docs/TOOLS.md +0 -538
  9. package/docs/agent-user-model.md +0 -89
  10. package/docs/changelog/v0/v0.2.0.md +0 -26
  11. package/docs/changelog/v0/v0.2.1.md +0 -23
  12. package/docs/changelog/v0/v0.2.2.md +0 -23
  13. package/docs/changelog/v0/v0.2.3.md +0 -33
  14. package/docs/changelog/v0/v0.2.4.md +0 -25
  15. package/docs/changelog/v0/v0.2.5.md +0 -29
  16. package/docs/changelog/v0/v0.3.0.md +0 -84
  17. package/docs/changelog/v0/v0.3.1.md +0 -30
  18. package/docs/changelog/v0/v0.3.2.md +0 -24
  19. package/docs/changelog/v0/v0.3.3.md +0 -28
  20. package/docs/changelog/v0/v0.4.0.md +0 -58
  21. package/docs/changelog/v0/v0.4.1.md +0 -33
  22. package/docs/changelog/v0/v0.4.2.md +0 -45
  23. package/docs/changelog/v0/v0.5.0.md +0 -39
  24. package/docs/changelog/v0/v0.5.1.md +0 -28
  25. package/docs/changelog/v0/v0.6.0.md +0 -52
  26. package/docs/changelog/v0/v0.6.1.md +0 -48
  27. package/docs/changelog/v0/v0.7.0.md +0 -71
  28. package/docs/changelog/v0/v0.8.0.md +0 -115
  29. package/docs/deploy-architecture.md +0 -84
  30. package/src/appauth.ts +0 -56
  31. package/src/audit.ts +0 -34
  32. package/src/availability.ts +0 -42
  33. package/src/catalog.ts +0 -182
  34. package/src/config.ts +0 -157
  35. package/src/index.ts +0 -107
  36. package/src/paths.ts +0 -60
  37. package/src/router.ts +0 -299
  38. package/src/secrets.ts +0 -167
  39. package/src/server.ts +0 -91
  40. package/src/types.ts +0 -128
  41. package/src/upstream.ts +0 -85
  42. package/src/verbs/ghcr/archive.ts +0 -314
  43. package/src/verbs/ghcr/config.ts +0 -142
  44. package/src/verbs/ghcr/jobs.ts +0 -141
  45. package/src/verbs/ghcr/push.ts +0 -192
  46. package/src/verbs/ghcr/registry.ts +0 -275
  47. package/src/verbs/ghcr/tar.ts +0 -203
  48. package/src/verbs/ghcr_push.ts +0 -264
  49. package/src/verbs/issue_labels.ts +0 -329
  50. package/src/verbs/link_issue.ts +0 -84
  51. package/src/verbs/npm-registry.ts +0 -168
  52. package/src/verbs/npm-tarball.ts +0 -76
  53. package/src/verbs/npm_scaffold.ts +0 -325
  54. package/src/verbs/release-analysis.ts +0 -212
  55. package/src/verbs/repo-tier.ts +0 -90
  56. package/src/verbs/report_ci_status.ts +0 -358
  57. package/src/verbs/report_release_prs.ts +0 -238
  58. package/src/verbs/report_security_alerts.ts +0 -442
  59. package/src/verbs/report_unlabeled.ts +0 -194
  60. package/src/verbs/rest-client.ts +0 -181
  61. package/src/verbs/review_thread.ts +0 -138
  62. package/tests/appauth.test.vitest.mts +0 -76
  63. package/tests/audit.test.vitest.mts +0 -48
  64. package/tests/catalog.test.vitest.mts +0 -234
  65. package/tests/config-merge.test.vitest.mts +0 -325
  66. package/tests/config.test.vitest.mts +0 -388
  67. package/tests/credentials.test.vitest.mts +0 -127
  68. package/tests/ghcr-archive.test.vitest.mts +0 -439
  69. package/tests/ghcr-config.test.vitest.mts +0 -96
  70. package/tests/ghcr-push.test.vitest.mts +0 -526
  71. package/tests/ghcr-registry.test.vitest.mts +0 -206
  72. package/tests/helpers/ghcr-fixtures.mts +0 -373
  73. package/tests/install-args.test.vitest.mts +0 -152
  74. package/tests/issue-labels.test.vitest.mts +0 -362
  75. package/tests/npm-scaffold.test.vitest.mts +0 -479
  76. package/tests/npm-setup.test.vitest.mts +0 -196
  77. package/tests/paths.test.vitest.mts +0 -57
  78. package/tests/prompt.test.vitest.mts +0 -128
  79. package/tests/release-prs.test.vitest.mts +0 -247
  80. package/tests/rest-client.test.vitest.mts +0 -115
  81. package/tests/router.test.vitest.mts +0 -505
  82. package/tests/run-vitest.mjs +0 -51
  83. package/tests/secret-store.test.vitest.mts +0 -167
  84. package/tests/secrets.test.vitest.mts +0 -208
  85. package/tests/security-alerts.test.vitest.mts +0 -338
  86. package/tests/server.test.vitest.mts +0 -200
  87. package/tests/smoke.test.vitest.mts +0 -177
  88. package/tests/system.test.vitest.mts +0 -355
  89. package/tests/upstream.test.vitest.mts +0 -91
  90. package/tests/verb-helpers.test.vitest.mts +0 -377
  91. package/tests/verbs.test.vitest.mts +0 -624
  92. package/tsconfig.json +0 -21
@@ -1,388 +0,0 @@
1
- import { describe, it, expect, beforeAll, afterAll, beforeEach } from "vitest";
2
- import { mkdirSync, writeFileSync, rmSync, readFileSync } from "node:fs";
3
- import { join } from "node:path";
4
- import { loadConfig, indexTools } from "../src/config.ts";
5
-
6
- // loadConfig reads a real file off disk, so we materialize each fixture into the
7
- // repo's gitignored tmp/ and pass the path.
8
- const DIR = join(process.cwd(), "tmp", "config-tests");
9
- let counter = 0;
10
-
11
- /** Write `contents` (string or object) to a unique tmp file and return its path. */
12
- function writeConfig(contents: unknown): string {
13
- const path = join(DIR, `cfg-${counter++}.json`);
14
- writeFileSync(path, typeof contents === "string" ? contents : JSON.stringify(contents));
15
- return path;
16
- }
17
-
18
- function baseConfig(): Record<string, unknown> {
19
- return {
20
- listen: "unix:/run/gh-broker/gh-broker.sock",
21
- upstream: "https://api.githubcopilot.com/mcp/",
22
- tools: [{ name: "get_me", mode: "read" }],
23
- owners: { cldmv: { read: "cred:cldmv-read", write: "cred:cldmv-write" } }
24
- };
25
- }
26
-
27
- beforeAll(() => {
28
- mkdirSync(DIR, { recursive: true });
29
- });
30
-
31
- afterAll(() => {
32
- rmSync(DIR, { recursive: true, force: true });
33
- });
34
-
35
- describe("loadConfig — file/parse errors", () => {
36
- it("throws when the file cannot be read", () => {
37
- expect(() => loadConfig(join(DIR, "does-not-exist.json"))).toThrow(/cannot read\/parse/);
38
- });
39
-
40
- it("throws on malformed JSON", () => {
41
- const p = writeConfig("{ not json ");
42
- expect(() => loadConfig(p)).toThrow(/cannot read\/parse/);
43
- });
44
-
45
- it("throws when the root is not an object", () => {
46
- const p = writeConfig("123");
47
- expect(() => loadConfig(p)).toThrow(/root must be an object/);
48
- });
49
-
50
- it("throws when the root is JSON null", () => {
51
- const p = writeConfig("null");
52
- expect(() => loadConfig(p)).toThrow(/root must be an object/);
53
- });
54
- });
55
-
56
- describe("loadConfig — listen / upstream", () => {
57
- it("rejects a non-string listen", () => {
58
- const c = baseConfig();
59
- delete c.listen;
60
- expect(() => loadConfig(writeConfig(c))).toThrow(/config\.listen/);
61
- });
62
-
63
- it("rejects a listen without the unix: prefix", () => {
64
- const c = baseConfig();
65
- c.listen = "tcp:0.0.0.0:9000";
66
- expect(() => loadConfig(writeConfig(c))).toThrow(/config\.listen/);
67
- });
68
-
69
- it("rejects a non-http upstream", () => {
70
- const c = baseConfig();
71
- c.upstream = "ftp://example.com";
72
- expect(() => loadConfig(writeConfig(c))).toThrow(/config\.upstream/);
73
- });
74
-
75
- it("rejects a non-string upstream", () => {
76
- const c = baseConfig();
77
- c.upstream = 42;
78
- expect(() => loadConfig(writeConfig(c))).toThrow(/config\.upstream/);
79
- });
80
- });
81
-
82
- describe("loadConfig — tools", () => {
83
- it("rejects a missing tools array", () => {
84
- const c = baseConfig();
85
- delete c.tools;
86
- expect(() => loadConfig(writeConfig(c))).toThrow(/config\.tools: must be a non-empty array/);
87
- });
88
-
89
- it("rejects an empty tools array", () => {
90
- const c = baseConfig();
91
- c.tools = [];
92
- expect(() => loadConfig(writeConfig(c))).toThrow(/config\.tools: must be a non-empty array/);
93
- });
94
-
95
- it("rejects a tool with no name", () => {
96
- const c = baseConfig();
97
- c.tools = [{ mode: "read" }];
98
- expect(() => loadConfig(writeConfig(c))).toThrow(/config\.tools\[0\]\.name: required/);
99
- });
100
-
101
- it("rejects a tool with a bad mode", () => {
102
- const c = baseConfig();
103
- c.tools = [{ name: "get_me", mode: "admin" }];
104
- expect(() => loadConfig(writeConfig(c))).toThrow(/config\.tools\[0\]\.mode: must be read\|write/);
105
- });
106
-
107
- it("rejects duplicate tool names", () => {
108
- const c = baseConfig();
109
- c.tools = [
110
- { name: "get_me", mode: "read" },
111
- { name: "get_me", mode: "write" }
112
- ];
113
- expect(() => loadConfig(writeConfig(c))).toThrow(/duplicate tool "get_me"/);
114
- });
115
-
116
- it("carries the custom flag through, defaulting to false", () => {
117
- const c = baseConfig();
118
- c.tools = [
119
- { name: "get_me", mode: "read" },
120
- { name: "link_issue", mode: "write", custom: true }
121
- ];
122
- const out = loadConfig(writeConfig(c));
123
- expect(out.tools).toEqual([
124
- { name: "get_me", mode: "read", custom: false, augment: false },
125
- { name: "link_issue", mode: "write", custom: true, augment: false }
126
- ]);
127
- });
128
-
129
- it("carries the augment flag through, defaulting to false", () => {
130
- const c = baseConfig();
131
- c.tools = [
132
- { name: "get_me", mode: "read" },
133
- { name: "pull_request_read", mode: "read", augment: true }
134
- ];
135
- const out = loadConfig(writeConfig(c));
136
- expect(out.tools).toEqual([
137
- { name: "get_me", mode: "read", custom: false, augment: false },
138
- { name: "pull_request_read", mode: "read", custom: false, augment: true }
139
- ]);
140
- });
141
-
142
- it("rejects a tool marked both custom and augment", () => {
143
- const c = baseConfig();
144
- c.tools = [{ name: "weird", mode: "read", custom: true, augment: true }];
145
- expect(() => loadConfig(writeConfig(c))).toThrow(/custom and augment are mutually exclusive/);
146
- });
147
- });
148
-
149
- describe("loadConfig — owners", () => {
150
- it("rejects a non-object owners", () => {
151
- const c = baseConfig();
152
- c.owners = "cldmv";
153
- expect(() => loadConfig(writeConfig(c))).toThrow(/config\.owners: must be an object/);
154
- });
155
-
156
- it("rejects an owner missing read/write", () => {
157
- const c = baseConfig();
158
- c.owners = { cldmv: { read: "cred:x" } };
159
- expect(() => loadConfig(writeConfig(c))).toThrow(/config\.owners\.cldmv: needs "read" and "write"/);
160
- });
161
-
162
- it("rejects a non-numeric installationId", () => {
163
- const c = baseConfig();
164
- c.owners = { cldmv: { read: "cred:r", write: "cred:w", installationId: "42" } };
165
- expect(() => loadConfig(writeConfig(c))).toThrow(/config\.owners\.cldmv\.installationId: must be a number/);
166
- });
167
-
168
- it("rejects an empty owners object", () => {
169
- const c = baseConfig();
170
- c.owners = {};
171
- expect(() => loadConfig(writeConfig(c))).toThrow(/at least one owner required/);
172
- });
173
-
174
- it("lowercases owner keys and carries installationId", () => {
175
- const c = baseConfig();
176
- c.owners = { CLDMV: { read: "cred:r", write: "cred:w", installationId: 99 } };
177
- const out = loadConfig(writeConfig(c));
178
- expect(out.owners).toEqual({ cldmv: { read: "cred:r", write: "cred:w", installationId: 99 } });
179
- });
180
-
181
- it("omits installationId when absent", () => {
182
- const out = loadConfig(writeConfig(baseConfig()));
183
- expect(out.owners.cldmv).toEqual({ read: "cred:cldmv-read", write: "cred:cldmv-write" });
184
- expect(out.owners.cldmv).not.toHaveProperty("installationId");
185
- });
186
- });
187
-
188
- describe("loadConfig — defaultOwner", () => {
189
- it("is undefined when omitted", () => {
190
- const out = loadConfig(writeConfig(baseConfig()));
191
- expect(out.defaultOwner).toBeUndefined();
192
- });
193
-
194
- it("lowercases a valid defaultOwner and carries it through", () => {
195
- const c = baseConfig();
196
- c.defaultOwner = "CLDMV";
197
- const out = loadConfig(writeConfig(c));
198
- expect(out.defaultOwner).toBe("cldmv");
199
- });
200
-
201
- it("rejects a non-string / empty defaultOwner", () => {
202
- const c = baseConfig();
203
- c.defaultOwner = "";
204
- expect(() => loadConfig(writeConfig(c))).toThrow(/config\.defaultOwner: must be a non-empty string/);
205
- });
206
-
207
- it("rejects a defaultOwner that is not a configured owner", () => {
208
- const c = baseConfig();
209
- c.defaultOwner = "nobody";
210
- expect(() => loadConfig(writeConfig(c))).toThrow(/config\.defaultOwner "nobody" is not a key of config\.owners/);
211
- });
212
- });
213
-
214
- describe("loadConfig — app", () => {
215
- it("rejects a non-numeric appId", () => {
216
- const c = baseConfig();
217
- c.app = { appId: "1", privateKey: "cred:app" };
218
- expect(() => loadConfig(writeConfig(c))).toThrow(/config\.app\.appId: must be a number/);
219
- });
220
-
221
- it("rejects a non-string privateKey", () => {
222
- const c = baseConfig();
223
- c.app = { appId: 1, privateKey: 5 };
224
- expect(() => loadConfig(writeConfig(c))).toThrow(/config\.app\.privateKey/);
225
- });
226
-
227
- it("carries a valid app config through", () => {
228
- const c = baseConfig();
229
- c.app = { appId: 12345, privateKey: "cred:gh-app-key" };
230
- const out = loadConfig(writeConfig(c));
231
- expect(out.app).toEqual({ appId: 12345, privateKey: "cred:gh-app-key" });
232
- });
233
-
234
- it("omits app when absent", () => {
235
- const out = loadConfig(writeConfig(baseConfig()));
236
- expect(out.app).toBeUndefined();
237
- });
238
- });
239
-
240
- describe("loadConfig — secrets", () => {
241
- it("rejects an array (which typeof reports as 'object')", () => {
242
- const c = baseConfig();
243
- c.secrets = [];
244
- expect(() => loadConfig(writeConfig(c))).toThrow(/config\.secrets: must be an object/);
245
- });
246
-
247
- it("rejects null", () => {
248
- const c = baseConfig();
249
- c.secrets = null;
250
- expect(() => loadConfig(writeConfig(c))).toThrow(/config\.secrets: must be an object/);
251
- });
252
-
253
- it("rejects an unknown provider", () => {
254
- const c = baseConfig();
255
- c.secrets = { provider: "vault" };
256
- expect(() => loadConfig(writeConfig(c))).toThrow(/config\.secrets\.provider/);
257
- });
258
-
259
- it("carries a valid secrets block through", () => {
260
- const c = baseConfig();
261
- c.secrets = { provider: "file", path: "/etc/gh-broker/secrets.json" };
262
- const out = loadConfig(writeConfig(c));
263
- expect(out.secrets).toEqual({ provider: "file", path: "/etc/gh-broker/secrets.json" });
264
- });
265
-
266
- it("carries an empty secrets block, and a provider without a path, through", () => {
267
- const c = baseConfig();
268
- c.secrets = {};
269
- expect(loadConfig(writeConfig(c)).secrets).toEqual({});
270
- c.secrets = { provider: "file" };
271
- expect(loadConfig(writeConfig(c)).secrets).toEqual({ provider: "file" });
272
- c.secrets = { provider: "systemd-creds" };
273
- expect(loadConfig(writeConfig(c)).secrets).toEqual({ provider: "systemd-creds" });
274
- });
275
-
276
- it("rejects a path without the file provider (omitted → systemd-creds default)", () => {
277
- const c = baseConfig();
278
- c.secrets = { path: "/etc/gh-broker/secrets.json" };
279
- expect(() => loadConfig(writeConfig(c))).toThrow(/config\.secrets\.path is only used by the "file" provider/);
280
- });
281
-
282
- it("rejects a path with the systemd-creds provider", () => {
283
- const c = baseConfig();
284
- c.secrets = { provider: "systemd-creds", path: "/etc/gh-broker/secrets.json" };
285
- expect(() => loadConfig(writeConfig(c))).toThrow(/config\.secrets\.path is only used by the "file" provider/);
286
- });
287
-
288
- it("rejects an empty path (would be silently ignored by resolveSecretsPath)", () => {
289
- const c = baseConfig();
290
- c.secrets = { provider: "file", path: "" };
291
- expect(() => loadConfig(writeConfig(c))).toThrow(/config\.secrets\.path: must be a non-empty string/);
292
- });
293
- });
294
-
295
- describe("loadConfig — full valid config", () => {
296
- it("returns the normalized shape", () => {
297
- const c = baseConfig();
298
- c.defaultOwner = "cldmv";
299
- c.app = { appId: 7, privateKey: "cred:app" };
300
- const out = loadConfig(writeConfig(c));
301
- expect(out).toEqual({
302
- listen: "unix:/run/gh-broker/gh-broker.sock",
303
- upstream: "https://api.githubcopilot.com/mcp/",
304
- tools: [{ name: "get_me", mode: "read", custom: false, augment: false }],
305
- owners: { cldmv: { read: "cred:cldmv-read", write: "cred:cldmv-write" } },
306
- app: { appId: 7, privateKey: "cred:app" },
307
- defaultOwner: "cldmv"
308
- });
309
- });
310
- });
311
-
312
- describe("indexTools", () => {
313
- it("builds a name→rule map", () => {
314
- const rules = [
315
- { name: "get_me", mode: "read" as const },
316
- { name: "create_pull_request", mode: "write" as const, custom: false }
317
- ];
318
- const map = indexTools(rules);
319
- expect(map.size).toBe(2);
320
- expect(map.get("get_me")).toEqual({ name: "get_me", mode: "read" });
321
- expect(map.get("create_pull_request")).toEqual({ name: "create_pull_request", mode: "write", custom: false });
322
- expect(map.get("missing")).toBeUndefined();
323
- });
324
- });
325
-
326
- describe("loadConfig — npm", () => {
327
- it("is absent unless configured", () => {
328
- expect(loadConfig(writeConfig(baseConfig())).npm).toBeUndefined();
329
- });
330
-
331
- it("leaves registry out when it is not set", () => {
332
- expect(loadConfig(writeConfig({ ...baseConfig(), npm: { token: "cred:npm-token", scopes: ["@cldmv"] } })).npm).toEqual({ token: "cred:npm-token", scopes: ["@cldmv"] });
333
- });
334
-
335
- it("accepts a token ref, scopes, an https registry and token orgs", () => {
336
- const npm = { token: "cred:npm-token", scopes: ["@cldmv"], registry: "https://registry.npmjs.org", tokenOrgs: ["cldmv"] };
337
- expect(loadConfig(writeConfig({ ...baseConfig(), npm })).npm).toEqual(npm);
338
- });
339
-
340
- it.each([
341
- [[], /config.npm: must be an object/],
342
- [{ scopes: ["@cldmv"] }, /config.npm.token/],
343
- [{ token: "npm_inline_secret", scopes: ["@cldmv"] }, /config.npm.token/],
344
- [{ token: "cred:npm-token" }, /config.npm.scopes: must be a non-empty array/],
345
- [{ token: "cred:npm-token", scopes: [] }, /config.npm.scopes: must be a non-empty array/],
346
- [{ token: "cred:npm-token", scopes: ["cldmv"] }, /config.npm.scopes\[0\]/],
347
- [{ token: "cred:npm-token", scopes: ["@CLDMV"] }, /config.npm.scopes\[0\]/],
348
- [{ token: "cred:npm-token", scopes: ["@cldmv"], registry: "http://registry.npmjs.org" }, /config.npm.registry: must be an https URL/],
349
- [{ token: "cred:npm-token", scopes: ["@cldmv"], tokenOrgs: "cldmv" }, /config.npm.tokenOrgs/],
350
- [{ token: "cred:npm-token", scopes: ["@cldmv"], tokenOrgs: ["@cldmv"] }, /config.npm.tokenOrgs/]
351
- ] as Array<[unknown, RegExp]>)("rejects an invalid npm block (%#)", (npm, msg) => {
352
- expect(() => loadConfig(writeConfig({ ...baseConfig(), npm }))).toThrow(msg);
353
- });
354
- });
355
-
356
- describe("loadConfig — ghcr", () => {
357
- const ghcr = { dropDir: "/srv/gh-broker/ghcr-drop", owners: { cldmv: { username: "Shinrai", credential: "cred:cldmv-packages", images: ["hoot"] } } };
358
-
359
- it("parses a ghcr block", () => {
360
- const c = loadConfig(writeConfig({ ...baseConfig(), ghcr }));
361
- expect(c.ghcr?.owners.cldmv?.credential).toBe("cred:cldmv-packages");
362
- expect(c.ghcr?.protectReleaseTags).toBe(true);
363
- });
364
-
365
- it("omits ghcr when absent and rejects a malformed block", () => {
366
- expect(loadConfig(writeConfig(baseConfig())).ghcr).toBeUndefined();
367
- expect(() => loadConfig(writeConfig({ ...baseConfig(), ghcr: { ...ghcr, dropDir: "/etc/x/y" } }))).toThrow(/config.ghcr.dropDir: may not be under \/etc/);
368
- });
369
-
370
- it("ships no owner-specific data in config.example.json, and it loads once an owner is added", () => {
371
- const raw = JSON.parse(readFileSync(join(process.cwd(), "config.example.json"), "utf8"));
372
- expect(Object.keys(raw).sort()).toEqual(["listen", "tools", "upstream"]);
373
- const c = loadConfig(writeConfig({ ...raw, owners: { acme: { read: "cred:acme-read", write: "cred:acme-write" } } }));
374
- expect(c.tools.filter((t) => t.name.startsWith("ghcr_")).map((t) => [t.name, t.mode, t.custom])).toEqual([
375
- ["ghcr_push", "write", true],
376
- ["ghcr_push_status", "read", true],
377
- ["ghcr_tags", "read", true]
378
- ]);
379
- });
380
-
381
- it("accepts the documentation-only config.sample.json merged onto the example tools", () => {
382
- const raw = JSON.parse(readFileSync(join(process.cwd(), "config.example.json"), "utf8"));
383
- const sample = JSON.parse(readFileSync(join(process.cwd(), "config.sample.json"), "utf8"));
384
- const c = loadConfig(writeConfig({ ...sample, tools: raw.tools }));
385
- expect(c.ghcr?.dropDir).toBe("/srv/gh-broker/ghcr-drop");
386
- expect(c.defaultOwner).toBe("your-org");
387
- });
388
- });
@@ -1,127 +0,0 @@
1
- import { describe, it, expect, vi } from "vitest";
2
- import { credentialPlan } from "../deploy/lib/credentials.mjs";
3
-
4
- type Opts = Parameters<typeof credentialPlan>[0];
5
-
6
- function plan(over: Partial<Opts> = {}, answers: string[] = []) {
7
- const logs: string[] = [];
8
- const promptHidden = vi.fn(async () => answers.shift() ?? "");
9
- const mint = vi.fn(async () => "npm_MINTED");
10
- const p = credentialPlan({
11
- needed: ["cldmv-read", "cldmv-write", "npm-token"],
12
- optional: new Set(["npm-token"]),
13
- cliSecrets: {},
14
- imported: {},
15
- env: {},
16
- existingFile: {},
17
- decrypt: () => undefined,
18
- mints: () => false,
19
- mint,
20
- appKeyName: undefined,
21
- nonInteractive: false,
22
- promptHidden,
23
- log: (m: string) => logs.push(m),
24
- ...over
25
- });
26
- return { p, logs, promptHidden, mint };
27
- }
28
-
29
- /** A fake systemd-creds store: which .cred files exist, and what was encrypted. */
30
- function creds(existing: string[] = []) {
31
- const files = new Set(existing);
32
- const written: Record<string, string> = {};
33
- return {
34
- written,
35
- io: {
36
- exists: (n: string) => files.has(n),
37
- encrypt: (n: string, v: string) => {
38
- files.add(n);
39
- written[n] = v;
40
- }
41
- }
42
- };
43
- }
44
-
45
- describe("credentialPlan", () => {
46
- it("refuses a --secret name the config does not use", () => {
47
- expect(() => plan({ cliSecrets: { "cldmv-raed": "x" } })).toThrow(/--secret names no credential this config uses: cldmv-raed \(it uses: cldmv-read, cldmv-write, npm-token\)/);
48
- });
49
-
50
- it("takes an explicit value from --secret, then --secrets-file, then env", () => {
51
- const env = { GH_BROKER_SECRET_CLDMV_READ: "from-env" };
52
- expect(plan({ env }).p.explicit("cldmv-read")).toBe("from-env");
53
- expect(plan({ env, imported: { "cldmv-read": "from-file" } }).p.explicit("cldmv-read")).toBe("from-file");
54
- expect(plan({ env, imported: { "cldmv-read": "from-file" }, cliSecrets: { "cldmv-read": "from-cli" } }).p.explicit("cldmv-read")).toBe("from-cli");
55
- expect(plan({ imported: { "cldmv-read": "" }, env: { GH_BROKER_SECRET_CLDMV_READ: "" } }).p.explicit("cldmv-read")).toBeUndefined();
56
- });
57
-
58
- it("resolves explicit → stored file → decrypt → prompt", async () => {
59
- expect(await plan({ cliSecrets: { "cldmv-read": "cli" }, existingFile: { "cldmv-read": "old" } }).p.resolve("cldmv-read")).toBe("cli");
60
- expect(await plan({ existingFile: { "cldmv-read": "old" }, decrypt: () => "dec" }).p.resolve("cldmv-read")).toBe("old");
61
- const d = plan({ decrypt: () => "dec" });
62
- expect(await d.p.resolve("cldmv-read")).toBe("dec");
63
- expect(d.logs).toContain(" cldmv-read: migrated from systemd-creds");
64
- const prompted = plan({}, ["typed"]);
65
- expect(await prompted.p.resolve("cldmv-read")).toBe("typed");
66
- expect(prompted.promptHidden).toHaveBeenCalledWith(" cldmv-read (input hidden): ");
67
- });
68
-
69
- it("fails a required credential that is missing or left empty", async () => {
70
- await expect(plan({ nonInteractive: true }).p.resolve("cldmv-read")).rejects.toThrow(/missing credential "cldmv-read" — provide via --secret, --secrets-file or env GH_BROKER_SECRET_CLDMV_READ/);
71
- await expect(plan({}, [""]).p.resolve("cldmv-read")).rejects.toThrow(/empty value for "cldmv-read"/);
72
- await expect(plan({ appKeyName: "gh-app-key", needed: ["gh-app-key"] }).p.resolve("gh-app-key")).rejects.toThrow(/GitHub App private key/);
73
- });
74
-
75
- it("lets an optional credential be skipped, never failing", async () => {
76
- expect(await plan({ nonInteractive: true }).p.resolve("npm-token")).toBeUndefined();
77
- const i = plan({}, [""]);
78
- expect(await i.p.resolve("npm-token")).toBeUndefined();
79
- expect(i.promptHidden).toHaveBeenCalledWith(expect.stringMatching(/Enter to skip/));
80
- expect(await plan({}, ["npm_typed"]).p.resolve("npm-token")).toBe("npm_typed");
81
- });
82
-
83
- it("creates the npm token when this run mints it, unless one is supplied", async () => {
84
- const m = plan({ mints: (n) => n === "npm-token" });
85
- expect(await m.p.resolve("npm-token")).toBe("npm_MINTED");
86
- expect(m.p.replaces("npm-token")).toBe(true);
87
- const given = plan({ mints: (n) => n === "npm-token", cliSecrets: { "npm-token": "npm_given" } });
88
- expect(await given.p.resolve("npm-token")).toBe("npm_given");
89
- expect(given.mint).not.toHaveBeenCalled();
90
- await expect(plan({ mints: () => true, nonInteractive: true }).p.resolve("npm-token")).rejects.toThrow(/needs a browser login/);
91
- });
92
-
93
- describe("storeSystemd", () => {
94
- it("keeps existing creds, encrypts new ones, and lists only what is stored", async () => {
95
- const c = creds(["cldmv-read"]);
96
- const { p, logs } = plan({}, ["write-typed", ""]);
97
- const stored = await p.storeSystemd(c.io);
98
- expect(stored).toEqual(["cldmv-read", "cldmv-write"]);
99
- expect(c.written).toEqual({ "cldmv-write": "write-typed" });
100
- expect(logs).toEqual([" cldmv-read: existing systemd-creds kept", " cldmv-write: encrypted (systemd-creds)", " npm-token: not stored — its tools stay hidden until it is (re-run the installer to add it)"]);
101
- });
102
-
103
- it("rotates on an explicit value or a plaintext entry, and notes a replaced npm token", async () => {
104
- const c = creds(["cldmv-read", "cldmv-write", "npm-token"]);
105
- const { p, logs } = plan({ cliSecrets: { "cldmv-read": "new-read" }, existingFile: { "cldmv-write": "file-write" }, mints: (n) => n === "npm-token" });
106
- expect(await p.storeSystemd(c.io)).toEqual(["cldmv-read", "cldmv-write", "npm-token"]);
107
- expect(c.written).toEqual({ "cldmv-read": "new-read", "cldmv-write": "file-write", "npm-token": "npm_MINTED" });
108
- expect(logs).toContain(" cldmv-read: rotated (systemd-creds)");
109
- expect(logs.some((l) => /previous npm-token is still valid/.test(l))).toBe(true);
110
- expect(logs.filter((l) => /previous/.test(l))).toHaveLength(1);
111
- });
112
- });
113
-
114
- describe("storeFile", () => {
115
- it("keeps stored values, fills missing ones, rotates on an explicit value, and skips an optional one", async () => {
116
- const { p, logs } = plan({ needed: ["cldmv-read", "cldmv-write", "cldmv-new", "npm-token"], existingFile: { "cldmv-read": "old-read", "cldmv-write": "old-write" }, cliSecrets: { "cldmv-write": "new-write", "cldmv-new": "fresh" }, nonInteractive: true });
117
- expect(await p.storeFile()).toEqual({ "cldmv-read": "old-read", "cldmv-write": "new-write", "cldmv-new": "fresh" });
118
- expect(logs).toEqual([" npm-token: not stored — its tools stay hidden until it is (re-run the installer to add it)"]);
119
- });
120
-
121
- it("replaces a stored npm token with a newly created one and says the old one is still live", async () => {
122
- const { p, logs } = plan({ existingFile: { "cldmv-read": "r", "cldmv-write": "w", "npm-token": "npm_old" }, mints: (n) => n === "npm-token" });
123
- expect(await p.storeFile()).toEqual({ "cldmv-read": "r", "cldmv-write": "w", "npm-token": "npm_MINTED" });
124
- expect(logs.some((l) => /previous npm-token is still valid/.test(l))).toBe(true);
125
- });
126
- });
127
- });