@cldmv/gh-broker 0.0.0-stage → 0.7.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/.configs/fix-headers.json +3 -0
- package/.configs/vitest.config.mjs +44 -0
- package/.github/dependabot.yml +111 -0
- package/.github/workflows/branch-retention.yml +44 -0
- package/.github/workflows/bundle-size.yml +52 -0
- package/.github/workflows/ci.yml +389 -0
- package/.github/workflows/cla.yml +66 -0
- package/.github/workflows/codeql.yml +87 -0
- package/.github/workflows/dependabot-auto-merge.yml +62 -0
- package/.github/workflows/dependabot-recreate.yml +67 -0
- package/.github/workflows/dependency-review.yml +46 -0
- package/.github/workflows/feature-pr.yml +75 -0
- package/.github/workflows/hotfix-redirector.yml +67 -0
- package/.github/workflows/hotfixes-release.yml +70 -0
- package/.github/workflows/labeler.yml +48 -0
- package/.github/workflows/master-commit-audit.yml +56 -0
- package/.github/workflows/member-auto-merge.yml +75 -0
- package/.github/workflows/next-release.yml +76 -0
- package/.github/workflows/next-reset.yml +50 -0
- package/.github/workflows/pr-notify.yml +44 -0
- package/.github/workflows/pr-title-normalizer.yml +52 -0
- package/.github/workflows/provenance.yml +51 -0
- package/.github/workflows/publish.yml +146 -0
- package/.github/workflows/release-merge.yml +104 -0
- package/.github/workflows/release-notify.yml +41 -0
- package/.github/workflows/scorecard.yml +64 -0
- package/.github/workflows/stale.yml +50 -0
- package/.github/workflows/tag-health.yml +70 -0
- package/.github/workflows/update-major-version-tags.yml +94 -0
- package/.github/workflows/v4-bootstrap.yml +115 -0
- package/.github/workflows/welcome.yml +42 -0
- package/LICENSE +202 -0
- package/README.md +185 -2
- package/config.example.json +281 -0
- package/deploy/README.md +92 -0
- package/deploy/gh-broker.service +57 -0
- package/deploy/install.mjs +344 -0
- package/deploy/install.sh +29 -0
- package/deploy/lib/config-merge.mjs +56 -0
- package/deploy/lib/prompt.mjs +98 -0
- package/deploy/lib/secret-store.mjs +114 -0
- package/deploy/lib/smoke.mjs +137 -0
- package/deploy/lib/system.mjs +222 -0
- package/dist/index.mjs +22970 -0
- package/dist/index.mjs.map +7 -0
- package/docs/TOOLS.md +347 -0
- package/docs/agent-user-model.md +89 -0
- package/docs/changelog/v0/v0.2.0.md +26 -0
- package/docs/changelog/v0/v0.2.1.md +23 -0
- package/docs/changelog/v0/v0.2.2.md +23 -0
- package/docs/changelog/v0/v0.2.3.md +33 -0
- package/docs/changelog/v0/v0.2.4.md +25 -0
- package/docs/changelog/v0/v0.2.5.md +29 -0
- package/docs/changelog/v0/v0.3.0.md +84 -0
- package/docs/changelog/v0/v0.3.1.md +30 -0
- package/docs/changelog/v0/v0.3.2.md +24 -0
- package/docs/changelog/v0/v0.3.3.md +28 -0
- package/docs/changelog/v0/v0.4.0.md +58 -0
- package/docs/changelog/v0/v0.4.1.md +33 -0
- package/docs/changelog/v0/v0.4.2.md +45 -0
- package/docs/changelog/v0/v0.5.0.md +39 -0
- package/docs/changelog/v0/v0.5.1.md +28 -0
- package/docs/changelog/v0/v0.6.0.md +52 -0
- package/docs/changelog/v0/v0.6.1.md +48 -0
- package/docs/changelog/v0/v0.7.0.md +71 -0
- package/docs/deploy-architecture.md +82 -0
- package/package.json +71 -5
- package/src/appauth.ts +56 -0
- package/src/audit.ts +34 -0
- package/src/catalog.ts +165 -0
- package/src/config.ts +130 -0
- package/src/index.ts +107 -0
- package/src/paths.ts +60 -0
- package/src/router.ts +258 -0
- package/src/secrets.ts +137 -0
- package/src/server.ts +91 -0
- package/src/types.ts +104 -0
- package/src/upstream.ts +85 -0
- package/src/verbs/issue_labels.ts +329 -0
- package/src/verbs/link_issue.ts +84 -0
- package/src/verbs/release-analysis.ts +210 -0
- package/src/verbs/repo-tier.ts +90 -0
- package/src/verbs/report_ci_status.ts +358 -0
- package/src/verbs/report_release_prs.ts +238 -0
- package/src/verbs/report_security_alerts.ts +442 -0
- package/src/verbs/report_unlabeled.ts +194 -0
- package/src/verbs/rest-client.ts +181 -0
- package/src/verbs/review_thread.ts +138 -0
- package/tests/appauth.test.vitest.mts +76 -0
- package/tests/audit.test.vitest.mts +48 -0
- package/tests/catalog.test.vitest.mts +180 -0
- package/tests/config-merge.test.vitest.mts +58 -0
- package/tests/config.test.vitest.mts +314 -0
- package/tests/issue-labels.test.vitest.mts +347 -0
- package/tests/paths.test.vitest.mts +52 -0
- package/tests/release-prs.test.vitest.mts +237 -0
- package/tests/rest-client.test.vitest.mts +115 -0
- package/tests/router.test.vitest.mts +399 -0
- package/tests/run-vitest.mjs +51 -0
- package/tests/secret-store.test.vitest.mts +106 -0
- package/tests/secrets.test.vitest.mts +155 -0
- package/tests/security-alerts.test.vitest.mts +328 -0
- package/tests/server.test.vitest.mts +20 -0
- package/tests/upstream.test.vitest.mts +91 -0
- package/tests/verb-helpers.test.vitest.mts +331 -0
- package/tests/verbs.test.vitest.mts +624 -0
- package/tsconfig.json +21 -0
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
# Deploy architecture
|
|
2
|
+
|
|
3
|
+
How gh-broker is installed, configured, and upgraded. The guiding goals are **portability** (copy the repo to another machine, run one installer, it works), a **clean upgrade path** (updates never lose operator-set data), and **platform-neutrality** (Linux today, with seams so macOS/Windows are additive later — not a rewrite).
|
|
4
|
+
|
|
5
|
+
## Config: one portable, token-free file, regenerated by merge
|
|
6
|
+
|
|
7
|
+
There is a single config file. It is **portable** and **contains no secrets** — only the tool allowlist, owner names, and credential *references* (`"read": "cred:cldmv-read"`). Keeping tokens out of it is what makes it safe to copy, commit, and carry between machines; a file designed to be copied must never hold live credentials.
|
|
8
|
+
|
|
9
|
+
On install *and* upgrade the deployed config is **regenerated by merging**, not overwritten:
|
|
10
|
+
|
|
11
|
+
```
|
|
12
|
+
new_config = merge(example_defaults, current_config)
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
- `example_defaults` — `config.example.json`, shipped with the code. Updated freely on upgrade; this is where new keys and allowlist changes arrive.
|
|
16
|
+
- `current_config` — whatever is already deployed on this machine (absent on a first install).
|
|
17
|
+
|
|
18
|
+
The merge is **field-level**, because the two halves have different owners:
|
|
19
|
+
|
|
20
|
+
| Field | Source of truth | Why |
|
|
21
|
+
|---|---|---|
|
|
22
|
+
| `tools` (the allowlist) | **example** (repo) | It is a security control. A removal or tightening in an update must apply; a naive "current wins" would silently keep a tool the repo dropped. |
|
|
23
|
+
| `owners`, `app`, `listen`, `upstream`, `defaultOwner` | **current** (deployed), falling back to example | Deployment data the operator set. Preserved across upgrades; new owners from the example are added, existing ones kept as-is. |
|
|
24
|
+
|
|
25
|
+
So an upgrade brings new defaults and allowlist changes through the example, while everything the operator configured survives. Nothing is blindly clobbered.
|
|
26
|
+
|
|
27
|
+
## Secrets: separate, agent-unreadable, and the storage backend is the operator's choice
|
|
28
|
+
|
|
29
|
+
Secrets never live in the config — the config carries only `cred:<name>` references. The tokens live in a separate store the agent user cannot read, behind a **provider seam** (`config.secrets.provider`) the installer sets from a prompt. Two providers ship:
|
|
30
|
+
|
|
31
|
+
- **`systemd-creds`** (default, recommended on Linux): tokens are encrypted with a host/TPM-bound key as `.cred` files, delivered by a unit drop-in (`LoadCredentialEncrypted`) into a memory-only `$CREDENTIALS_DIRECTORY` at start. **Encrypted at rest** and resistant to backups/disk theft (the key isn't beside the ciphertext); not portable to another machine.
|
|
32
|
+
- **`file`**: a JSON `{ "<name>": "<token>" }` map at `0640 root:gh-broker` inside the `0750` config dir. **Portable and platform-neutral**; at-rest protection is filesystem permissions only (plaintext on disk).
|
|
33
|
+
|
|
34
|
+
The **agent-read boundary is identical either way** — it's filesystem permissions (the agent isn't in the `gh-broker` group), not encryption. The choice trades *at-rest protection* (systemd-creds) against *portability* (file); it does not weaken the agent boundary. This is a genuine trade because the daemon must auto-start, so any decryption key must be available without a human at boot — which only a host/TPM-bound key (systemd-creds) achieves without leaving the key beside the ciphertext.
|
|
35
|
+
|
|
36
|
+
The installer moves tokens between backends without re-entry where it can: switching to `file` decrypts the existing host-bound `.cred` files (as root, same host) into the JSON; switching to `systemd-creds` encrypts the tokens into `.cred` files. It prompts only for tokens it can't find. Further providers (OS keychain, DPAPI, encrypted vault) slot into the same seam without touching the daemon's call sites.
|
|
37
|
+
|
|
38
|
+
## Paths: resolved, not hardcoded
|
|
39
|
+
|
|
40
|
+
Neither the config nor the secrets path is hardcoded to `/etc`. Both resolve through the same order:
|
|
41
|
+
|
|
42
|
+
1. explicit `--config` flag / `GH_BROKER_CONFIG` (and `GH_BROKER_SECRETS`) — highest,
|
|
43
|
+
2. the platform's system config dir — Linux `/etc/gh-broker`, macOS `/Library/Application Support/gh-broker`, Windows `%ProgramData%\gh-broker`,
|
|
44
|
+
3. the packaged default.
|
|
45
|
+
|
|
46
|
+
Only the Linux defaults are exercised today; the others are wired so a later platform port is a table entry, not surgery.
|
|
47
|
+
|
|
48
|
+
## Node runtime: a maintained symlink, no runtime resolution
|
|
49
|
+
|
|
50
|
+
The service runs jailed (`ProtectHome`), so it cannot use a `/home` nvm, and there is no login shell at start. Rather than have the unit *resolve* node at runtime, the installer maintains a **stable symlink** — `/usr/local/bin/node` → the chosen node — and the unit simply runs it:
|
|
51
|
+
|
|
52
|
+
```
|
|
53
|
+
ExecStart=/usr/local/bin/node /opt/gh-broker/dist/index.mjs --config <resolved>
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
The symlink is the single indirection: the CLI (via `PATH`) and the service resolve the *same* link, so "CLI node == service node" holds by construction. Bumping node is re-pointing the symlink — which is exactly the node step the installer runs on setup and on update, so it is not a special case. This removes `launch.sh` (a runtime nvm resolver that duplicated the symlink) and keeps the hardened unit free of a `bash`/`nvm` startup dependency.
|
|
57
|
+
|
|
58
|
+
## Tooling: Node, with one optional shell bootstrap
|
|
59
|
+
|
|
60
|
+
All install/deploy *logic* is Node (`deploy/install.mjs` + `deploy/lib/*.mjs`): config merge and validation (validation reuses the daemon's own loader via `node dist/index.mjs --check`), the interactive prompts, the privileged system operations (shelled out to `useradd` / `systemctl` / `rsync`), building `dist/`/`node_modules/` as the checkout's own owner via `sudo -u '#<uid>'` rather than leaving them root-owned, and the smoke test (a real MCP client over the socket). One language, testable with the repo's vitest suite.
|
|
61
|
+
|
|
62
|
+
The only thing that can't be Node is bootstrapping node itself. So the entrypoint is **`sudo node deploy/install.mjs`** with node ≥20 as the one prerequisite — which runs on any platform node runs on. For zero-prerequisite convenience on Linux, an **optional** `deploy/install.sh` (~20 lines) sets up nvm + node + the symlink if node is absent, then `exec`s the Node installer. It is a convenience, not the real installer.
|
|
63
|
+
|
|
64
|
+
## The installer flow (install and upgrade are the same command)
|
|
65
|
+
|
|
66
|
+
`sudo node deploy/install.mjs` is idempotent — first install and later upgrade are one command:
|
|
67
|
+
|
|
68
|
+
1. detect platform + check prerequisites (`socat`, `rsync`, `systemctl`, `sudo`),
|
|
69
|
+
2. **create or confirm the agent user** — hard stop if it can't (no silent warn-and-continue),
|
|
70
|
+
3. ensure node on a stable path + the `/usr/local/bin/node` symlink,
|
|
71
|
+
4. **regenerate the config** via `merge(example, current)`,
|
|
72
|
+
5. **choose the secret backend and store the PATs** — keep existing, migrate a legacy `systemd-creds` token, or take it from `--secrets-file` / env / a prompt (see the Secrets section),
|
|
73
|
+
6. validate the config with the real loader (`node dist/index.mjs --check`),
|
|
74
|
+
7. build + deploy (unit → `/usr/local/bin/node`, `dist/`, config, secrets),
|
|
75
|
+
8. **smoke test** — MCP `initialize` + one real read over the socket → ✅/❌.
|
|
76
|
+
|
|
77
|
+
The same flow runs **fully unattended** via flags — `--agent-user`, `--secrets-provider`, `--secrets-file` / `GH_BROKER_SECRET_<NAME>`, `--create-agent-user`, `--non-interactive`; prompts are the default when flags are omitted. See [`../deploy/README.md`](../deploy/README.md) and `--help`.
|
|
78
|
+
|
|
79
|
+
## What is deliberately deferred
|
|
80
|
+
|
|
81
|
+
- Non-Linux secret providers and path defaults (the seams exist; the implementations are additive).
|
|
82
|
+
- `link_issue` (App-token-only) stays deferred — implemented but not advertised or wired; see [`../deploy/README.md`](../deploy/README.md).
|
package/package.json
CHANGED
|
@@ -1,6 +1,72 @@
|
|
|
1
1
|
{
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
2
|
+
"name": "@cldmv/gh-broker",
|
|
3
|
+
"version": "0.7.0",
|
|
4
|
+
"description": "Local credential-routing MCP proxy: holds per-org GitHub PATs out of the agent's reach, routes by repo owner, exposes a config-driven default-deny toolset in front of GitHub's hosted MCP.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"license": "Apache-2.0",
|
|
7
|
+
"bin": {
|
|
8
|
+
"gh-broker": "./dist/index.mjs"
|
|
9
|
+
},
|
|
10
|
+
"main": "./dist/index.mjs",
|
|
11
|
+
"engines": {
|
|
12
|
+
"node": ">=22.12.0"
|
|
13
|
+
},
|
|
14
|
+
"scripts": {
|
|
15
|
+
"typecheck": "tsc -p tsconfig.json --noEmit",
|
|
16
|
+
"build": "npm run typecheck && rm -rf dist && esbuild src/index.ts --bundle --platform=node --format=esm --target=node20 --outfile=dist/index.mjs --sourcemap",
|
|
17
|
+
"start": "node dist/index.mjs",
|
|
18
|
+
"dev": "node --experimental-strip-types --watch src/index.ts",
|
|
19
|
+
"build:ci": "npm run build",
|
|
20
|
+
"test:types": "npm run typecheck",
|
|
21
|
+
"test": "node tests/run-vitest.mjs",
|
|
22
|
+
"test:watch": "vitest --config .configs/vitest.config.mjs",
|
|
23
|
+
"coverage": "node tests/run-vitest.mjs --coverage-quiet",
|
|
24
|
+
"ci:coverage": "npm run coverage",
|
|
25
|
+
"fix:headers": "fix-headers --config .configs/fix-headers.json"
|
|
26
|
+
},
|
|
27
|
+
"author": {
|
|
28
|
+
"name": "Shinrai",
|
|
29
|
+
"company": "CLDMV",
|
|
30
|
+
"email": "git+npm@cldmv.net",
|
|
31
|
+
"url": "https://cldmv.net"
|
|
32
|
+
},
|
|
33
|
+
"contributors": [
|
|
34
|
+
{
|
|
35
|
+
"name": "CLDMV",
|
|
36
|
+
"url": "https://github.com/CLDMV"
|
|
37
|
+
}
|
|
38
|
+
],
|
|
39
|
+
"funding": {
|
|
40
|
+
"type": "github",
|
|
41
|
+
"url": "https://github.com/sponsors/shinrai"
|
|
42
|
+
},
|
|
43
|
+
"repository": {
|
|
44
|
+
"type": "git",
|
|
45
|
+
"url": "git+https://github.com/CLDMV/gh-broker.git"
|
|
46
|
+
},
|
|
47
|
+
"bugs": {
|
|
48
|
+
"url": "https://github.com/CLDMV/gh-broker/issues"
|
|
49
|
+
},
|
|
50
|
+
"homepage": "https://github.com/CLDMV/gh-broker#readme",
|
|
51
|
+
"devDependencies": {
|
|
52
|
+
"@cldmv/configs": "^1.3.0",
|
|
53
|
+
"@cldmv/fix-headers": "^2.2.1",
|
|
54
|
+
"@cldmv/vitest-runner": "^1.5.4",
|
|
55
|
+
"@types/node": "^26.6.5",
|
|
56
|
+
"@vitest/coverage-v8": "^5.0.2",
|
|
57
|
+
"esbuild": "^0.28.2",
|
|
58
|
+
"typescript": "^6.0.3",
|
|
59
|
+
"vitest": "^5.0.3"
|
|
60
|
+
},
|
|
61
|
+
"dependencies": {
|
|
62
|
+
"@modelcontextprotocol/sdk": "^1.32.1",
|
|
63
|
+
"@octokit/auth-app": "^8.3.1",
|
|
64
|
+
"@octokit/core": "^7.0.8"
|
|
65
|
+
},
|
|
66
|
+
"publishConfig": {
|
|
67
|
+
"access": "public"
|
|
68
|
+
},
|
|
69
|
+
"allowScripts": {
|
|
70
|
+
"esbuild": true
|
|
71
|
+
}
|
|
72
|
+
}
|
package/src/appauth.ts
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
/**
|
|
2
|
+
*
|
|
3
|
+
* @Project: @cldmv/gh-broker
|
|
4
|
+
* @Filename: /src/appauth.ts
|
|
5
|
+
* @Date: 2026-07-07T18:39:48-07:00 (1783474788)
|
|
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 { Octokit } from "@octokit/core";
|
|
17
|
+
import { createAppAuth } from "@octokit/auth-app";
|
|
18
|
+
import type { Config, SecretStore } from "./types.ts";
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Provides per-owner GitHub App–authenticated Octokit clients for the operations
|
|
22
|
+
* that fine-grained PATs can't do (`link_issue` → addClosingIssueReference, which
|
|
23
|
+
* is not in the fine-grained-PAT GraphQL schema — only App/classic tokens see it).
|
|
24
|
+
*
|
|
25
|
+
* `@octokit/auth-app` handles the JWT signing + installation-token minting/refresh
|
|
26
|
+
* internally, so there's no hand-rolled token machinery here. The App private key
|
|
27
|
+
* lives only in the broker (systemd-creds), never in the agent.
|
|
28
|
+
*/
|
|
29
|
+
export class AppAuth {
|
|
30
|
+
private readonly config: Config;
|
|
31
|
+
private readonly appId: number;
|
|
32
|
+
private readonly privateKey: string;
|
|
33
|
+
private readonly cache = new Map<number, Octokit>();
|
|
34
|
+
|
|
35
|
+
constructor(config: Config, secrets: SecretStore) {
|
|
36
|
+
if (!config.app) throw new Error("AppAuth: no app config");
|
|
37
|
+
this.config = config;
|
|
38
|
+
this.appId = config.app.appId;
|
|
39
|
+
this.privateKey = secrets.resolve(config.app.privateKey);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** App-authed Octokit for this owner's installation, or null if none configured. */
|
|
43
|
+
octokitFor(owner: string): Octokit | null {
|
|
44
|
+
const installationId = this.config.owners[owner]?.installationId;
|
|
45
|
+
if (installationId === undefined) return null;
|
|
46
|
+
let ok = this.cache.get(installationId);
|
|
47
|
+
if (!ok) {
|
|
48
|
+
ok = new Octokit({
|
|
49
|
+
authStrategy: createAppAuth,
|
|
50
|
+
auth: { appId: this.appId, privateKey: this.privateKey, installationId }
|
|
51
|
+
});
|
|
52
|
+
this.cache.set(installationId, ok);
|
|
53
|
+
}
|
|
54
|
+
return ok;
|
|
55
|
+
}
|
|
56
|
+
}
|
package/src/audit.ts
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
/**
|
|
2
|
+
*
|
|
3
|
+
* @Project: @cldmv/gh-broker
|
|
4
|
+
* @Filename: /src/audit.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
|
+
/**
|
|
17
|
+
* Structured audit log. One JSON line per decision/call to stdout (journald
|
|
18
|
+
* captures it). NEVER include token values — only tool, owner, mode, repo, result.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
export interface AuditEntry {
|
|
22
|
+
tool: string;
|
|
23
|
+
owner?: string;
|
|
24
|
+
mode?: "read" | "write";
|
|
25
|
+
repo?: string;
|
|
26
|
+
decision: "forward" | "custom" | "augment" | "deny" | "error";
|
|
27
|
+
ok?: boolean;
|
|
28
|
+
detail?: string;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function audit(entry: AuditEntry): void {
|
|
32
|
+
// eslint-disable-next-line no-console -- journald is the sink
|
|
33
|
+
console.log(JSON.stringify({ ts: new Date().toISOString(), ...entry }));
|
|
34
|
+
}
|
package/src/catalog.ts
ADDED
|
@@ -0,0 +1,165 @@
|
|
|
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-16T17:17:02-07:00 (1792196222)
|
|
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
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Build the advertised tool list ONCE at startup. Proxied tools carry their real
|
|
30
|
+
* schemas fetched from the hosted MCP (tool schemas are owner-independent, so any
|
|
31
|
+
* valid read token works — we use the first configured owner's read PAT). Custom
|
|
32
|
+
* verbs carry local schemas. Only allowlisted tools appear; nothing else is
|
|
33
|
+
* advertised (and, per the router, nothing else is executable either).
|
|
34
|
+
*
|
|
35
|
+
* If the upstream fetch fails (network/token), we advertise minimal schemas rather
|
|
36
|
+
* than fail startup — the daemon still serves and calls still route correctly.
|
|
37
|
+
*/
|
|
38
|
+
export async function buildCatalog(config: Config, secrets: SecretStore, upstream: UpstreamPool): Promise<Tool[]> {
|
|
39
|
+
const upstreamByName = new Map<string, Tool>();
|
|
40
|
+
// Whether the upstream tool list was actually fetched. If it wasn't (network/token), we must NOT drop
|
|
41
|
+
// allowlisted tools — we fall back to minimal schemas so the daemon still serves + routes. But if the
|
|
42
|
+
// fetch DID succeed, a tool missing from it genuinely isn't served by this upstream (wrong toolset /
|
|
43
|
+
// renamed), so advertising a minimal-schema stub only defers the failure to call time (the confusing
|
|
44
|
+
// "could not determine repo owner" → "unknown tool" pair) — better to skip it and say why at startup.
|
|
45
|
+
let upstreamFetched = false;
|
|
46
|
+
const firstOwner = Object.values(config.owners)[0];
|
|
47
|
+
if (firstOwner) {
|
|
48
|
+
try {
|
|
49
|
+
const token = secrets.resolve(firstOwner.read);
|
|
50
|
+
// Never let a slow/unreachable upstream hang startup — fall back to minimal schemas.
|
|
51
|
+
const timeoutMs = Number(process.env.GH_BROKER_CATALOG_TIMEOUT_MS ?? 10000);
|
|
52
|
+
const tools = await Promise.race([
|
|
53
|
+
upstream.listToolsFor(token),
|
|
54
|
+
new Promise<never>((_, rej) => setTimeout(() => rej(new Error(`timed out after ${timeoutMs}ms`)), timeoutMs).unref())
|
|
55
|
+
]);
|
|
56
|
+
for (const t of tools) upstreamByName.set(t.name, t);
|
|
57
|
+
upstreamFetched = true;
|
|
58
|
+
} catch (err) {
|
|
59
|
+
console.error(`catalog: could not fetch upstream tool schemas (${(err as Error).message}) — advertising minimal schemas`);
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const tools: Tool[] = [];
|
|
64
|
+
for (const rule of config.tools) {
|
|
65
|
+
if (rule.custom) {
|
|
66
|
+
// DEFERRED: link_issue (App-token-only addClosingIssueReference) is not wired in — see docs.
|
|
67
|
+
// Intentionally not advertised even if an allowlist entry is added; re-enable by restoring
|
|
68
|
+
// this branch + the import above + the router dispatch case, then config.app + installationId.
|
|
69
|
+
if (rule.name === "resolve_review_thread") {
|
|
70
|
+
tools.push({
|
|
71
|
+
name: "resolve_review_thread",
|
|
72
|
+
description:
|
|
73
|
+
"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).",
|
|
74
|
+
inputSchema: RESOLVE_REVIEW_THREAD_SCHEMA
|
|
75
|
+
});
|
|
76
|
+
} else if (rule.name === "reply_review_thread") {
|
|
77
|
+
tools.push({
|
|
78
|
+
name: "reply_review_thread",
|
|
79
|
+
description:
|
|
80
|
+
"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).",
|
|
81
|
+
inputSchema: REPLY_REVIEW_THREAD_SCHEMA
|
|
82
|
+
});
|
|
83
|
+
} else if (rule.name === "issue_labels") {
|
|
84
|
+
tools.push({
|
|
85
|
+
name: "issue_labels",
|
|
86
|
+
description:
|
|
87
|
+
'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).',
|
|
88
|
+
inputSchema: ISSUE_LABELS_SCHEMA
|
|
89
|
+
});
|
|
90
|
+
} else if (rule.name === "report_unlabeled") {
|
|
91
|
+
tools.push({
|
|
92
|
+
name: "report_unlabeled",
|
|
93
|
+
description:
|
|
94
|
+
"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.",
|
|
95
|
+
inputSchema: REPORT_UNLABELED_SCHEMA
|
|
96
|
+
});
|
|
97
|
+
} else if (rule.name === "report_security_alerts") {
|
|
98
|
+
tools.push({
|
|
99
|
+
name: "report_security_alerts",
|
|
100
|
+
description:
|
|
101
|
+
"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.",
|
|
102
|
+
inputSchema: REPORT_SECURITY_ALERTS_SCHEMA
|
|
103
|
+
});
|
|
104
|
+
} else if (rule.name === "report_release_prs") {
|
|
105
|
+
tools.push({
|
|
106
|
+
name: "report_release_prs",
|
|
107
|
+
description:
|
|
108
|
+
"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.",
|
|
109
|
+
inputSchema: REPORT_RELEASE_PRS_SCHEMA
|
|
110
|
+
});
|
|
111
|
+
} else if (rule.name === "report_ci_status") {
|
|
112
|
+
tools.push({
|
|
113
|
+
name: "report_ci_status",
|
|
114
|
+
description:
|
|
115
|
+
"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.",
|
|
116
|
+
inputSchema: REPORT_CI_STATUS_SCHEMA
|
|
117
|
+
});
|
|
118
|
+
}
|
|
119
|
+
continue;
|
|
120
|
+
}
|
|
121
|
+
const up = upstreamByName.get(rule.name);
|
|
122
|
+
if (!up && upstreamFetched) {
|
|
123
|
+
// Reachable upstream that doesn't expose this tool → it would 32602 at call time. Skip + warn.
|
|
124
|
+
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).`);
|
|
125
|
+
continue;
|
|
126
|
+
}
|
|
127
|
+
const base = up ?? { name: rule.name, description: `(${rule.mode}) proxied to the GitHub MCP`, inputSchema: { type: "object" } };
|
|
128
|
+
if (rule.augment && rule.name === "pull_request_read") {
|
|
129
|
+
// Layer gh-broker's own extension params onto the real upstream schema — the
|
|
130
|
+
// forwarded call still needs pull_request_read's actual params (method,
|
|
131
|
+
// pullNumber, ...); this ADDS to that, it doesn't replace it. See router.ts's
|
|
132
|
+
// dispatchAugmented for why gh-broker can extend a proxied tool's contract.
|
|
133
|
+
const baseSchema = base.inputSchema as { properties?: Record<string, unknown>; [k: string]: unknown };
|
|
134
|
+
tools.push({
|
|
135
|
+
...base,
|
|
136
|
+
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.`,
|
|
137
|
+
inputSchema: {
|
|
138
|
+
...baseSchema,
|
|
139
|
+
type: "object",
|
|
140
|
+
properties: {
|
|
141
|
+
...(baseSchema.properties ?? {}),
|
|
142
|
+
ciVerbosity: { type: "number", enum: [0, 1, 2, 3], description: 'gh-broker extension: also compute CI status at this verbosity (method "get" only)' },
|
|
143
|
+
logLines: { type: "number", description: "gh-broker extension: tier-3 log tail length" }
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
});
|
|
147
|
+
continue;
|
|
148
|
+
}
|
|
149
|
+
if (rule.name === "issue_write") {
|
|
150
|
+
// issue_write's `labels` REPLACES the issue's whole label set — the trap behind
|
|
151
|
+
// CLDMV/gh-broker#117. Say so where the agent reads the tool, and point at the verb
|
|
152
|
+
// that edits labels one at a time when it is allowlisted.
|
|
153
|
+
const pointer = config.tools.some((t) => t.name === "issue_labels" && t.custom)
|
|
154
|
+
? " To add, remove or swap individual labels, use issue_labels instead."
|
|
155
|
+
: " Read the current labels first and pass the full set you want to keep.";
|
|
156
|
+
tools.push({
|
|
157
|
+
...base,
|
|
158
|
+
description: `${base.description ?? ""} WARNING (gh-broker): "labels" REPLACES the issue's whole label set — every label not listed is removed.${pointer}`.trim()
|
|
159
|
+
});
|
|
160
|
+
continue;
|
|
161
|
+
}
|
|
162
|
+
tools.push(base);
|
|
163
|
+
}
|
|
164
|
+
return tools;
|
|
165
|
+
}
|
package/src/config.ts
ADDED
|
@@ -0,0 +1,130 @@
|
|
|
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-02T11:30:58-07:00 (1790965858)
|
|
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
|
+
|
|
19
|
+
const MODES: readonly Mode[] = ["read", "write"];
|
|
20
|
+
|
|
21
|
+
/** Load and validate the JSON config. Throws with a precise message on any problem. */
|
|
22
|
+
export function loadConfig(path: string): Config {
|
|
23
|
+
let raw: unknown;
|
|
24
|
+
try {
|
|
25
|
+
raw = JSON.parse(readFileSync(path, "utf8"));
|
|
26
|
+
} catch (err) {
|
|
27
|
+
throw new Error(`config: cannot read/parse ${path}: ${(err as Error).message}`);
|
|
28
|
+
}
|
|
29
|
+
if (typeof raw !== "object" || raw === null) throw new Error("config: root must be an object");
|
|
30
|
+
const c = raw as Record<string, unknown>;
|
|
31
|
+
|
|
32
|
+
if (typeof c.listen !== "string" || !c.listen.startsWith("unix:")) {
|
|
33
|
+
throw new Error('config.listen: must be a "unix:<path>" string');
|
|
34
|
+
}
|
|
35
|
+
if (typeof c.upstream !== "string" || !/^https?:\/\//.test(c.upstream)) {
|
|
36
|
+
throw new Error("config.upstream: must be an http(s) URL");
|
|
37
|
+
}
|
|
38
|
+
if (!Array.isArray(c.tools) || c.tools.length === 0) {
|
|
39
|
+
throw new Error("config.tools: must be a non-empty array");
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const seen = new Set<string>();
|
|
43
|
+
const tools: ToolRule[] = c.tools.map((t, i) => {
|
|
44
|
+
const o = t as Record<string, unknown>;
|
|
45
|
+
if (typeof o.name !== "string" || !o.name) throw new Error(`config.tools[${i}].name: required`);
|
|
46
|
+
if (!MODES.includes(o.mode as Mode)) throw new Error(`config.tools[${i}].mode: must be read|write`);
|
|
47
|
+
if (seen.has(o.name)) throw new Error(`config.tools: duplicate tool "${o.name}"`);
|
|
48
|
+
seen.add(o.name);
|
|
49
|
+
const custom = o.custom === true;
|
|
50
|
+
const augment = o.augment === true;
|
|
51
|
+
if (custom && augment) throw new Error(`config.tools[${i}] ("${o.name}"): custom and augment are mutually exclusive`);
|
|
52
|
+
return { name: o.name, mode: o.mode as Mode, custom, augment };
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
if (typeof c.owners !== "object" || c.owners === null) throw new Error("config.owners: must be an object");
|
|
56
|
+
const owners: Config["owners"] = {};
|
|
57
|
+
for (const [owner, v] of Object.entries(c.owners as Record<string, unknown>)) {
|
|
58
|
+
const o = v as Record<string, unknown>;
|
|
59
|
+
if (typeof o.read !== "string" || typeof o.write !== "string") {
|
|
60
|
+
throw new Error(`config.owners.${owner}: needs "read" and "write" credential refs`);
|
|
61
|
+
}
|
|
62
|
+
if (o.installationId !== undefined && typeof o.installationId !== "number") {
|
|
63
|
+
throw new Error(`config.owners.${owner}.installationId: must be a number`);
|
|
64
|
+
}
|
|
65
|
+
owners[owner.toLowerCase()] = {
|
|
66
|
+
read: o.read,
|
|
67
|
+
write: o.write,
|
|
68
|
+
...(typeof o.installationId === "number" ? { installationId: o.installationId } : {})
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
if (Object.keys(owners).length === 0) throw new Error("config.owners: at least one owner required");
|
|
72
|
+
|
|
73
|
+
let defaultOwner: string | undefined;
|
|
74
|
+
if (c.defaultOwner !== undefined) {
|
|
75
|
+
if (typeof c.defaultOwner !== "string" || !c.defaultOwner) {
|
|
76
|
+
throw new Error("config.defaultOwner: must be a non-empty string naming a configured owner");
|
|
77
|
+
}
|
|
78
|
+
defaultOwner = c.defaultOwner.toLowerCase();
|
|
79
|
+
if (!owners[defaultOwner]) {
|
|
80
|
+
throw new Error(`config.defaultOwner "${c.defaultOwner}" is not a key of config.owners`);
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
let app: Config["app"];
|
|
85
|
+
if (c.app !== undefined) {
|
|
86
|
+
const a = c.app as Record<string, unknown>;
|
|
87
|
+
if (typeof a.appId !== "number") throw new Error("config.app.appId: must be a number");
|
|
88
|
+
if (typeof a.privateKey !== "string") throw new Error('config.app.privateKey: must be a "cred:<name>" ref');
|
|
89
|
+
app = { appId: a.appId, privateKey: a.privateKey };
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
let secrets: Config["secrets"];
|
|
93
|
+
if (c.secrets !== undefined) {
|
|
94
|
+
if (typeof c.secrets !== "object" || c.secrets === null || Array.isArray(c.secrets)) throw new Error("config.secrets: must be an object");
|
|
95
|
+
const s = c.secrets as Record<string, unknown>;
|
|
96
|
+
if (s.provider !== undefined && s.provider !== "file" && s.provider !== "systemd-creds") {
|
|
97
|
+
throw new Error('config.secrets.provider: must be "systemd-creds" or "file"');
|
|
98
|
+
}
|
|
99
|
+
// Reject an empty path: resolveSecretsPath treats a falsy explicit path as
|
|
100
|
+
// "not provided" and falls back to env/default, so "" would be silently ignored.
|
|
101
|
+
if (s.path !== undefined && (typeof s.path !== "string" || s.path.length === 0)) {
|
|
102
|
+
throw new Error("config.secrets.path: must be a non-empty string");
|
|
103
|
+
}
|
|
104
|
+
// path only means anything to the file provider; the systemd-creds provider
|
|
105
|
+
// (the default) reads $CREDENTIALS_DIRECTORY and would silently ignore it.
|
|
106
|
+
// Reject the combination so an operator can't think a file path is in effect.
|
|
107
|
+
if (s.path !== undefined && (s.provider ?? "systemd-creds") !== "file") {
|
|
108
|
+
throw new Error('config.secrets.path is only used by the "file" provider — set provider to "file" or remove path');
|
|
109
|
+
}
|
|
110
|
+
secrets = {
|
|
111
|
+
...(s.provider === "file" || s.provider === "systemd-creds" ? { provider: s.provider } : {}),
|
|
112
|
+
...(typeof s.path === "string" ? { path: s.path } : {})
|
|
113
|
+
};
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
return {
|
|
117
|
+
listen: c.listen,
|
|
118
|
+
upstream: c.upstream,
|
|
119
|
+
tools,
|
|
120
|
+
owners,
|
|
121
|
+
...(app ? { app } : {}),
|
|
122
|
+
...(secrets ? { secrets } : {}),
|
|
123
|
+
...(defaultOwner ? { defaultOwner } : {})
|
|
124
|
+
};
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/** Build a name→rule index for O(1) allowlist checks. */
|
|
128
|
+
export function indexTools(tools: ToolRule[]): Map<string, ToolRule> {
|
|
129
|
+
return new Map(tools.map((t) => [t.name, t]));
|
|
130
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,107 @@
|
|
|
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
|
+
});
|