From 91b994126f92a564d0bcd7fe1da17fc99d3fb07c Mon Sep 17 00:00:00 2001 From: Val Alexander Date: Mon, 6 Jul 2026 14:33:25 -0500 Subject: [PATCH] chore: add app config doctor Signed-off-by: Val Alexander --- .env.1password.example | 6 +++ README.md | 1 + docs/coven-github-connection.md | 19 ++++++- package.json | 1 + scripts/doctor-app-config.mjs | 89 +++++++++++++++++++++++++++++++++ src/adapter.ts | 4 +- tests/webhook-adapter.test.ts | 11 ++++ 7 files changed, 129 insertions(+), 2 deletions(-) create mode 100644 .env.1password.example create mode 100644 scripts/doctor-app-config.mjs diff --git a/.env.1password.example b/.env.1password.example new file mode 100644 index 0000000..1c52eb5 --- /dev/null +++ b/.env.1password.example @@ -0,0 +1,6 @@ +GITHUB_APP_ID=op://Private/CovenCat/App ID +GITHUB_WEBHOOK_SECRET=op://Private/CovenCat/Webhook Secret +GITHUB_APP_PRIVATE_KEY=op://Private/CovenCat/Private Key +COVEN_GITHUB_POLICY_PATH=./coven-github-policy.json +COVEN_GITHUB_STATE_DIR=./coven-github-state +COVEN_CODE_BIN=coven-code diff --git a/README.md b/README.md index 28fb495..a5fcd3e 100644 --- a/README.md +++ b/README.md @@ -79,6 +79,7 @@ while a correctly HMAC-signed GitHub `ping` delivery is accepted without needing ```bash npm test npm run build +npm run doctor:app npm run smoke:app ``` diff --git a/docs/coven-github-connection.md b/docs/coven-github-connection.md index cdad21e..906664c 100644 --- a/docs/coven-github-connection.md +++ b/docs/coven-github-connection.md @@ -34,7 +34,23 @@ After creating the App, keep these values outside git: - App ID -> `GITHUB_APP_ID` - Webhook secret -> `GITHUB_WEBHOOK_SECRET` -- Private key PEM path -> `GITHUB_APP_PRIVATE_KEY_PATH` +- Private key PEM -> `GITHUB_APP_PRIVATE_KEY`, or private key PEM path -> + `GITHUB_APP_PRIVATE_KEY_PATH` + +For 1Password-backed local runs, copy +`.env.1password.example` to an ignored local env file and update the `op://` +references to the fields in your item: + +```bash +cp .env.1password.example .env.1password.local +``` + +Then run commands through 1Password without exposing secret values: + +```bash +op run --env-file .env.1password.local -- npm run doctor:app +op run --env-file .env.1password.local -- npm start +``` ## Policy @@ -78,6 +94,7 @@ export COVEN_GITHUB_POLICY_PATH="$PWD/coven-github-policy.json" export COVEN_GITHUB_STATE_DIR="$PWD/coven-github-state" export COVEN_CODE_BIN="$(command -v coven-code)" +npm run doctor:app npm start ``` diff --git a/package.json b/package.json index 2082721..6830ed7 100644 --- a/package.json +++ b/package.json @@ -8,6 +8,7 @@ }, "scripts": { "build": "tsc -p tsconfig.json", + "doctor:app": "npm run build && node scripts/doctor-app-config.mjs", "start": "node dist/src/server.js", "dev": "tsx src/server.ts", "smoke:app": "npm run build && node scripts/demo-app-smoke.mjs", diff --git a/scripts/doctor-app-config.mjs b/scripts/doctor-app-config.mjs new file mode 100644 index 0000000..b991608 --- /dev/null +++ b/scripts/doctor-app-config.mjs @@ -0,0 +1,89 @@ +#!/usr/bin/env node +import {existsSync, readFileSync} from "node:fs"; +import {join} from "node:path"; + +import {createConfig} from "../dist/src/adapter.js"; + +function finding(level, field, message, next) { + return {level, field, message, next}; +} + +function hasPem(value) { + return /-----BEGIN [A-Z ]*PRIVATE KEY-----/.test(value || ""); +} + +function loadJson(path) { + try { + return JSON.parse(readFileSync(path, "utf8")); + } catch { + return null; + } +} + +const config = createConfig(process.env, process.cwd()); +const findings = []; + +if (!config.appId) { + findings.push(finding("error", "GITHUB_APP_ID", "missing GitHub App ID", "Set GITHUB_APP_ID or run through 1Password with an App ID env ref.")); +} else if (!/^\d+$/.test(config.appId)) { + findings.push(finding("error", "GITHUB_APP_ID", "GitHub App ID must be numeric", "Use the numeric App ID from the GitHub App settings page.")); +} + +if (!config.webhookSecret) { + findings.push(finding("error", "GITHUB_WEBHOOK_SECRET", "missing webhook secret", "Set GITHUB_WEBHOOK_SECRET from the GitHub App webhook secret.")); +} + +if (!config.privateKeyPem && !existsSync(config.privateKeyPath)) { + findings.push(finding("error", "GITHUB_APP_PRIVATE_KEY", "missing GitHub App private key", "Set GITHUB_APP_PRIVATE_KEY from 1Password or GITHUB_APP_PRIVATE_KEY_PATH to a PEM file.")); +} else if (config.privateKeyPem && !hasPem(config.privateKeyPem)) { + findings.push(finding("error", "GITHUB_APP_PRIVATE_KEY", "private key env var does not look like a PEM", "Store the full downloaded GitHub App private key PEM in 1Password.")); +} else if (!config.privateKeyPem) { + const key = readFileSync(config.privateKeyPath, "utf8"); + if (!hasPem(key)) { + findings.push(finding("error", "GITHUB_APP_PRIVATE_KEY_PATH", "private key file does not look like a PEM", "Point GITHUB_APP_PRIVATE_KEY_PATH at the downloaded GitHub App private key.")); + } +} + +const policy = loadJson(config.policyPath); +if (!policy) { + findings.push(finding("error", "COVEN_GITHUB_POLICY_PATH", "policy file is missing or invalid JSON", "Copy config/example-policy.json and replace the installation/repository IDs.")); +} else { + const installations = policy.installations || {}; + const installationIds = Object.keys(installations); + if (!installationIds.length) { + findings.push(finding("error", "policy.installations", "policy has no installation routes", "Add the GitHub App installation ID and repository ID.")); + } + if (installationIds.includes("123456")) { + findings.push(finding("error", "policy.installations", "policy still uses placeholder installation ID 123456", "Replace it with the real installation ID.")); + } + for (const installationId of installationIds) { + const repos = installations[installationId]?.repositories || {}; + if (!Object.keys(repos).length) { + findings.push(finding("error", "policy.repositories", `installation ${installationId} has no repositories`, "Add at least one repository ID route.")); + } + if (Object.keys(repos).includes("987654321")) { + findings.push(finding("error", "policy.repositories", "policy still uses placeholder repository ID 987654321", "Replace it with the real repository ID.")); + } + } +} + +if (!process.env.COVEN_CODE_BIN && !config.demoMode) { + findings.push(finding("warning", "COVEN_CODE_BIN", "COVEN_CODE_BIN is not set", "Set it to the coven-code binary path before running real tasks.")); +} + +const errors = findings.filter((item) => item.level === "error"); +const output = { + ok: errors.length === 0, + demo_mode: config.demoMode, + checked: { + app_id: Boolean(config.appId), + webhook_secret: Boolean(config.webhookSecret), + private_key: Boolean(config.privateKeyPem || existsSync(config.privateKeyPath)), + policy_path: config.policyPath, + state_dir: config.stateDir, + }, + findings, +}; + +console.log(JSON.stringify(output, null, 2)); +process.exit(errors.length ? 1 : 0); diff --git a/src/adapter.ts b/src/adapter.ts index 47f30da..503bd2e 100644 --- a/src/adapter.ts +++ b/src/adapter.ts @@ -16,6 +16,7 @@ export interface AdapterConfig { attemptsDir: string; policyPath: string; privateKeyPath: string; + privateKeyPem: string; appId: string; webhookSecret: string; covenCodeBin: string; @@ -72,6 +73,7 @@ export function createConfig(env: NodeJS.ProcessEnv = process.env, rootDir = pro attemptsDir: join(stateDir, "attempts"), policyPath: resolve(env.COVEN_GITHUB_POLICY_PATH || join(rootDir, "coven-github-policy.json")), privateKeyPath: resolve(env.GITHUB_APP_PRIVATE_KEY_PATH || join(rootDir, ".coven-github-private-key.pem")), + privateKeyPem: (env.GITHUB_APP_PRIVATE_KEY || "").trim(), appId: (env.GITHUB_APP_ID || "").trim(), webhookSecret: (env.GITHUB_WEBHOOK_SECRET || env.WEBHOOK_SECRET || "").trim(), covenCodeBin: (env.COVEN_CODE_BIN || "coven-code").trim() || "coven-code", @@ -181,7 +183,7 @@ function githubAppJwt(config: AdapterConfig): string { const header = {alg: "RS256", typ: "JWT"}; const payload = {iat: now - 60, exp: now + 540, iss: config.appId}; const signingInput = `${b64url(JSON.stringify(header))}.${b64url(JSON.stringify(payload))}`; - const privateKey = readFileSync(config.privateKeyPath, "utf8"); + const privateKey = config.privateKeyPem || readFileSync(config.privateKeyPath, "utf8"); const signature = createSign("RSA-SHA256").update(signingInput).sign(privateKey); return `${signingInput}.${b64url(signature)}`; } diff --git a/tests/webhook-adapter.test.ts b/tests/webhook-adapter.test.ts index a84932e..4488c99 100644 --- a/tests/webhook-adapter.test.ts +++ b/tests/webhook-adapter.test.ts @@ -268,6 +268,17 @@ test("webhook secret supports smoke script environment name", async () => { assert.equal(response.body.ok, true); }); +test("config accepts an inline GitHub App private key from env", () => { + const config = createConfig( + { + GITHUB_APP_PRIVATE_KEY: "-----BEGIN PRIVATE KEY-----\nexample\n-----END PRIVATE KEY-----", + }, + process.cwd(), + ); + + assert.equal(config.privateKeyPem, "-----BEGIN PRIVATE KEY-----\nexample\n-----END PRIVATE KEY-----"); +}); + test("missing familiar policy does not fall back to hardcoded installation", () => { const task = buildTaskFromEvent( "issues",