@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
package/src/catalog.ts DELETED
@@ -1,182 +0,0 @@
1
- /**
2
- *
3
- * @Project: @cldmv/gh-broker
4
- * @Filename: /src/catalog.ts
5
- * @Date: 2026-07-07T00:24:51-07:00 (1783409091)
6
- * @Author: Nate Corcoran <CLDMV>
7
- * @Email: <Shinrai@users.noreply.github.com>
8
- * -----
9
- * @Last modified by: Nate Corcoran <CLDMV> (Shinrai@users.noreply.github.com)
10
- * @Last modified time: 2026-10-31T12:12:11-07:00 (1793473931)
11
- * -----
12
- * @Copyright: Copyright (c) 2013-2026 Catalyzed Motivation Inc. All rights reserved.
13
- *
14
- */
15
-
16
- import type { Tool } from "@modelcontextprotocol/sdk/types.js";
17
- import type { Config, SecretStore } from "./types.ts";
18
- import type { UpstreamPool } from "./upstream.ts";
19
- // DEFERRED: link_issue (addClosingIssueReference) is App-token-only and not wired in — see docs.
20
- // import { LINK_ISSUE_SCHEMA } from "./verbs/link_issue.ts";
21
- import { RESOLVE_REVIEW_THREAD_SCHEMA, REPLY_REVIEW_THREAD_SCHEMA } from "./verbs/review_thread.ts";
22
- import { REPORT_UNLABELED_SCHEMA } from "./verbs/report_unlabeled.ts";
23
- import { REPORT_SECURITY_ALERTS_SCHEMA } from "./verbs/report_security_alerts.ts";
24
- import { REPORT_RELEASE_PRS_SCHEMA } from "./verbs/report_release_prs.ts";
25
- import { ISSUE_LABELS_SCHEMA } from "./verbs/issue_labels.ts";
26
- import { REPORT_CI_STATUS_SCHEMA } from "./verbs/report_ci_status.ts";
27
- import { NPM_SCAFFOLD_SCHEMA } from "./verbs/npm_scaffold.ts";
28
- import { unavailableReason } from "./availability.ts";
29
- import { GHCR_CATALOG } from "./verbs/ghcr_push.ts";
30
-
31
- /**
32
- * Build the advertised tool list ONCE at startup. Proxied tools carry their real
33
- * schemas fetched from the hosted MCP (tool schemas are owner-independent, so any
34
- * valid read token works — we use the first configured owner's read PAT). Custom
35
- * verbs carry local schemas. Only allowlisted tools appear; nothing else is
36
- * advertised (and, per the router, nothing else is executable either).
37
- *
38
- * If the upstream fetch fails (network/token), we advertise minimal schemas rather
39
- * than fail startup — the daemon still serves and calls still route correctly.
40
- */
41
- export async function buildCatalog(config: Config, secrets: SecretStore, upstream: UpstreamPool): Promise<Tool[]> {
42
- const upstreamByName = new Map<string, Tool>();
43
- // Whether the upstream tool list was actually fetched. If it wasn't (network/token), we must NOT drop
44
- // allowlisted tools — we fall back to minimal schemas so the daemon still serves + routes. But if the
45
- // fetch DID succeed, a tool missing from it genuinely isn't served by this upstream (wrong toolset /
46
- // renamed), so advertising a minimal-schema stub only defers the failure to call time (the confusing
47
- // "could not determine repo owner" → "unknown tool" pair) — better to skip it and say why at startup.
48
- let upstreamFetched = false;
49
- const firstOwner = Object.values(config.owners)[0];
50
- if (firstOwner) {
51
- try {
52
- const token = secrets.resolve(firstOwner.read);
53
- // Never let a slow/unreachable upstream hang startup — fall back to minimal schemas.
54
- const timeoutMs = Number(process.env.GH_BROKER_CATALOG_TIMEOUT_MS ?? 10000);
55
- const tools = await Promise.race([
56
- upstream.listToolsFor(token),
57
- new Promise<never>((_, rej) => setTimeout(() => rej(new Error(`timed out after ${timeoutMs}ms`)), timeoutMs).unref())
58
- ]);
59
- for (const t of tools) upstreamByName.set(t.name, t);
60
- upstreamFetched = true;
61
- } catch (err) {
62
- console.error(`catalog: could not fetch upstream tool schemas (${(err as Error).message}) — advertising minimal schemas`);
63
- }
64
- }
65
-
66
- const tools: Tool[] = [];
67
- for (const rule of config.tools) {
68
- const unavailable = unavailableReason(rule, config, secrets);
69
- if (unavailable) {
70
- console.error(`catalog: "${rule.name}" is not exposed: ${unavailable}`);
71
- continue;
72
- }
73
- if (rule.custom) {
74
- // DEFERRED: link_issue (App-token-only addClosingIssueReference) is not wired in — see docs.
75
- // Intentionally not advertised even if an allowlist entry is added; re-enable by restoring
76
- // this branch + the import above + the router dispatch case, then config.app + installationId.
77
- if (rule.name === "resolve_review_thread") {
78
- tools.push({
79
- name: "resolve_review_thread",
80
- description:
81
- "Resolve (or unresolve) a pull-request review thread — e.g. mark a Copilot review comment resolved once addressed. Takes the thread node id from pull_request_read get_review_comments. Runs on the write PAT (Pull requests: write).",
82
- inputSchema: RESOLVE_REVIEW_THREAD_SCHEMA
83
- });
84
- } else if (rule.name === "reply_review_thread") {
85
- tools.push({
86
- name: "reply_review_thread",
87
- description:
88
- "Post a reply inside a pull-request review thread — e.g. respond to a Copilot review comment. Takes the thread node id from pull_request_read get_review_comments. Runs on the write PAT (Pull requests: write).",
89
- inputSchema: REPLY_REVIEW_THREAD_SCHEMA
90
- });
91
- } else if (rule.name === "issue_labels") {
92
- tools.push({
93
- name: "issue_labels",
94
- description:
95
- 'Edit the labels on one issue or PR without touching any other label on it. add / remove take one label or several (combinable in one call); swap { prefix, to } removes every label starting with prefix and adds to (e.g. prefix "status: ", to "status: verified"); set replaces the whole set and only as an explicit choice. Labels already present (add) or absent (remove) are no-ops. A label name to add that does not exist in the repo refuses the call and changes nothing, unless create_missing is true. Returns the resulting label set plus what was added, removed and created. Runs on the write PAT (Issues: write).',
96
- inputSchema: ISSUE_LABELS_SCHEMA
97
- });
98
- } else if (rule.name === "npm_scaffold") {
99
- tools.push({
100
- name: "npm_scaffold",
101
- description:
102
- "Create a new npm package and register its GitHub Actions trusted publisher, so the repo's publish workflow can release it through OIDC from then on. Publishes a broker-built placeholder (0.0.0-stage, no code) and never publishes a version of an existing package. Every npm write needs its own one-time password from the user, so it takes two calls: the first publishes the placeholder and returns status needs_otp; ask the user for the next code and call again to add the trusted publisher. Ask for each code right before the call. A refused or expired code also returns needs_otp with the remaining steps. Checks first that the repo's package.json names the package and that the workflow file exists. Uses the broker's npm token (no 2FA bypass) and the read PAT.",
103
- inputSchema: NPM_SCAFFOLD_SCHEMA
104
- });
105
- } else if (rule.name === "report_unlabeled") {
106
- tools.push({
107
- name: "report_unlabeled",
108
- description:
109
- "Report issues and/or PRs with no labels, for one repo or every non-archived repo an owner has. Bounded by maxRepos/perRepoLimit/maxApiCalls; returns truncated:true (with a reason) instead of failing outright if a cap or GitHub's rate limit is hit mid-scan. Runs on the read PAT.",
110
- inputSchema: REPORT_UNLABELED_SCHEMA
111
- });
112
- } else if (rule.name === "report_security_alerts") {
113
- tools.push({
114
- name: "report_security_alerts",
115
- description:
116
- "Report every open Dependabot, code-scanning (CodeQL) and secret-scanning alert, for one repo or across every repo an owner has. Reads the org-level endpoints in a handful of calls and falls back to a per-repo sweep for any alert type the token can't read org-wide. Alert state tracks the default branch. Secret values are never returned. Bounded by maxRepos/alertLimit/maxApiCalls; returns truncated:true (with a reason) instead of failing outright if a cap or GitHub's rate limit is hit. Runs on the read PAT.",
117
- inputSchema: REPORT_SECURITY_ALERTS_SCHEMA
118
- });
119
- } else if (rule.name === "report_release_prs") {
120
- tools.push({
121
- name: "report_release_prs",
122
- description:
123
- "Report every open release PR (label `release`: next→master and hotfixes→master) for one repo or across every repo an owner has, and whether each one's per-version changelog file (docs/changelog[s]/v<major>/vX.Y.Z.md) is already on the release branch. Status per PR: ready, needs-changelog, or unknown-version (title has no `release: vX.Y.Z`). Use during mass updates to find which repos need a changelog. One search call plus two calls per release PR found; returns truncated:true with a reason if a cap or GitHub's rate limit is hit. Runs on the read PAT.",
124
- inputSchema: REPORT_RELEASE_PRS_SCHEMA
125
- });
126
- } else if (rule.name === "report_ci_status") {
127
- tools.push({
128
- name: "report_ci_status",
129
- description:
130
- "Report CI health for a PR, or every open PR in a repo, at a chosen verbosity: 0 counts only, 1 adds which run/job failed, 2 adds which step failed, 3 adds a tail of the failing job's log. Detail beyond tier 0 is only fetched for runs that are actually failing. Runs on the read PAT.",
131
- inputSchema: REPORT_CI_STATUS_SCHEMA
132
- });
133
- } else if (GHCR_CATALOG[rule.name]) {
134
- tools.push({ name: rule.name, ...GHCR_CATALOG[rule.name]! });
135
- }
136
- continue;
137
- }
138
- const up = upstreamByName.get(rule.name);
139
- if (!up && upstreamFetched) {
140
- // Reachable upstream that doesn't expose this tool → it would 32602 at call time. Skip + warn.
141
- console.error(`catalog: allowlisted tool "${rule.name}" is NOT exposed by the upstream (${config.upstream}) — skipping it. Check the tool name or the upstream toolset (e.g. use /mcp/x/all for non-default toolsets like labels).`);
142
- continue;
143
- }
144
- const base = up ?? { name: rule.name, description: `(${rule.mode}) proxied to the GitHub MCP`, inputSchema: { type: "object" } };
145
- if (rule.augment && rule.name === "pull_request_read") {
146
- // Layer gh-broker's own extension params onto the real upstream schema — the
147
- // forwarded call still needs pull_request_read's actual params (method,
148
- // pullNumber, ...); this ADDS to that, it doesn't replace it. See router.ts's
149
- // dispatchAugmented for why gh-broker can extend a proxied tool's contract.
150
- const baseSchema = base.inputSchema as { properties?: Record<string, unknown>; [k: string]: unknown };
151
- tools.push({
152
- ...base,
153
- description: `${base.description} gh-broker extension: with method "get", pass "ciVerbosity" (0-3) to also compute CI status for the PR's current head — 0 counts only, 1 adds which run/job failed, 2 adds which step failed, 3 adds a tail of the failing job's log; "logLines" tunes tier 3's tail length.`,
154
- inputSchema: {
155
- ...baseSchema,
156
- type: "object",
157
- properties: {
158
- ...(baseSchema.properties ?? {}),
159
- ciVerbosity: { type: "number", enum: [0, 1, 2, 3], description: 'gh-broker extension: also compute CI status at this verbosity (method "get" only)' },
160
- logLines: { type: "number", description: "gh-broker extension: tier-3 log tail length" }
161
- }
162
- }
163
- });
164
- continue;
165
- }
166
- if (rule.name === "issue_write") {
167
- // issue_write's `labels` REPLACES the issue's whole label set — the trap behind
168
- // CLDMV/gh-broker#117. Say so where the agent reads the tool, and point at the verb
169
- // that edits labels one at a time when it is allowlisted.
170
- const pointer = config.tools.some((t) => t.name === "issue_labels" && t.custom)
171
- ? " To add, remove or swap individual labels, use issue_labels instead."
172
- : " Read the current labels first and pass the full set you want to keep.";
173
- tools.push({
174
- ...base,
175
- description: `${base.description ?? ""} WARNING (gh-broker): "labels" REPLACES the issue's whole label set — every label not listed is removed.${pointer}`.trim()
176
- });
177
- continue;
178
- }
179
- tools.push(base);
180
- }
181
- return tools;
182
- }
package/src/config.ts DELETED
@@ -1,157 +0,0 @@
1
- /**
2
- *
3
- * @Project: @cldmv/gh-broker
4
- * @Filename: /src/config.ts
5
- * @Date: 2026-07-07T00:24:51-07:00 (1783409091)
6
- * @Author: Nate Corcoran <CLDMV>
7
- * @Email: <Shinrai@users.noreply.github.com>
8
- * -----
9
- * @Last modified by: Nate Corcoran <CLDMV> (Shinrai@users.noreply.github.com)
10
- * @Last modified time: 2026-10-31T11:23:22-07:00 (1793471002)
11
- * -----
12
- * @Copyright: Copyright (c) 2013-2026 Catalyzed Motivation Inc. All rights reserved.
13
- *
14
- */
15
-
16
- import { readFileSync } from "node:fs";
17
- import type { Config, Mode, ToolRule } from "./types.ts";
18
- import { parseGhcrConfig } from "./verbs/ghcr/config.ts";
19
-
20
- const MODES: readonly Mode[] = ["read", "write"];
21
-
22
- /** Load and validate the JSON config. Throws with a precise message on any problem. */
23
- export function loadConfig(path: string): Config {
24
- let raw: unknown;
25
- try {
26
- raw = JSON.parse(readFileSync(path, "utf8"));
27
- } catch (err) {
28
- throw new Error(`config: cannot read/parse ${path}: ${(err as Error).message}`);
29
- }
30
- if (typeof raw !== "object" || raw === null) throw new Error("config: root must be an object");
31
- const c = raw as Record<string, unknown>;
32
-
33
- if (typeof c.listen !== "string" || !c.listen.startsWith("unix:")) {
34
- throw new Error('config.listen: must be a "unix:<path>" string');
35
- }
36
- if (typeof c.upstream !== "string" || !/^https?:\/\//.test(c.upstream)) {
37
- throw new Error("config.upstream: must be an http(s) URL");
38
- }
39
- if (!Array.isArray(c.tools) || c.tools.length === 0) {
40
- throw new Error("config.tools: must be a non-empty array");
41
- }
42
-
43
- const seen = new Set<string>();
44
- const tools: ToolRule[] = c.tools.map((t, i) => {
45
- const o = t as Record<string, unknown>;
46
- if (typeof o.name !== "string" || !o.name) throw new Error(`config.tools[${i}].name: required`);
47
- if (!MODES.includes(o.mode as Mode)) throw new Error(`config.tools[${i}].mode: must be read|write`);
48
- if (seen.has(o.name)) throw new Error(`config.tools: duplicate tool "${o.name}"`);
49
- seen.add(o.name);
50
- const custom = o.custom === true;
51
- const augment = o.augment === true;
52
- if (custom && augment) throw new Error(`config.tools[${i}] ("${o.name}"): custom and augment are mutually exclusive`);
53
- return { name: o.name, mode: o.mode as Mode, custom, augment };
54
- });
55
-
56
- if (typeof c.owners !== "object" || c.owners === null) throw new Error("config.owners: must be an object");
57
- const owners: Config["owners"] = {};
58
- for (const [owner, v] of Object.entries(c.owners as Record<string, unknown>)) {
59
- const o = v as Record<string, unknown>;
60
- if (typeof o.read !== "string" || typeof o.write !== "string") {
61
- throw new Error(`config.owners.${owner}: needs "read" and "write" credential refs`);
62
- }
63
- if (o.installationId !== undefined && typeof o.installationId !== "number") {
64
- throw new Error(`config.owners.${owner}.installationId: must be a number`);
65
- }
66
- owners[owner.toLowerCase()] = {
67
- read: o.read,
68
- write: o.write,
69
- ...(typeof o.installationId === "number" ? { installationId: o.installationId } : {})
70
- };
71
- }
72
- if (Object.keys(owners).length === 0) throw new Error("config.owners: at least one owner required");
73
-
74
- let defaultOwner: string | undefined;
75
- if (c.defaultOwner !== undefined) {
76
- if (typeof c.defaultOwner !== "string" || !c.defaultOwner) {
77
- throw new Error("config.defaultOwner: must be a non-empty string naming a configured owner");
78
- }
79
- defaultOwner = c.defaultOwner.toLowerCase();
80
- if (!owners[defaultOwner]) {
81
- throw new Error(`config.defaultOwner "${c.defaultOwner}" is not a key of config.owners`);
82
- }
83
- }
84
-
85
- let app: Config["app"];
86
- if (c.app !== undefined) {
87
- const a = c.app as Record<string, unknown>;
88
- if (typeof a.appId !== "number") throw new Error("config.app.appId: must be a number");
89
- if (typeof a.privateKey !== "string") throw new Error('config.app.privateKey: must be a "cred:<name>" ref');
90
- app = { appId: a.appId, privateKey: a.privateKey };
91
- }
92
-
93
- let secrets: Config["secrets"];
94
- if (c.secrets !== undefined) {
95
- if (typeof c.secrets !== "object" || c.secrets === null || Array.isArray(c.secrets)) throw new Error("config.secrets: must be an object");
96
- const s = c.secrets as Record<string, unknown>;
97
- if (s.provider !== undefined && s.provider !== "file" && s.provider !== "systemd-creds") {
98
- throw new Error('config.secrets.provider: must be "systemd-creds" or "file"');
99
- }
100
- // Reject an empty path: resolveSecretsPath treats a falsy explicit path as
101
- // "not provided" and falls back to env/default, so "" would be silently ignored.
102
- if (s.path !== undefined && (typeof s.path !== "string" || s.path.length === 0)) {
103
- throw new Error("config.secrets.path: must be a non-empty string");
104
- }
105
- // path only means anything to the file provider; the systemd-creds provider
106
- // (the default) reads $CREDENTIALS_DIRECTORY and would silently ignore it.
107
- // Reject the combination so an operator can't think a file path is in effect.
108
- if (s.path !== undefined && (s.provider ?? "systemd-creds") !== "file") {
109
- throw new Error('config.secrets.path is only used by the "file" provider — set provider to "file" or remove path');
110
- }
111
- secrets = {
112
- ...(s.provider === "file" || s.provider === "systemd-creds" ? { provider: s.provider } : {}),
113
- ...(typeof s.path === "string" ? { path: s.path } : {})
114
- };
115
- }
116
-
117
- let npm: Config["npm"];
118
- if (c.npm !== undefined) {
119
- if (typeof c.npm !== "object" || c.npm === null || Array.isArray(c.npm)) throw new Error("config.npm: must be an object");
120
- const n = c.npm as Record<string, unknown>;
121
- if (typeof n.token !== "string" || !n.token.startsWith("cred:")) throw new Error('config.npm.token: must be a "cred:<name>" ref');
122
- if (!Array.isArray(n.scopes) || n.scopes.length === 0) throw new Error('config.npm.scopes: must be a non-empty array of scopes, e.g. ["@cldmv"]');
123
- const scopes = n.scopes.map((s, i) => {
124
- if (typeof s !== "string" || !/^@[a-z0-9][a-z0-9._~-]*$/.test(s)) throw new Error(`config.npm.scopes[${i}]: must be a lowercase npm scope like "@cldmv"`);
125
- return s;
126
- });
127
- if (n.registry !== undefined && (typeof n.registry !== "string" || !/^https:\/\//.test(n.registry))) {
128
- throw new Error("config.npm.registry: must be an https URL");
129
- }
130
- if (n.tokenOrgs !== undefined && (!Array.isArray(n.tokenOrgs) || !n.tokenOrgs.every((o) => typeof o === "string" && /^[a-z0-9][a-z0-9._~-]*$/.test(o)))) {
131
- throw new Error('config.npm.tokenOrgs: must be an array of lowercase npm org names, e.g. ["cldmv"]');
132
- }
133
- npm = {
134
- token: n.token,
135
- scopes,
136
- ...(typeof n.registry === "string" ? { registry: n.registry } : {}),
137
- ...(Array.isArray(n.tokenOrgs) ? { tokenOrgs: n.tokenOrgs as string[] } : {})
138
- };
139
- }
140
-
141
- return {
142
- listen: c.listen,
143
- upstream: c.upstream,
144
- tools,
145
- owners,
146
- ...(app ? { app } : {}),
147
- ...(secrets ? { secrets } : {}),
148
- ...(defaultOwner ? { defaultOwner } : {}),
149
- ...(npm ? { npm } : {}),
150
- ...(c.ghcr !== undefined ? { ghcr: parseGhcrConfig(c.ghcr) } : {})
151
- };
152
- }
153
-
154
- /** Build a name→rule index for O(1) allowlist checks. */
155
- export function indexTools(tools: ToolRule[]): Map<string, ToolRule> {
156
- return new Map(tools.map((t) => [t.name, t]));
157
- }
package/src/index.ts DELETED
@@ -1,107 +0,0 @@
1
- /**
2
- *
3
- * @Project: @cldmv/gh-broker
4
- * @Filename: /src/index.ts
5
- * @Date: 2026-07-07T00:24:51-07:00 (1783409091)
6
- * @Author: Nate Corcoran <CLDMV>
7
- * @Email: <Shinrai@users.noreply.github.com>
8
- * -----
9
- * @Last modified by: Nate Corcoran <CLDMV> (Shinrai@users.noreply.github.com)
10
- * @Last modified time: 2026-10-02T11:30:58-07:00 (1790965858)
11
- * -----
12
- * @Copyright: Copyright (c) 2013-2026 Catalyzed Motivation Inc. All rights reserved.
13
- *
14
- */
15
-
16
- import { loadConfig } from "./config.ts";
17
- import { loadSecretStore } from "./secrets.ts";
18
- import { UpstreamPool } from "./upstream.ts";
19
- import { Router } from "./router.ts";
20
- import { AppAuth } from "./appauth.ts";
21
- import { buildCatalog } from "./catalog.ts";
22
- import { startServer } from "./server.ts";
23
- import { resolveConfigPath } from "./paths.ts";
24
-
25
- function argConfig(): string {
26
- const i = process.argv.indexOf("--config");
27
- if (i >= 0) {
28
- // Fail fast on `--config` with no value (or a flag as the "value") rather than
29
- // silently falling back to env/default and hiding the operator's mistake.
30
- const explicit = process.argv[i + 1];
31
- if (!explicit || explicit.startsWith("-")) throw new Error("--config requires a path argument");
32
- return resolveConfigPath(explicit);
33
- }
34
- return resolveConfigPath(undefined);
35
- }
36
-
37
- async function main(): Promise<void> {
38
- const configPath = argConfig();
39
-
40
- // --print-paths: emit the resolved config + secrets paths and exit. The
41
- // installer uses this so there is one path resolver (src/paths.ts), no drift.
42
- // Honor a deployed config's secrets.path override when the config already exists
43
- // (upgrades); fall back to the default when it doesn't (first install) or is
44
- // unparseable — this must not require a valid config to run.
45
- if (process.argv.includes("--print-paths")) {
46
- const { resolveSecretsPath } = await import("./paths.ts");
47
- let secretsOverride: string | undefined;
48
- try {
49
- secretsOverride = loadConfig(configPath).secrets?.path;
50
- } catch {
51
- secretsOverride = undefined;
52
- }
53
- console.log(JSON.stringify({ config: configPath, secrets: resolveSecretsPath(secretsOverride) }));
54
- return;
55
- }
56
-
57
- const config = loadConfig(configPath);
58
-
59
- // --check: validate the config parses (and, where possible, that credentials
60
- // resolve), then exit WITHOUT starting the server. The installer runs this to
61
- // fail an install/upgrade early. The file provider validates here; the
62
- // systemd-creds provider can only resolve under systemd (needs
63
- // $CREDENTIALS_DIRECTORY), so it's checked at service start / by the smoke test.
64
- if (process.argv.includes("--check")) {
65
- const provider = config.secrets?.provider ?? "systemd-creds";
66
- // Truthy, not just "defined": the systemd-creds store treats an empty
67
- // $CREDENTIALS_DIRECTORY as unset (if (!dir)), so --check must too — otherwise
68
- // it would attempt secret resolution and fail where it should report unchecked.
69
- const secretsChecked = provider === "file" || Boolean(process.env.CREDENTIALS_DIRECTORY);
70
- if (secretsChecked) loadSecretStore(config);
71
- console.log(JSON.stringify({ event: "config-ok", config: configPath, provider, secretsChecked, owners: Object.keys(config.owners), tools: config.tools.length }));
72
- return;
73
- }
74
-
75
- const secrets = loadSecretStore(config); // eagerly validates every referenced credential
76
- const upstream = new UpstreamPool(config);
77
-
78
- // Optional GitHub App (for link_issue). Non-fatal: a misconfigured App disables
79
- // link_issue but never takes down the read/write path.
80
- let appAuth: AppAuth | undefined;
81
- if (config.app) {
82
- try {
83
- appAuth = new AppAuth(config, secrets);
84
- } catch (err) {
85
- console.error(`gh-broker: app-auth disabled (link_issue unavailable): ${(err as Error).message}`);
86
- }
87
- }
88
-
89
- const router = new Router(config, secrets, upstream, appAuth);
90
- const catalog = await buildCatalog(config, secrets, upstream);
91
- await startServer(config, router, catalog);
92
- console.log(
93
- JSON.stringify({
94
- ts: new Date().toISOString(),
95
- event: "listening",
96
- listen: config.listen,
97
- owners: Object.keys(config.owners),
98
- app: appAuth ? "enabled" : "none",
99
- tools: catalog.map((t) => t.name)
100
- })
101
- );
102
- }
103
-
104
- main().catch((err: unknown) => {
105
- console.error(`gh-broker: fatal: ${(err as Error).message}`);
106
- process.exit(1);
107
- });
package/src/paths.ts DELETED
@@ -1,60 +0,0 @@
1
- /**
2
- *
3
- * @Project: @cldmv/gh-broker
4
- * @Filename: /src/paths.ts
5
- * @Date: 2026-09-06T10:05:35-07:00 (1788714335)
6
- * @Author: Nate Corcoran <CLDMV>
7
- * @Email: <Shinrai@users.noreply.github.com>
8
- * -----
9
- * @Last modified by: Nate Corcoran <CLDMV> (Shinrai@users.noreply.github.com)
10
- * @Last modified time: 2026-10-02T11:30:58-07:00 (1790965858)
11
- * -----
12
- * @Copyright: Copyright (c) 2013-2026 Catalyzed Motivation Inc. All rights reserved.
13
- *
14
- */
15
-
16
- import { platform, env } from "node:process";
17
- import { join } from "node:path";
18
-
19
- /**
20
- * Platform-neutral path resolution for the config and secrets files.
21
- *
22
- * Nothing is hardcoded to /etc. Both files resolve through the same order:
23
- * 1. an explicit path (a `--config` flag, or GH_BROKER_CONFIG / GH_BROKER_SECRETS),
24
- * 2. the platform's system config dir (below),
25
- * 3. the packaged default under that dir.
26
- *
27
- * Only the Linux defaults are exercised today; the macOS/Windows entries are
28
- * wired so a later port is a table entry, not surgery. See docs/deploy-architecture.md.
29
- */
30
-
31
- /** The system-wide config directory for the current platform (env-overridable). */
32
- export function configDir(): string {
33
- const override = env.GH_BROKER_CONFIG_DIR;
34
- if (override) return override;
35
- switch (platform) {
36
- case "darwin":
37
- return "/Library/Application Support/gh-broker";
38
- case "win32":
39
- return join(env.ProgramData ?? "C:\\ProgramData", "gh-broker");
40
- default: // linux and other unixes
41
- return "/etc/gh-broker";
42
- }
43
- }
44
-
45
- /**
46
- * Resolve the config file path: an explicit argument (from `--config`) wins,
47
- * then GH_BROKER_CONFIG, then `<configDir>/config.json`.
48
- */
49
- export function resolveConfigPath(explicit?: string): string {
50
- if (explicit) return explicit;
51
- if (env.GH_BROKER_CONFIG) return env.GH_BROKER_CONFIG;
52
- return join(configDir(), "config.json");
53
- }
54
-
55
- /** Resolve the secrets file path: an explicit argument wins, then GH_BROKER_SECRETS, then `<configDir>/secrets.json`. */
56
- export function resolveSecretsPath(explicit?: string): string {
57
- if (explicit) return explicit;
58
- if (env.GH_BROKER_SECRETS) return env.GH_BROKER_SECRETS;
59
- return join(configDir(), "secrets.json");
60
- }