@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.
- package/README.md +40 -10
- package/config.example.json +17 -1
- package/deploy/README.md +3 -2
- package/deploy/install.mjs +5 -3
- package/dist/index.mjs +4261 -1642
- package/dist/index.mjs.map +4 -4
- package/package.json +12 -2
- package/docs/TOOLS.md +0 -538
- package/docs/agent-user-model.md +0 -89
- package/docs/changelog/v0/v0.2.0.md +0 -26
- package/docs/changelog/v0/v0.2.1.md +0 -23
- package/docs/changelog/v0/v0.2.2.md +0 -23
- package/docs/changelog/v0/v0.2.3.md +0 -33
- package/docs/changelog/v0/v0.2.4.md +0 -25
- package/docs/changelog/v0/v0.2.5.md +0 -29
- package/docs/changelog/v0/v0.3.0.md +0 -84
- package/docs/changelog/v0/v0.3.1.md +0 -30
- package/docs/changelog/v0/v0.3.2.md +0 -24
- package/docs/changelog/v0/v0.3.3.md +0 -28
- package/docs/changelog/v0/v0.4.0.md +0 -58
- package/docs/changelog/v0/v0.4.1.md +0 -33
- package/docs/changelog/v0/v0.4.2.md +0 -45
- package/docs/changelog/v0/v0.5.0.md +0 -39
- package/docs/changelog/v0/v0.5.1.md +0 -28
- package/docs/changelog/v0/v0.6.0.md +0 -52
- package/docs/changelog/v0/v0.6.1.md +0 -48
- package/docs/changelog/v0/v0.7.0.md +0 -71
- package/docs/changelog/v0/v0.8.0.md +0 -115
- package/docs/deploy-architecture.md +0 -84
- package/src/appauth.ts +0 -56
- package/src/audit.ts +0 -34
- package/src/availability.ts +0 -42
- package/src/catalog.ts +0 -182
- package/src/config.ts +0 -157
- package/src/index.ts +0 -107
- package/src/paths.ts +0 -60
- package/src/router.ts +0 -299
- package/src/secrets.ts +0 -167
- package/src/server.ts +0 -91
- package/src/types.ts +0 -128
- package/src/upstream.ts +0 -85
- package/src/verbs/ghcr/archive.ts +0 -314
- package/src/verbs/ghcr/config.ts +0 -142
- package/src/verbs/ghcr/jobs.ts +0 -141
- package/src/verbs/ghcr/push.ts +0 -192
- package/src/verbs/ghcr/registry.ts +0 -275
- package/src/verbs/ghcr/tar.ts +0 -203
- package/src/verbs/ghcr_push.ts +0 -264
- package/src/verbs/issue_labels.ts +0 -329
- package/src/verbs/link_issue.ts +0 -84
- package/src/verbs/npm-registry.ts +0 -168
- package/src/verbs/npm-tarball.ts +0 -76
- package/src/verbs/npm_scaffold.ts +0 -325
- package/src/verbs/release-analysis.ts +0 -212
- package/src/verbs/repo-tier.ts +0 -90
- package/src/verbs/report_ci_status.ts +0 -358
- package/src/verbs/report_release_prs.ts +0 -238
- package/src/verbs/report_security_alerts.ts +0 -442
- package/src/verbs/report_unlabeled.ts +0 -194
- package/src/verbs/rest-client.ts +0 -181
- package/src/verbs/review_thread.ts +0 -138
- package/tests/appauth.test.vitest.mts +0 -76
- package/tests/audit.test.vitest.mts +0 -48
- package/tests/catalog.test.vitest.mts +0 -234
- package/tests/config-merge.test.vitest.mts +0 -325
- package/tests/config.test.vitest.mts +0 -388
- package/tests/credentials.test.vitest.mts +0 -127
- package/tests/ghcr-archive.test.vitest.mts +0 -439
- package/tests/ghcr-config.test.vitest.mts +0 -96
- package/tests/ghcr-push.test.vitest.mts +0 -526
- package/tests/ghcr-registry.test.vitest.mts +0 -206
- package/tests/helpers/ghcr-fixtures.mts +0 -373
- package/tests/install-args.test.vitest.mts +0 -152
- package/tests/issue-labels.test.vitest.mts +0 -362
- package/tests/npm-scaffold.test.vitest.mts +0 -479
- package/tests/npm-setup.test.vitest.mts +0 -196
- package/tests/paths.test.vitest.mts +0 -57
- package/tests/prompt.test.vitest.mts +0 -128
- package/tests/release-prs.test.vitest.mts +0 -247
- package/tests/rest-client.test.vitest.mts +0 -115
- package/tests/router.test.vitest.mts +0 -505
- package/tests/run-vitest.mjs +0 -51
- package/tests/secret-store.test.vitest.mts +0 -167
- package/tests/secrets.test.vitest.mts +0 -208
- package/tests/security-alerts.test.vitest.mts +0 -338
- package/tests/server.test.vitest.mts +0 -200
- package/tests/smoke.test.vitest.mts +0 -177
- package/tests/system.test.vitest.mts +0 -355
- package/tests/upstream.test.vitest.mts +0 -91
- package/tests/verb-helpers.test.vitest.mts +0 -377
- package/tests/verbs.test.vitest.mts +0 -624
- 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
|
-
}
|