@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,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
- });