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