@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,234 +0,0 @@
1
- import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
2
- import { buildCatalog } from "../src/catalog.ts";
3
-
4
- function makeConfig(tools: any[], owners: Record<string, unknown> = { cldmv: { read: "cred:r", write: "cred:w" } }): any {
5
- return { listen: "unix:/x", upstream: "https://api.githubcopilot.com/mcp/", tools, owners };
6
- }
7
-
8
- describe("buildCatalog", () => {
9
- let secrets: { resolve: ReturnType<typeof vi.fn> };
10
- let upstream: { listToolsFor: ReturnType<typeof vi.fn> };
11
- let errSpy: ReturnType<typeof vi.spyOn>;
12
-
13
- beforeEach(() => {
14
- secrets = { resolve: vi.fn(() => "tok"), has: vi.fn(() => true) };
15
- upstream = { listToolsFor: vi.fn() };
16
- errSpy = vi.spyOn(console, "error").mockImplementation(() => {});
17
- });
18
-
19
- afterEach(() => {
20
- vi.restoreAllMocks();
21
- delete process.env.GH_BROKER_CATALOG_TIMEOUT_MS;
22
- });
23
-
24
- it("uses upstream schemas, emits local schemas for custom verbs, and skips unexposed proxied tools", async () => {
25
- const upstreamTool = { name: "get_me", description: "real", inputSchema: { type: "object", properties: {} } };
26
- upstream.listToolsFor.mockResolvedValue([upstreamTool]);
27
- const config = makeConfig([
28
- { name: "get_me", mode: "read" },
29
- { name: "not_exposed", mode: "read" },
30
- { name: "link_issue", mode: "write", custom: true },
31
- { name: "resolve_review_thread", mode: "write", custom: true },
32
- { name: "reply_review_thread", mode: "write", custom: true },
33
- { name: "mystery_custom", mode: "read", custom: true }
34
- ]);
35
- const catalog = await buildCatalog(config, secrets as any, upstream as any);
36
-
37
- expect(secrets.resolve).toHaveBeenCalledWith("cred:r");
38
- const names = catalog.map((t) => t.name);
39
- // get_me is served from upstream; not_exposed is dropped (fetched but absent);
40
- // the two wired custom verbs get local schemas; link_issue is DEFERRED (its
41
- // catalog branch is commented out) and mystery_custom matches no branch — both
42
- // are silently skipped even when allowlisted.
43
- expect(names).toEqual(["get_me", "resolve_review_thread", "reply_review_thread"]);
44
- expect(names).not.toContain("link_issue");
45
- expect(catalog[0]).toBe(upstreamTool);
46
- expect(catalog.find((t) => t.name === "resolve_review_thread")?.inputSchema).toMatchObject({ type: "object" });
47
- expect(errSpy).toHaveBeenCalledWith(expect.stringMatching(/allowlisted tool "not_exposed" is NOT exposed/));
48
- });
49
-
50
- it("advertises report_unlabeled with its local schema", async () => {
51
- upstream.listToolsFor.mockResolvedValue([]);
52
- const config = makeConfig([{ name: "report_unlabeled", mode: "read", custom: true }]);
53
- const catalog = await buildCatalog(config, secrets as any, upstream as any);
54
- expect(catalog).toHaveLength(1);
55
- expect(catalog[0]).toMatchObject({ name: "report_unlabeled" });
56
- expect(catalog[0]?.inputSchema).toMatchObject({ type: "object", required: ["owner"] });
57
- });
58
-
59
- it("advertises report_security_alerts with its local schema", async () => {
60
- upstream.listToolsFor.mockResolvedValue([]);
61
- const config = makeConfig([{ name: "report_security_alerts", mode: "read", custom: true }]);
62
- const catalog = await buildCatalog(config, secrets as any, upstream as any);
63
- expect(catalog).toHaveLength(1);
64
- expect(catalog[0]).toMatchObject({ name: "report_security_alerts" });
65
- expect(catalog[0]?.inputSchema).toMatchObject({ type: "object", required: ["owner"] });
66
- });
67
-
68
- it("advertises report_release_prs with its local schema", async () => {
69
- upstream.listToolsFor.mockResolvedValue([]);
70
- const config = makeConfig([{ name: "report_release_prs", mode: "read", custom: true }]);
71
- const catalog = await buildCatalog(config, secrets as any, upstream as any);
72
- expect(catalog).toHaveLength(1);
73
- expect(catalog[0]).toMatchObject({ name: "report_release_prs" });
74
- expect(catalog[0]?.inputSchema).toMatchObject({ type: "object", required: ["owner"] });
75
- });
76
-
77
- it("does not expose npm_scaffold without an npm block, or when its token is not stored", async () => {
78
- upstream.listToolsFor.mockResolvedValue([]);
79
- const rules = [{ name: "npm_scaffold", mode: "write", custom: true }];
80
- expect(await buildCatalog(makeConfig(rules), secrets as any, upstream as any)).toEqual([]);
81
- expect(errSpy).toHaveBeenCalledWith(expect.stringMatching(/"npm_scaffold" is not exposed: the npm integration is not configured/));
82
- secrets.has.mockReturnValue(false);
83
- const noToken = { ...makeConfig(rules), npm: { token: "cred:npm-token", scopes: ["@cldmv"] } };
84
- expect(await buildCatalog(noToken, secrets as any, upstream as any)).toEqual([]);
85
- expect(errSpy).toHaveBeenCalledWith(expect.stringMatching(/"npm_scaffold" is not exposed: its credential "npm-token" is not stored/));
86
- });
87
-
88
- it("advertises npm_scaffold with its local schema", async () => {
89
- upstream.listToolsFor.mockResolvedValue([]);
90
- const catalog = await buildCatalog({ ...makeConfig([{ name: "npm_scaffold", mode: "write", custom: true }]), npm: { token: "cred:npm-token", scopes: ["@cldmv"] } }, secrets as any, upstream as any);
91
- expect(catalog).toHaveLength(1);
92
- expect(catalog[0]).toMatchObject({ name: "npm_scaffold" });
93
- expect(catalog[0]?.description).toMatch(/one-time password/);
94
- expect(catalog[0]?.inputSchema).toMatchObject({ type: "object", required: ["owner", "repo", "package", "otp"] });
95
- });
96
-
97
- it("advertises issue_labels with its local schema", async () => {
98
- upstream.listToolsFor.mockResolvedValue([]);
99
- const config = makeConfig([{ name: "issue_labels", mode: "write", custom: true }]);
100
- const catalog = await buildCatalog(config, secrets as any, upstream as any);
101
- expect(catalog).toHaveLength(1);
102
- expect(catalog[0]).toMatchObject({ name: "issue_labels" });
103
- expect(catalog[0]?.description).toMatch(/swap/);
104
- expect(catalog[0]?.inputSchema).toMatchObject({ type: "object", required: ["owner", "repo", "issue_number"] });
105
- });
106
-
107
- it("warns on issue_write that labels replaces the set, pointing at issue_labels when it is allowlisted", async () => {
108
- const upstreamTool = { name: "issue_write", description: "Create or update an issue.", inputSchema: { type: "object", properties: { labels: {} } } };
109
- upstream.listToolsFor.mockResolvedValue([upstreamTool]);
110
- const withVerb = await buildCatalog(
111
- makeConfig([{ name: "issue_write", mode: "write" }, { name: "issue_labels", mode: "write", custom: true }]),
112
- secrets as any,
113
- upstream as any
114
- );
115
- const iw = withVerb.find((t) => t.name === "issue_write")!;
116
- expect(iw.description).toMatch(/^Create or update an issue\. WARNING \(gh-broker\): "labels" REPLACES the issue's whole label set/);
117
- expect(iw.description).toMatch(/use issue_labels instead/);
118
- expect(iw.inputSchema).toBe(upstreamTool.inputSchema);
119
- expect(upstreamTool.description).toBe("Create or update an issue."); // the fetched tool is not mutated
120
-
121
- const without = await buildCatalog(makeConfig([{ name: "issue_write", mode: "write" }]), secrets as any, upstream as any);
122
- expect(without[0]?.description).toMatch(/REPLACES/);
123
- expect(without[0]?.description).not.toMatch(/issue_labels/);
124
- expect(without[0]?.description).toMatch(/pass the full set/);
125
- });
126
-
127
- it("warns on issue_write even when the upstream tool carries no description", async () => {
128
- upstream.listToolsFor.mockResolvedValue([{ name: "issue_write", inputSchema: { type: "object" } }]);
129
- const catalog = await buildCatalog(makeConfig([{ name: "issue_write", mode: "write" }]), secrets as any, upstream as any);
130
- expect(catalog[0]?.description).toMatch(/^WARNING \(gh-broker\): "labels" REPLACES/);
131
- });
132
-
133
- it("still warns on issue_write when the upstream fetch fails (minimal schema)", async () => {
134
- upstream.listToolsFor.mockRejectedValue(new Error("down"));
135
- const catalog = await buildCatalog(makeConfig([{ name: "issue_write", mode: "write" }]), secrets as any, upstream as any);
136
- expect(catalog[0]?.description).toMatch(/^\(write\) proxied to the GitHub MCP WARNING/);
137
- });
138
-
139
- it("advertises report_ci_status with its local schema", async () => {
140
- upstream.listToolsFor.mockResolvedValue([]);
141
- const config = makeConfig([{ name: "report_ci_status", mode: "read", custom: true }]);
142
- const catalog = await buildCatalog(config, secrets as any, upstream as any);
143
- expect(catalog).toHaveLength(1);
144
- expect(catalog[0]).toMatchObject({ name: "report_ci_status" });
145
- expect(catalog[0]?.inputSchema).toMatchObject({ type: "object", required: ["owner", "repo"] });
146
- });
147
-
148
- it("layers ciVerbosity/logLines onto pull_request_read's real upstream schema without dropping its own params", async () => {
149
- const upstreamTool = {
150
- name: "pull_request_read",
151
- description: "Get information on a specific pull request.",
152
- inputSchema: { type: "object", required: ["owner", "repo", "pullNumber", "method"], properties: { method: { type: "string" }, pullNumber: { type: "number" } } }
153
- };
154
- upstream.listToolsFor.mockResolvedValue([upstreamTool]);
155
- const config = makeConfig([{ name: "pull_request_read", mode: "read", augment: true }]);
156
- const catalog = await buildCatalog(config, secrets as any, upstream as any);
157
- expect(catalog).toHaveLength(1);
158
- const tool = catalog[0]!;
159
- expect(tool.description).toContain("gh-broker extension");
160
- expect((tool.inputSchema as any).required).toEqual(["owner", "repo", "pullNumber", "method"]);
161
- expect((tool.inputSchema as any).properties.pullNumber).toEqual({ type: "number" });
162
- expect((tool.inputSchema as any).properties.ciVerbosity).toMatchObject({ type: "number", enum: [0, 1, 2, 3] });
163
- expect((tool.inputSchema as any).properties.logLines).toMatchObject({ type: "number" });
164
- });
165
-
166
- it("still adds ciVerbosity/logLines to pull_request_read when the upstream fetch fails entirely", async () => {
167
- upstream.listToolsFor.mockRejectedValue(new Error("network down"));
168
- const config = makeConfig([{ name: "pull_request_read", mode: "read", augment: true }]);
169
- const catalog = await buildCatalog(config, secrets as any, upstream as any);
170
- expect(catalog).toHaveLength(1);
171
- expect((catalog[0]!.inputSchema as any).properties).toEqual({
172
- ciVerbosity: { type: "number", enum: [0, 1, 2, 3], description: expect.any(String) },
173
- logLines: { type: "number", description: expect.any(String) }
174
- });
175
- });
176
-
177
- it("falls back to minimal schemas when the upstream fetch fails", async () => {
178
- upstream.listToolsFor.mockRejectedValue(new Error("401 bad token"));
179
- const config = makeConfig([
180
- { name: "get_me", mode: "read" },
181
- { name: "resolve_review_thread", mode: "write", custom: true }
182
- ]);
183
- const catalog = await buildCatalog(config, secrets as any, upstream as any);
184
-
185
- const getMe = catalog.find((t) => t.name === "get_me");
186
- expect(getMe).toEqual({ name: "get_me", description: "(read) proxied to the GitHub MCP", inputSchema: { type: "object" } });
187
- expect(catalog.map((t) => t.name)).toContain("resolve_review_thread");
188
- expect(errSpy).toHaveBeenCalledWith(expect.stringMatching(/could not fetch upstream tool schemas/));
189
- });
190
-
191
- it("skips the upstream fetch entirely when there are no owners", async () => {
192
- const config = makeConfig([{ name: "get_me", mode: "read" }], {});
193
- const catalog = await buildCatalog(config, secrets as any, upstream as any);
194
- expect(upstream.listToolsFor).not.toHaveBeenCalled();
195
- expect(catalog).toEqual([{ name: "get_me", description: "(read) proxied to the GitHub MCP", inputSchema: { type: "object" } }]);
196
- });
197
-
198
- it("times out a slow upstream and advertises minimal schemas", async () => {
199
- process.env.GH_BROKER_CATALOG_TIMEOUT_MS = "15";
200
- upstream.listToolsFor.mockReturnValue(new Promise(() => {})); // never resolves
201
- const config = makeConfig([{ name: "get_me", mode: "read" }]);
202
- const catalog = await buildCatalog(config, secrets as any, upstream as any);
203
- expect(catalog.map((t) => t.name)).toEqual(["get_me"]);
204
- expect(errSpy).toHaveBeenCalledWith(expect.stringMatching(/timed out after 15ms/));
205
- });
206
-
207
- it("advertises ghcr_push / ghcr_push_status / ghcr_tags with their local schemas", async () => {
208
- upstream.listToolsFor.mockResolvedValue([]);
209
- const ghcrTools = [
210
- { name: "ghcr_push", mode: "write", custom: true },
211
- { name: "ghcr_push_status", mode: "read", custom: true },
212
- { name: "ghcr_tags", mode: "read", custom: true }
213
- ];
214
- const ghcr = { owners: { cldmv: { credential: "cred:cldmv-packages" }, other: { credential: "cred:other-packages" } } };
215
- const config = { ...makeConfig(ghcrTools), ghcr };
216
- // Exposed as soon as any one GHCR owner's credential is stored.
217
- secrets.has.mockImplementation((ref: string) => ref === "cred:other-packages");
218
- const catalog = await buildCatalog(config, secrets as any, upstream as any);
219
- expect(catalog.map((t) => t.name)).toEqual(["ghcr_push", "ghcr_push_status", "ghcr_tags"]);
220
- expect(catalog[0]?.inputSchema).toMatchObject({ required: ["owner", "image", "tag", "archive"], additionalProperties: false });
221
- expect(catalog[0]?.description).toMatch(/background job/);
222
- });
223
-
224
- it("does not expose the GHCR tools without config.ghcr, or while no GHCR credential is stored", async () => {
225
- upstream.listToolsFor.mockResolvedValue([]);
226
- const rules = [{ name: "ghcr_push", mode: "write", custom: true }];
227
- expect(await buildCatalog(makeConfig(rules), secrets as any, upstream as any)).toEqual([]);
228
- expect(errSpy).toHaveBeenCalledWith(expect.stringMatching(/"ghcr_push" is not exposed: GHCR pushes are not configured/));
229
- secrets.has.mockReturnValue(false);
230
- const config = { ...makeConfig(rules), ghcr: { owners: { cldmv: { credential: "cred:cldmv-packages" } } } };
231
- expect(await buildCatalog(config, secrets as any, upstream as any)).toEqual([]);
232
- expect(errSpy).toHaveBeenCalledWith(expect.stringMatching(/no GHCR credential is stored \("cldmv-packages"; re-run the installer/));
233
- });
234
- });
@@ -1,325 +0,0 @@
1
- import { describe, it, expect } from "vitest";
2
- import { mergeConfig, layerConfig, overlayFromFlags, readOperatorConfig, buildConfig, secretsBlock, DEFAULT_GHCR_DROP_DIR } from "../deploy/lib/config-merge.mjs";
3
-
4
- const example = {
5
- listen: "unix:/run/gh-broker/gh-broker.sock",
6
- upstream: "https://api.githubcopilot.com/mcp/x/all",
7
- tools: [
8
- { name: "get_me", mode: "read" },
9
- { name: "list_issues", mode: "read" }
10
- ],
11
- owners: { acme: { read: "cred:acme-read", write: "cred:acme-write" } },
12
- defaultOwner: "acme"
13
- };
14
-
15
- describe("mergeConfig", () => {
16
- it("uses the example as-is on first install (no current)", () => {
17
- expect(mergeConfig(example, null)).toEqual(example);
18
- });
19
-
20
- it("takes tools from the example so allowlist removals/tightenings apply", () => {
21
- const current = { ...example, tools: [{ name: "get_me", mode: "read" }, { name: "removed_tool", mode: "write" }] };
22
- const merged = mergeConfig(example, current);
23
- expect(merged.tools).toEqual(example.tools);
24
- expect(merged.tools.find((t: { name: string }) => t.name === "removed_tool")).toBeUndefined();
25
- });
26
-
27
- it("preserves current owner data and adds example-only owners on upgrade", () => {
28
- const current = { ...example, owners: { acme: { read: "cred:acme-read", write: "cred:acme-write", installationId: 123 } } };
29
- const example2 = {
30
- ...example,
31
- owners: {
32
- acme: { read: "cred:acme-read", write: "cred:acme-write" },
33
- beta: { read: "cred:beta-read", write: "cred:beta-write" }
34
- }
35
- };
36
- const merged = mergeConfig(example2, current);
37
- expect(merged.owners.acme.installationId).toBe(123); // current wins
38
- expect(merged.owners.beta).toBeDefined(); // new example owner appears
39
- });
40
-
41
- it("lets current win for listen/upstream/defaultOwner/app/secrets", () => {
42
- const current = { ...example, listen: "unix:/tmp/custom.sock", app: { appId: 1, privateKey: "cred:k" } };
43
- const merged = mergeConfig(example, current);
44
- expect(merged.listen).toBe("unix:/tmp/custom.sock");
45
- expect(merged.app).toEqual({ appId: 1, privateKey: "cred:k" });
46
- });
47
-
48
- it("keeps the current npm block on upgrade (the example has none)", () => {
49
- const npm = { token: "cred:npm-token", scopes: ["@cldmv"] };
50
- expect(mergeConfig(example, { ...example, npm }).npm).toEqual(npm);
51
- expect(mergeConfig(example, { ...example }).npm).toBeUndefined();
52
- });
53
-
54
- it("keeps the example's scalars when current omits them, and tolerates configs with no owners", () => {
55
- const merged = mergeConfig({ tools: [], listen: "unix:/a" }, { tools: [] });
56
- expect(merged.listen).toBe("unix:/a");
57
- expect(merged.owners).toEqual({});
58
- });
59
-
60
- it("does not mutate its inputs", () => {
61
- const ex = structuredClone(example);
62
- mergeConfig(ex, { ...example, listen: "unix:/z" });
63
- expect(ex).toEqual(example);
64
- });
65
-
66
- it("rejects an array as example or current (typeof reports 'object')", () => {
67
- expect(() => mergeConfig([], null)).toThrow(/example config must be an object/);
68
- expect(() => mergeConfig(example, [])).toThrow(/current config must be an object/);
69
- });
70
-
71
- describe("ghcr", () => {
72
- const ghcr = { dropDir: "/srv/gh-broker/ghcr-drop", owners: { cldmv: { username: "u", credential: "cred:p", images: ["hoot"] } } };
73
-
74
- it("keeps the deployed ghcr block (operator data); the example ships none", () => {
75
- expect(mergeConfig(example, { ...example, ghcr }).ghcr).toEqual(ghcr);
76
- expect(mergeConfig(example, { ...example }).ghcr).toBeUndefined();
77
- });
78
-
79
- it("lets the deployed ghcr win over one the example carries", () => {
80
- const edited = { ...ghcr, dropDir: "/srv/other/drop" };
81
- expect(mergeConfig({ ...example, ghcr }, { ...example, ghcr: edited }).ghcr).toEqual(edited);
82
- expect(mergeConfig({ ...example, ghcr }, example).ghcr).toEqual(ghcr);
83
- });
84
- });
85
- });
86
-
87
- const shipped = {
88
- listen: "unix:/run/gh-broker/gh-broker.sock",
89
- upstream: "https://api.githubcopilot.com/mcp/x/all",
90
- tools: [{ name: "get_me", mode: "read" }]
91
- };
92
- const noOpts = {};
93
- const ask = async () => {
94
- throw new Error("unexpected prompt");
95
- };
96
-
97
- describe("layerConfig", () => {
98
- it("replaces scalars and merges owners per owner and per field", () => {
99
- const base = { listen: "unix:/a", upstream: "https://a", owners: { acme: { read: "cred:acme-read", write: "cred:acme-write", installationId: 7 } } };
100
- const out = layerConfig(base, { listen: "unix:/b", defaultOwner: "acme", owners: { acme: { write: "cred:w2" }, beta: { read: "cred:r", write: "cred:w" } } });
101
- expect(out.listen).toBe("unix:/b");
102
- expect(out.upstream).toBe("https://a");
103
- expect(out.defaultOwner).toBe("acme");
104
- expect(out.owners.acme).toEqual({ read: "cred:acme-read", write: "cred:w2", installationId: 7 });
105
- expect(out.owners.beta).toEqual({ read: "cred:r", write: "cred:w" });
106
- expect(base.owners.acme.write).toBe("cred:acme-write"); // input untouched
107
- });
108
-
109
- it("merges app/secrets/npm per field and creates them when absent", () => {
110
- const out = layerConfig({ npm: { token: "cred:t", scopes: ["@a"] } }, { npm: { registry: "https://r" }, secrets: { path: "/p" }, app: { appId: 1, privateKey: "cred:k" } });
111
- expect(out.npm).toEqual({ token: "cred:t", scopes: ["@a"], registry: "https://r" });
112
- expect(out.secrets).toEqual({ path: "/p" });
113
- expect(out.app).toEqual({ appId: 1, privateKey: "cred:k" });
114
- });
115
-
116
- it("merges ghcr scalars and ghcr owners per owner", () => {
117
- const base = { owners: {}, ghcr: { dropDir: "/srv/a/b", owners: { acme: { username: "u", credential: "cred:acme-packages", images: ["x"] } } } };
118
- const out = layerConfig(base, { ghcr: { protectReleaseTags: false, owners: { acme: { images: ["y"] }, beta: { username: "b", credential: "cred:beta-packages", images: ["z"] } } } });
119
- expect(out.ghcr.dropDir).toBe("/srv/a/b");
120
- expect(out.ghcr.protectReleaseTags).toBe(false);
121
- expect(out.ghcr.owners.acme).toEqual({ username: "u", credential: "cred:acme-packages", images: ["y"] });
122
- expect(Object.keys(out.ghcr.owners)).toEqual(["acme", "beta"]);
123
- expect(layerConfig({}, { ghcr: { dropDir: "/srv/a/b" } }).ghcr).toEqual({ dropDir: "/srv/a/b" });
124
- });
125
-
126
- it("leaves a base without overlay data alone", () => {
127
- expect(layerConfig({ listen: "unix:/a" }, {})).toEqual({ listen: "unix:/a" });
128
- });
129
- });
130
-
131
- describe("overlayFromFlags", () => {
132
- it("is empty with no flags", () => {
133
- expect(overlayFromFlags(noOpts)).toEqual({});
134
- });
135
-
136
- it("maps every flag to its config field", () => {
137
- const o = overlayFromFlags({
138
- listen: "unix:/x.sock",
139
- upstream: "https://u",
140
- defaultOwner: "acme",
141
- owners: ["acme", "beta"],
142
- installationIds: { acme: 42, gamma: 9 },
143
- appId: 5,
144
- appPrivateKey: "my-key",
145
- secretsPath: "/s.json",
146
- ghcrOwner: "acme",
147
- ghcrUsername: "me",
148
- ghcrImages: ["app", "app-*"],
149
- ghcrDropDir: "/srv/x/drop",
150
- ghcrMaxArchiveBytes: 1024,
151
- protectReleaseTags: false
152
- });
153
- expect(o).toEqual({
154
- listen: "unix:/x.sock",
155
- upstream: "https://u",
156
- defaultOwner: "acme",
157
- owners: {
158
- acme: { read: "cred:acme-read", write: "cred:acme-write", installationId: 42 },
159
- beta: { read: "cred:beta-read", write: "cred:beta-write" },
160
- gamma: { installationId: 9 }
161
- },
162
- app: { appId: 5, privateKey: "cred:my-key" },
163
- secrets: { path: "/s.json" },
164
- ghcr: {
165
- dropDir: "/srv/x/drop",
166
- maxArchiveBytes: 1024,
167
- protectReleaseTags: false,
168
- owners: { acme: { username: "me", credential: "cred:acme-packages", images: ["app", "app-*"] } }
169
- }
170
- });
171
- });
172
-
173
- it("derives the App key name when only --app-id is given and handles --protect-release-tags", () => {
174
- expect(overlayFromFlags({ appId: 5 }).app).toEqual({ appId: 5, privateKey: "cred:gh-app-key" });
175
- expect(overlayFromFlags({ protectReleaseTags: true }).ghcr).toEqual({ protectReleaseTags: true });
176
- });
177
- });
178
-
179
- describe("readOperatorConfig", () => {
180
- const read = (text: string) => () => text;
181
-
182
- it("returns a valid operator config", () => {
183
- const cfg = { listen: "unix:/a", owners: { acme: { read: "cred:acme-read", write: "cred:acme-write" } } };
184
- expect(readOperatorConfig("/c.json", read(JSON.stringify(cfg)))).toEqual(cfg);
185
- });
186
-
187
- it("reports an unreadable file and bad JSON", () => {
188
- expect(() =>
189
- readOperatorConfig("/missing.json", () => {
190
- throw new Error("ENOENT: no such file");
191
- })
192
- ).toThrow(/--config: cannot read\/parse \/missing.json: ENOENT/);
193
- expect(() => readOperatorConfig("/c.json", read("{ nope"))).toThrow(/--config: cannot read\/parse/);
194
- });
195
-
196
- it("refuses a non-object root, tools, and unknown keys", () => {
197
- expect(() => readOperatorConfig("/c.json", read("[]"))).toThrow(/must be a JSON object/);
198
- expect(() => readOperatorConfig("/c.json", read("null"))).toThrow(/must be a JSON object/);
199
- expect(() => readOperatorConfig("/c.json", read('{"tools":[]}'))).toThrow(/`tools` is the shipped allowlist/);
200
- expect(() => readOperatorConfig("/c.json", read('{"lisen":"x","owners":{}}'))).toThrow(/unknown key\(s\) lisen/);
201
- });
202
-
203
- it.each([
204
- ["a classic PAT", { owners: { acme: { read: "ghp_" + "a".repeat(36), write: "cred:w" } } }, "owners.acme.read"],
205
- ["a fine-grained PAT", { ghcr: { owners: { a: { images: ["github_pat_" + "A1".repeat(20)] } } } }, "ghcr.owners.a.images[0]"],
206
- ["an npm token", { npm: { token: "npm_" + "b".repeat(36), scopes: [] } }, "npm.token"],
207
- ["a token as a list entry", { npm: { scopes: ["ok", "ghs_" + "c".repeat(30)] } }, "npm.scopes[1]"]
208
- ])("refuses %s", (_name, cfg, where) => {
209
- expect(() => readOperatorConfig("/c.json", read(JSON.stringify(cfg)))).toThrow(new RegExp(`--config: ${where.replace(/[[\]]/g, "\\$&")} looks like a token`));
210
- });
211
-
212
- it("accepts cred refs, numbers and booleans alongside strings", () => {
213
- const cfg = { app: { appId: 1, privateKey: "cred:gh-app-key" }, ghcr: { protectReleaseTags: true } };
214
- expect(readOperatorConfig("/c.json", read(JSON.stringify(cfg)))).toEqual(cfg);
215
- });
216
- });
217
-
218
- describe("buildConfig", () => {
219
- const acme = { read: "cred:acme-read", write: "cred:acme-write" };
220
- const build = (o: Record<string, unknown>) => buildConfig({ example: shipped, current: null, file: undefined, opts: noOpts, nonInteractive: true, ask, ...o } as never);
221
-
222
- it("a first install takes owners from flags, defaults defaultOwner and sets ghcr.dropDir", async () => {
223
- const { config, notes } = await build({
224
- opts: { owners: ["acme"], ghcrOwner: "acme", ghcrUsername: "me", ghcrImages: ["app"] }
225
- });
226
- expect(config.tools).toEqual(shipped.tools);
227
- expect(config.owners).toEqual({ acme });
228
- expect(config.defaultOwner).toBe("acme");
229
- expect(config.ghcr).toEqual({ dropDir: DEFAULT_GHCR_DROP_DIR, owners: { acme: { username: "me", credential: "cred:acme-packages", images: ["app"] } } });
230
- expect(notes).toHaveLength(2);
231
- });
232
-
233
- it("does not pick a defaultOwner with several owners, or when one is set", async () => {
234
- expect((await build({ opts: { owners: ["acme", "beta"] } })).config.defaultOwner).toBeUndefined();
235
- expect((await build({ opts: { owners: ["acme", "beta"], defaultOwner: "beta" } })).config.defaultOwner).toBe("beta");
236
- });
237
-
238
- it("layers example < deployed < --config < flags", async () => {
239
- const current = { ...shipped, listen: "unix:/deployed", upstream: "https://deployed", owners: { acme }, defaultOwner: "acme" };
240
- const file = { listen: "unix:/file", upstream: "https://file" };
241
- const flags = { listen: "unix:/flag" };
242
- const { config } = await build({ current, file, opts: flags });
243
- expect(config.listen).toBe("unix:/flag"); // flag beats file
244
- expect(config.upstream).toBe("https://file"); // file beats deployed
245
- const deployedOnly = await build({ current });
246
- expect(deployedOnly.config.listen).toBe("unix:/deployed"); // deployed beats example
247
- expect(deployedOnly.config.upstream).toBe("https://deployed");
248
- expect((await build({ current: { tools: [], owners: { acme } } })).config.listen).toBe(shipped.listen); // example fills gaps
249
- });
250
-
251
- it("keeps the deployed owners, ghcr and tools on an upgrade with no flags", async () => {
252
- const ghcr = { dropDir: "/srv/old/drop", protectReleaseTags: false, maxArchiveBytes: 5, owners: { acme: { username: "me", credential: "cred:acme-packages", images: ["x"] } } };
253
- const current = { ...shipped, tools: [{ name: "gone", mode: "write" }], owners: { acme: { ...acme, installationId: 3 } }, ghcr };
254
- const { config, notes } = await build({ current });
255
- expect(config.owners).toEqual({ acme: { ...acme, installationId: 3 } });
256
- expect(config.ghcr).toEqual(ghcr);
257
- expect(config.tools).toEqual(shipped.tools); // allowlist from the example
258
- expect(config.defaultOwner).toBeUndefined();
259
- expect(notes).toEqual([]);
260
- });
261
-
262
- it("adds an owner on top of a deployed one without losing it", async () => {
263
- const { config } = await build({ current: { ...shipped, owners: { acme: { ...acme, installationId: 3 } } }, opts: { owners: ["acme", "beta"] } });
264
- expect(config.owners.acme.installationId).toBe(3);
265
- expect(config.owners.beta).toEqual({ read: "cred:beta-read", write: "cred:beta-write" });
266
- });
267
-
268
- it("takes owners, ghcr and defaultOwner from a --config file", async () => {
269
- const file = { owners: { acme }, defaultOwner: "acme", ghcr: { dropDir: "/srv/f/drop", owners: { acme: { username: "me", credential: "cred:acme-packages", images: ["a"] } } } };
270
- const { config } = await build({ file });
271
- expect(config.owners).toEqual({ acme });
272
- expect(config.ghcr.dropDir).toBe("/srv/f/drop");
273
- });
274
-
275
- it("fails non-interactively when no owner exists anywhere, naming --owner and --config", async () => {
276
- await expect(build({})).rejects.toThrow(/--owner <org>.*--config/);
277
- await expect(build({ current: { ...shipped } })).rejects.toThrow(/no GitHub owner configured/);
278
- });
279
-
280
- it("asks for the owner on an interactive first install", async () => {
281
- const asked: string[] = [];
282
- const { config } = await build({
283
- nonInteractive: false,
284
- ask: async (q: string) => {
285
- asked.push(q);
286
- return " Acme-Corp \n";
287
- }
288
- });
289
- expect(asked).toHaveLength(1);
290
- expect(config.owners).toEqual({ "acme-corp": { read: "cred:acme-corp-read", write: "cred:acme-corp-write" } });
291
- expect(config.defaultOwner).toBe("acme-corp");
292
- });
293
-
294
- it("rejects an invalid owner typed at the prompt", async () => {
295
- await expect(build({ nonInteractive: false, ask: async () => "not an org!" })).rejects.toThrow(/not a valid GitHub owner name/);
296
- await expect(build({ nonInteractive: false, ask: async () => "" })).rejects.toThrow(/not a valid GitHub owner name/);
297
- });
298
-
299
- it("does not prompt when owners are already configured", async () => {
300
- const { config } = await build({ nonInteractive: false, current: { ...shipped, owners: { acme } } });
301
- expect(Object.keys(config.owners)).toEqual(["acme"]);
302
- });
303
-
304
- it("refuses an owner that has no credentials (e.g. --installation-id for an unknown owner)", async () => {
305
- await expect(build({ opts: { owners: ["acme"], installationIds: { gamma: 9 } } })).rejects.toThrow(/owner "gamma" has no credentials.*--owner gamma/);
306
- });
307
-
308
- it("keeps an explicit ghcr.dropDir instead of the default", async () => {
309
- const { config, notes } = await build({ opts: { owners: ["acme"], ghcrOwner: "acme", ghcrUsername: "me", ghcrImages: ["a"], ghcrDropDir: "/srv/mine/drop" } });
310
- expect(config.ghcr.dropDir).toBe("/srv/mine/drop");
311
- expect(notes.some((n: string) => n.includes("dropDir"))).toBe(false);
312
- });
313
- });
314
-
315
- describe("secretsBlock", () => {
316
- it("returns just the provider, keeping a path only for the file provider", () => {
317
- expect(secretsBlock("systemd-creds", undefined)).toEqual({ provider: "systemd-creds" });
318
- expect(secretsBlock("file", { path: "/s.json" })).toEqual({ provider: "file", path: "/s.json" });
319
- expect(secretsBlock("file", {})).toEqual({ provider: "file" });
320
- });
321
-
322
- it("refuses a path with the systemd-creds provider", () => {
323
- expect(() => secretsBlock("systemd-creds", { path: "/s.json" })).toThrow(/only used by the "file" provider/);
324
- });
325
- });