From ea2eb831b2a65bf980cf273122354ae4ab4699a2 Mon Sep 17 00:00:00 2001
From: "propr-dev[bot]" <213159723+propr-dev[bot]@users.noreply.github.com>
Date: Mon, 29 Jun 2026 23:27:18 +0000
Subject: [PATCH 1/3] Test files are intentionally excluded from eslint (the
existing test file shows the identical warning), and `eslint .` reports no
errors. Everything is clean.
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Summary
When I investigated issue #1655, I found that the `propr github-app manifest` command was already fully implemented in the base branch (`origin/1647-epic-github-app-11i`, built earlier under PR #1651). My branch had no commits beyond the base. I verified the existing implementation against every acceptance criterion and found the one genuine gap: there was no dedicated test exercising the manifest generator.
Verification of the existing implementation (all acceptance criteria met)
I ran the command end-to-end and confirmed:
- `propr github-app manifest --root
--public-url https://propr.example.com` writes `github-app-manifest.json` and `github-app.env` into the target dir.
- The manifest contains the correct webhook URL (`https://host/webhook`), all required permissions (`contents:write`, `issues:write`, `pull_requests:write`, `metadata:read`, `actions:read`, plus `checks:read`/`statuses:read` which GitHub requires to deliver `check_run`/`status` events), and all 7 webhook events — matching core's `SUPPORTED_WEBHOOK_EVENTS` exactly.
- `github-app.env` contains `GH_AUTH_MODE=app`, `GITHUB_EVENT_INTAKE_MODE=direct_webhook`, and the same generated secret as the manifest.
- A second run without `--force` exits 1 with "Refusing to overwrite…"; `--force` overwrites and exits 0.
- The command is exported from the barrel (`commands/index.ts`) and registered in the CLI entrypoint (`index.ts`).
What I added
`packages/cli/src/commands/githubAppCommands.test.ts` — a 14-case test suite (following the project's `node:test` + `assert/strict` convention) that locks in the acceptance criteria and guards against regressions:
- File creation, manifest contents (URL/permissions/events), and the direct-webhook `.env` snippet sharing the manifest's secret.
- Strong secret generation (64 hex chars), supplied-secret passthrough, and trailing-slash URL normalization.
- Overwrite protection — including the case where only one of the two files pre-exists (the preflight refuses so no half-pair is written) — plus `--force` overwrite.
- URL validation (rejects non-http(s) and missing URL), custom webhook URL override, org-scoped create URL, `redactSecret` non-mutation, and an event-list parity check against core.
Results: 14/14 tests pass, `tsc -p tsconfig.test.json` typechecks clean, and `eslint .` reports no errors.
---
.../src/commands/githubAppCommands.test.ts | 296 ++++++++++++++++++
1 file changed, 296 insertions(+)
create mode 100644 packages/cli/src/commands/githubAppCommands.test.ts
diff --git a/packages/cli/src/commands/githubAppCommands.test.ts b/packages/cli/src/commands/githubAppCommands.test.ts
new file mode 100644
index 000000000..00ce05331
--- /dev/null
+++ b/packages/cli/src/commands/githubAppCommands.test.ts
@@ -0,0 +1,296 @@
+/**
+ * Tests for the `propr github-app manifest` generator. Run with:
+ * `npx tsx --test src/commands/githubAppCommands.test.ts` (from packages/cli).
+ *
+ * Locks in the acceptance criteria from the issue: the right files are written,
+ * the manifest carries the expected webhook URL / permissions / events, the
+ * `.env` snippet selects direct webhook mode with the same secret, and a
+ * repeated run refuses to overwrite unless `--force` is passed.
+ */
+
+import assert from "node:assert/strict";
+import { test } from "node:test";
+import { mkdtemp, rm, readFile, writeFile } from "node:fs/promises";
+import { tmpdir } from "node:os";
+import path from "node:path";
+import {
+ generateGithubAppManifest,
+ generateWebhookSecret,
+ redactSecret,
+ PROPR_APP_PERMISSIONS,
+ PROPR_WEBHOOK_EVENTS,
+ MANIFEST_FILENAME,
+ ENV_FILENAME,
+} from "./githubAppCommands.js";
+
+async function withTempDir(
+ fn: (dir: string) => Promise
+): Promise {
+ const dir = await mkdtemp(path.join(tmpdir(), "propr-ghapp-"));
+ try {
+ await fn(dir);
+ } finally {
+ await rm(dir, { recursive: true, force: true });
+ }
+}
+
+test("writes the manifest and env files into --root", async () => {
+ await withTempDir(async (dir) => {
+ const result = await generateGithubAppManifest({
+ root: dir,
+ publicUrl: "https://propr.example.com",
+ });
+
+ assert.equal(result.manifestPath, path.join(dir, MANIFEST_FILENAME));
+ assert.equal(result.envPath, path.join(dir, ENV_FILENAME));
+
+ // Both files are actually on disk and parseable.
+ const manifestRaw = await readFile(result.manifestPath, "utf-8");
+ const manifest = JSON.parse(manifestRaw);
+ assert.equal(manifest.name, "ProPR");
+
+ const env = await readFile(result.envPath, "utf-8");
+ assert.match(env, /GH_AUTH_MODE=app/);
+ });
+});
+
+test("manifest carries the expected webhook URL, permissions, and events", async () => {
+ await withTempDir(async (dir) => {
+ const { manifest, webhookUrl } = await generateGithubAppManifest({
+ root: dir,
+ publicUrl: "https://propr.example.com/",
+ });
+
+ // Trailing slash on the public URL must not double up in the webhook URL.
+ assert.equal(webhookUrl, "https://propr.example.com/webhook");
+ assert.equal(manifest.hook_attributes.url, webhookUrl);
+ assert.equal(manifest.hook_attributes.active, true);
+ assert.equal(manifest.public, false);
+ assert.equal(manifest.url, "https://propr.example.com");
+
+ assert.deepEqual(manifest.default_permissions, {
+ contents: "write",
+ issues: "write",
+ pull_requests: "write",
+ metadata: "read",
+ actions: "read",
+ checks: "read",
+ statuses: "read",
+ });
+
+ // Every event ProPR's core webhook handler understands must be subscribed.
+ for (const event of [
+ "issues",
+ "issue_comment",
+ "pull_request_review_comment",
+ "pull_request",
+ "check_run",
+ "push",
+ "status",
+ ]) {
+ assert.ok(
+ manifest.default_events.includes(event),
+ `manifest should subscribe to "${event}"`
+ );
+ }
+ });
+});
+
+test("env snippet selects direct webhook mode with the manifest's secret", async () => {
+ await withTempDir(async (dir) => {
+ const result = await generateGithubAppManifest({
+ root: dir,
+ publicUrl: "https://propr.example.com",
+ });
+
+ const env = await readFile(result.envPath, "utf-8");
+ assert.match(env, /^GH_AUTH_MODE=app$/m);
+ assert.match(env, /^GITHUB_EVENT_INTAKE_MODE=direct_webhook$/m);
+ assert.match(
+ env,
+ new RegExp(`^GH_WEBHOOK_SECRET=${result.webhookSecret}$`, "m")
+ );
+
+ // The secret in the env file and the manifest must be identical.
+ assert.equal(result.manifest.hook_attributes.secret, result.webhookSecret);
+ });
+});
+
+test("generates a cryptographically strong secret when none is supplied", async () => {
+ await withTempDir(async (dir) => {
+ const { webhookSecret } = await generateGithubAppManifest({
+ root: dir,
+ publicUrl: "https://propr.example.com",
+ });
+ // 32 random bytes -> 64 hex chars.
+ assert.match(webhookSecret, /^[0-9a-f]{64}$/);
+ });
+});
+
+test("honors a supplied webhook secret", async () => {
+ await withTempDir(async (dir) => {
+ const { webhookSecret, manifest } = await generateGithubAppManifest({
+ root: dir,
+ publicUrl: "https://propr.example.com",
+ webhookSecret: "my-explicit-secret",
+ });
+ assert.equal(webhookSecret, "my-explicit-secret");
+ assert.equal(manifest.hook_attributes.secret, "my-explicit-secret");
+ });
+});
+
+test("refuses to overwrite existing files without force", async () => {
+ await withTempDir(async (dir) => {
+ await generateGithubAppManifest({
+ root: dir,
+ publicUrl: "https://propr.example.com",
+ });
+
+ await assert.rejects(
+ generateGithubAppManifest({
+ root: dir,
+ publicUrl: "https://propr.example.com",
+ }),
+ /Refusing to overwrite/
+ );
+ });
+});
+
+test("refuses even when only one of the two output files exists", async () => {
+ await withTempDir(async (dir) => {
+ // Pre-create just the env file; the manifest does not exist yet. The
+ // preflight must still refuse so we never write a half-pair.
+ await writeFile(path.join(dir, ENV_FILENAME), "stale\n", "utf-8");
+
+ await assert.rejects(
+ generateGithubAppManifest({
+ root: dir,
+ publicUrl: "https://propr.example.com",
+ }),
+ /Refusing to overwrite/
+ );
+
+ // The manifest must not have been written by the refused run.
+ await assert.rejects(readFile(path.join(dir, MANIFEST_FILENAME), "utf-8"));
+ });
+});
+
+test("force overwrites existing files", async () => {
+ await withTempDir(async (dir) => {
+ const first = await generateGithubAppManifest({
+ root: dir,
+ publicUrl: "https://propr.example.com",
+ });
+ const second = await generateGithubAppManifest({
+ root: dir,
+ publicUrl: "https://propr.example.com",
+ force: true,
+ });
+ // A fresh secret each run confirms the second write actually replaced the
+ // first rather than being skipped.
+ assert.notEqual(first.webhookSecret, second.webhookSecret);
+ });
+});
+
+test("rejects a non-http(s) public URL", async () => {
+ await withTempDir(async (dir) => {
+ await assert.rejects(
+ generateGithubAppManifest({ root: dir, publicUrl: "ftp://nope.example" }),
+ /must use http:\/\/ or https:\/\//
+ );
+ await assert.rejects(
+ generateGithubAppManifest({ root: dir, publicUrl: "not a url" }),
+ /is not a valid URL/
+ );
+ });
+});
+
+test("requires a public URL", async () => {
+ await withTempDir(async (dir) => {
+ await assert.rejects(
+ generateGithubAppManifest({ root: dir, publicUrl: " " }),
+ /public base URL is required/
+ );
+ });
+});
+
+test("a custom webhook URL overrides the default", async () => {
+ await withTempDir(async (dir) => {
+ const { webhookUrl, manifest } = await generateGithubAppManifest({
+ root: dir,
+ publicUrl: "https://propr.example.com",
+ webhookUrl: "https://hooks.example.com/ingest",
+ });
+ assert.equal(webhookUrl, "https://hooks.example.com/ingest");
+ assert.equal(manifest.hook_attributes.url, "https://hooks.example.com/ingest");
+ });
+});
+
+test("org scoping points the create URL at the org App page", async () => {
+ await withTempDir(async (dir) => {
+ const personal = await generateGithubAppManifest({
+ root: dir,
+ publicUrl: "https://propr.example.com",
+ });
+ assert.equal(personal.createUrl, "https://github.com/settings/apps/new");
+
+ const org = await generateGithubAppManifest({
+ root: dir,
+ publicUrl: "https://propr.example.com",
+ org: "acme",
+ force: true,
+ });
+ assert.equal(
+ org.createUrl,
+ "https://github.com/organizations/acme/settings/apps/new"
+ );
+ });
+});
+
+test("redactSecret hides the secret in both places without mutating the input", () => {
+ const secret = generateWebhookSecret();
+ const result = {
+ directory: "/tmp/x",
+ manifestPath: "/tmp/x/m.json",
+ envPath: "/tmp/x/e.env",
+ publicUrl: "https://propr.example.com",
+ webhookUrl: "https://propr.example.com/webhook",
+ webhookSecret: secret,
+ createUrl: "https://github.com/settings/apps/new",
+ manifest: {
+ name: "ProPR",
+ url: "https://propr.example.com",
+ hook_attributes: {
+ url: "https://propr.example.com/webhook",
+ active: true,
+ secret,
+ },
+ redirect_url: "https://propr.example.com/",
+ public: false,
+ default_permissions: { ...PROPR_APP_PERMISSIONS },
+ default_events: [...PROPR_WEBHOOK_EVENTS],
+ },
+ };
+
+ const redacted = redactSecret(result);
+ assert.notEqual(redacted.webhookSecret, secret);
+ assert.notEqual(redacted.manifest.hook_attributes.secret, secret);
+ // Original is untouched.
+ assert.equal(result.webhookSecret, secret);
+ assert.equal(result.manifest.hook_attributes.secret, secret);
+});
+
+test("CLI events list matches the documented core webhook events", () => {
+ // Guards against drift: PROPR_WEBHOOK_EVENTS in the CLI must mirror
+ // SUPPORTED_WEBHOOK_EVENTS in @propr/core (duplicated intentionally to avoid
+ // a core dependency).
+ assert.deepEqual(PROPR_WEBHOOK_EVENTS, [
+ "issues",
+ "issue_comment",
+ "pull_request_review_comment",
+ "pull_request",
+ "check_run",
+ "push",
+ "status",
+ ]);
+});
From 6a3570b0dd3c5ee8f8fa3bffebbc1740a543895a Mon Sep 17 00:00:00 2001
From: "propr-dev[bot]" <213159723+propr-dev[bot]@users.noreply.github.com>
Date: Mon, 29 Jun 2026 23:36:49 +0000
Subject: [PATCH 2/3] My changes are in place. Let me summarize what I found
and did.
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Summary
Key finding: Issue #1656 is a verbatim duplicate of issue #1648, whose implementation is already complete and merged into the base branch `1647-epic-github-app-11i`.
I verified this rigorously rather than assuming:
- Issue #1648 has the identical title, body, requirements, and acceptance criteria to #1656.
- Its implementation landed in commit `8d3a418e` ("...for issue #1648"), which `git merge-base --is-ancestor` confirms is an ancestor of this branch's base.
- The diff between this branch and the base touches none of the relevant setup/manifest files.
The functional work the issue asks for is already present and passing tests in the base:
| Requirement | Where it lives | Status |
|---|---|---|
| Setup action for manifest generation | `SetupActions.inspectGithubAppManifest` / `generateGithubAppManifest` (engine.ts) | ✅ |
| Prompt on `app` auth + `direct_webhook` | `maybeGenerateAppManifest()` (engine.ts:662), wired into intake step (engine.ts:1135) | ✅ |
| Public URL from `API_PUBLIC_URL`/`FRONTEND_URL`, else ask | engine.ts:678 + both renderers' `configureGithubAppManifest` | ✅ |
| Writes same files as `propr github-app manifest` | delegates to `generateGithubAppManifest` | ✅ |
| Next steps surfaced (GH_APP_ID, etc.) | folded into intake `settle` (engine.ts:1172) | ✅ |
| Re-runnable, no overwrite without confirm | `filesExist` → regenerate confirm (default no) | ✅ |
| Sequential + Ink renderers | `sequential.ts:432`, `SetupApp.tsx:365` | ✅ |
| Existing relay/polling unchanged | guarded by `direct_webhook && app` early-return | ✅ |
I ran the relevant suites — all green: engine (33), sequential (24), Ink (16), manifest command (14), and the end-to-end `cliSetupWizard` integration test (30, including "writes the same files as `propr github-app manifest`" and the re-run warning case).
What I changed: Since fabricating redundant code would be wrong, I closed the one genuine gap #1648 left — documentation. The docs described the standalone `propr github-app manifest` command but never mentioned that `propr setup` now offers to generate the manifest during the direct-webhook flow (the user-facing behavior this issue added). I added accurate, verified notes to:
- `docs/docs/features/propr-cli.md` — in the "Own GitHub App (direct webhook mode)" section.
- `docs/docs/tutorials/setup-server.md` — in the manifest-scaffolding section.
Both notes precisely match the implementation (when it's offered, URL detection from `.env`, no-overwrite re-run safety, and the next-steps folded into the intake step).
If the intent was net-new functional work, there is none to do here — the feature is already shipped in the base branch via #1648. I'd recommend the planner deduplicate #1656 against #1648.
---
docs/docs/features/propr-cli.md | 2 ++
docs/docs/tutorials/setup-server.md | 2 ++
2 files changed, 4 insertions(+)
diff --git a/docs/docs/features/propr-cli.md b/docs/docs/features/propr-cli.md
index 618c6b567..342c45b94 100644
--- a/docs/docs/features/propr-cli.md
+++ b/docs/docs/features/propr-cli.md
@@ -86,6 +86,8 @@ This writes two files into the current directory (use `--root ` to target
- `github-app-manifest.json` — submit it at GitHub's *Register new GitHub App* page. It pre-fills the repository permissions ProPR needs, the subscribed webhook events, your `POST /webhook` delivery URL, and a freshly generated webhook secret.
- `github-app.env` — a matching `.env` snippet (`GH_AUTH_MODE=app`, `GITHUB_EVENT_INTAKE_MODE=direct_webhook`, and the generated `GH_WEBHOOK_SECRET`). Append it to your stack `.env`.
+**`propr setup` offers this for you.** When you choose custom GitHub App auth and `direct_webhook` intake, the wizard prompts to generate the same two files into the stack root — reusing `API_PUBLIC_URL` or `FRONTEND_URL` from `.env` as the public URL, or asking for one when neither is set. To keep setup safe to re-run, it leaves an existing manifest in place unless you confirm a regenerate, and it folds the same create/install and `GH_APP_ID` / `GH_INSTALLATION_ID` / `HOST_GH_PRIVATE_KEY` next steps into the intake step rather than failing the run.
+
| Option | Description |
|--------|-------------|
| `--public-url ` | **Required.** Public base URL GitHub can reach (e.g. `https://propr.example.com`). |
diff --git a/docs/docs/tutorials/setup-server.md b/docs/docs/tutorials/setup-server.md
index 279e6a2cd..63a4920b1 100644
--- a/docs/docs/tutorials/setup-server.md
+++ b/docs/docs/tutorials/setup-server.md
@@ -80,6 +80,8 @@ propr github-app manifest --public-url https://propr.example.com
This writes `github-app-manifest.json` and `github-app.env` into the current directory. The manifest pre-fills the repository permissions, subscribed webhook events, the `POST /webhook` URL, and a freshly generated `GH_WEBHOOK_SECRET`; submit it at GitHub's *Register new GitHub App* page. The `.env` snippet carries the same secret plus the `GH_AUTH_MODE=app` / `GITHUB_EVENT_INTAKE_MODE=direct_webhook` settings — append it to your stack `.env`. After GitHub creates the App and you install it, fill in the values GitHub only assigns once the App exists: `GH_APP_ID`, `GH_INSTALLATION_ID`, and `HOST_GH_PRIVATE_KEY` (download the App's private key and point this at its absolute host path). See [ProPR CLI](../features/propr-cli.md#own-github-app-direct-webhook-mode) for all flags. Running `propr check` between generating the manifest and filling in those values flags exactly what is still missing and repeats the command.
+The guided `propr setup` wizard (below) can run this step for you: when you pick custom GitHub App auth and `direct_webhook` intake, it offers to write the same two files into the stack root, reusing `API_PUBLIC_URL`/`FRONTEND_URL` from `.env` for the public URL (or asking when neither is set) and leaving an existing manifest untouched unless you confirm a regenerate.
+
The manifest only scaffolds configuration — direct webhook mode still requires the public `POST /webhook` route below and installing the App on your account/org.
The webhook endpoint is `POST /webhook` on the API service (port `4000`). Route it through your reverse proxy, for example with nginx. Use an exact-match `location = /webhook` so the proxy does not also forward prefix siblings such as `/webhookadmin` or `/webhook-test` to the API:
From c48c573868cd36a18ee41f211a3451f43b6211f7 Mon Sep 17 00:00:00 2001
From: "propr-dev[bot]" <213159723+propr-dev[bot]@users.noreply.github.com>
Date: Mon, 29 Jun 2026 23:46:32 +0000
Subject: [PATCH 3/3] Summary
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
I analyzed issue #1657 ("Document and validate the automated own-GitHub-App direct webhook workflow") and audited the base branch `1647-epic-github-app-11i` against every requirement and acceptance criterion.
Finding: Most of the issue was already satisfied in the base branch — `.env.example` references `propr github-app manifest` and explains the generated files + manual values; the setup, deployment, CLI, and GitHub-auth docs all document the command (all cross-reference anchors verified to resolve); routing WebSocket is still framed as the recommended default; and `propr check` already appends a manifest hint to missing own-App values in direct-webhook mode. All 34 existing tests pass.
The one genuine gap was in this issue requirement:
> `propr check` should also recognize generated-but-incomplete own-App setup files and provide better next actions.
The existing hint was static — it always told users to *"Generate a ready-to-fill manifest"*, even when they'd already run the command and just needed to finish. Since users run `propr check` precisely *between* generating the manifest and filling in values, this was misleading.
Changes made:
1. `packages/cli/src/commands/checkCommands.ts` — Made the direct-webhook guidance context-aware:
- Added `manifestFilesPresent()` to detect `github-app-manifest.json` / `github-app.env` in the stack root.
- Added a "generated" hint variant that points at the existing files and the remaining GitHub-side steps (create/install the App, fill in `GH_APP_ID` / `GH_INSTALLATION_ID` / `HOST_GH_PRIVATE_KEY`), and mentions `propr github-app manifest --force` only for regeneration — so the command stays discoverable.
- Threaded the stack `rootDir` through `checkGithubAuth()` and `checkGithubIntakeMode()` (backward-compatible optional param) and the `runChecks` call sites.
2. `test/cliCheckGithubGuidance.test.ts` — Added 2 tests covering the generated-files branch for both auth and intake-mode checks (assert it names the files + stack root, still mentions the command, and no longer says "Generate a ready-to-fill manifest").
3. Docs (`propr-cli.md`, `setup-server.md`, `deployment.md`) — Refined the three sentences describing `propr check`'s behavior so they accurately reflect file recognition rather than "repeats the command."
Verification: CLI package typechecks clean (`tsc --noEmit`, exit 0); all 36 guidance/manifest tests pass. The one unrelated failure in `checkRunHandler.test.ts` is pre-existing and environmental — it fails at import time on `mock.module is not a function` (the installed Node v20.20.2 predates that API), before any of my code runs.
---
docs/docs/features/propr-cli.md | 2 +-
docs/docs/operations/deployment.md | 2 +-
docs/docs/tutorials/setup-server.md | 2 +-
packages/cli/src/commands/checkCommands.ts | 55 +++++++++++++++++---
test/cliCheckGithubGuidance.test.ts | 58 ++++++++++++++++++++++
5 files changed, 108 insertions(+), 11 deletions(-)
diff --git a/docs/docs/features/propr-cli.md b/docs/docs/features/propr-cli.md
index 342c45b94..26ad69604 100644
--- a/docs/docs/features/propr-cli.md
+++ b/docs/docs/features/propr-cli.md
@@ -99,7 +99,7 @@ This writes two files into the current directory (use `--root ` to target
| `-f, --force` | Overwrite existing output files. |
| `-j, --json` | Machine-readable output. |
-The manifest only scaffolds configuration. Direct webhook mode still requires a publicly reachable `POST /webhook` route (served by the API container on port 4000 — proxy it) and installing the created App on your account/org. GitHub assigns the App ID, installation id, and private key only **after** the App exists, so once it does, fill in `GH_APP_ID`, `GH_INSTALLATION_ID`, and `HOST_GH_PRIVATE_KEY` by hand. Run `propr check` in between: when direct webhook mode is selected and those own-App values are still missing, it reports each one and repeats the `propr github-app manifest` next step. See [Server Setup](../tutorials/setup-server.md#advanced-your-own-github-app-webhook) and [Deployment](../operations/deployment.md#issue-intake-modes).
+The manifest only scaffolds configuration. Direct webhook mode still requires a publicly reachable `POST /webhook` route (served by the API container on port 4000 — proxy it) and installing the created App on your account/org. GitHub assigns the App ID, installation id, and private key only **after** the App exists, so once it does, fill in `GH_APP_ID`, `GH_INSTALLATION_ID`, and `HOST_GH_PRIVATE_KEY` by hand. Run `propr check` in between: when direct webhook mode is selected and those own-App values are still missing, it reports each one. If it finds the generated `github-app-manifest.json` / `github-app.env` in the stack root, it recognizes the scaffolding is already in place and points at the remaining GitHub-side steps; otherwise it suggests running `propr github-app manifest` to generate them. See [Server Setup](../tutorials/setup-server.md#advanced-your-own-github-app-webhook) and [Deployment](../operations/deployment.md#issue-intake-modes).
## Hosted UI Tunnel
diff --git a/docs/docs/operations/deployment.md b/docs/docs/operations/deployment.md
index 513e5f42e..4b0b4dc76 100644
--- a/docs/docs/operations/deployment.md
+++ b/docs/docs/operations/deployment.md
@@ -158,7 +158,7 @@ GH_WEBHOOK_SECRET=your-webhook-secret
The fastest way to provision an own App for this mode is `propr github-app manifest --public-url https://propr.example.com` (see [ProPR CLI](../features/propr-cli.md#own-github-app-direct-webhook-mode)). It writes `github-app-manifest.json` (which pre-fills the required repository permissions, subscribed webhook events, your `POST /webhook` URL, and a generated `GH_WEBHOOK_SECRET`) plus a matching `github-app.env` snippet. Submit the manifest at GitHub's *Register new GitHub App* page, then — once GitHub has created the App and you have installed it — fill in `GH_APP_ID`, `GH_INSTALLATION_ID`, and `HOST_GH_PRIVATE_KEY` (the values GitHub only assigns after the App exists; `HOST_GH_PRIVATE_KEY` matches what the generated `github-app.env` recommends — see the CLI vs Launcher key-path note above if you deploy via the launcher). The manifest only scaffolds configuration: direct webhook mode still requires a public `POST /webhook` route and installing the App on your account/org.
-The API container serves the endpoint at `POST /webhook` (port 4000). Point your GitHub App's webhook URL at it through your reverse proxy, and set the same secret in the GitHub App settings. Direct webhook therefore requires your own GitHub App, a public URL, and `GH_WEBHOOK_SECRET`. The API refuses to start in `direct_webhook` mode without `GH_WEBHOOK_SECRET` (it is unused in the other modes — in particular, the default `routing_websocket` does not require it). Webhook delivery has no periodic backstop, so a missed or undelivered event relies on GitHub's redelivery. If you run `propr check` after generating the manifest but before filling in the App ID / installation id / private key, it flags the missing own-App values and repeats the `propr github-app manifest` next step.
+The API container serves the endpoint at `POST /webhook` (port 4000). Point your GitHub App's webhook URL at it through your reverse proxy, and set the same secret in the GitHub App settings. Direct webhook therefore requires your own GitHub App, a public URL, and `GH_WEBHOOK_SECRET`. The API refuses to start in `direct_webhook` mode without `GH_WEBHOOK_SECRET` (it is unused in the other modes — in particular, the default `routing_websocket` does not require it). Webhook delivery has no periodic backstop, so a missed or undelivered event relies on GitHub's redelivery. If you run `propr check` after generating the manifest but before filling in the App ID / installation id / private key, it flags the missing own-App values; when it detects the generated `github-app-manifest.json` / `github-app.env` in the stack root it recognizes the scaffolding already exists and points at the remaining GitHub-side steps instead of suggesting you regenerate it.
> **Migration from `ENABLE_GITHUB_WEBHOOKS`:** the legacy boolean `ENABLE_GITHUB_WEBHOOKS` is **deprecated** and no longer selects an intake mode. If it is still present in your environment, the backend logs a deprecation warning at startup and otherwise ignores it. Remove it and set `GITHUB_EVENT_INTAKE_MODE` explicitly (`routing_websocket`, `polling`, or `direct_webhook`); when unset, intake resolves to `routing_websocket`. Note that event intake is independent of GitHub auth mode (`GH_AUTH_MODE`) — see [GitHub Authentication](./github-auth.md).
diff --git a/docs/docs/tutorials/setup-server.md b/docs/docs/tutorials/setup-server.md
index 63a4920b1..7dcfdee74 100644
--- a/docs/docs/tutorials/setup-server.md
+++ b/docs/docs/tutorials/setup-server.md
@@ -78,7 +78,7 @@ Rather than assembling the App's permissions, webhook events, and secret by hand
propr github-app manifest --public-url https://propr.example.com
```
-This writes `github-app-manifest.json` and `github-app.env` into the current directory. The manifest pre-fills the repository permissions, subscribed webhook events, the `POST /webhook` URL, and a freshly generated `GH_WEBHOOK_SECRET`; submit it at GitHub's *Register new GitHub App* page. The `.env` snippet carries the same secret plus the `GH_AUTH_MODE=app` / `GITHUB_EVENT_INTAKE_MODE=direct_webhook` settings — append it to your stack `.env`. After GitHub creates the App and you install it, fill in the values GitHub only assigns once the App exists: `GH_APP_ID`, `GH_INSTALLATION_ID`, and `HOST_GH_PRIVATE_KEY` (download the App's private key and point this at its absolute host path). See [ProPR CLI](../features/propr-cli.md#own-github-app-direct-webhook-mode) for all flags. Running `propr check` between generating the manifest and filling in those values flags exactly what is still missing and repeats the command.
+This writes `github-app-manifest.json` and `github-app.env` into the current directory. The manifest pre-fills the repository permissions, subscribed webhook events, the `POST /webhook` URL, and a freshly generated `GH_WEBHOOK_SECRET`; submit it at GitHub's *Register new GitHub App* page. The `.env` snippet carries the same secret plus the `GH_AUTH_MODE=app` / `GITHUB_EVENT_INTAKE_MODE=direct_webhook` settings — append it to your stack `.env`. After GitHub creates the App and you install it, fill in the values GitHub only assigns once the App exists: `GH_APP_ID`, `GH_INSTALLATION_ID`, and `HOST_GH_PRIVATE_KEY` (download the App's private key and point this at its absolute host path). See [ProPR CLI](../features/propr-cli.md#own-github-app-direct-webhook-mode) for all flags. Running `propr check` between generating the manifest and filling in those values flags exactly what is still missing; when it sees the generated `github-app-manifest.json` / `github-app.env` in the stack root it recognizes the scaffolding is already there and points at the remaining GitHub-side steps rather than telling you to regenerate it.
The guided `propr setup` wizard (below) can run this step for you: when you pick custom GitHub App auth and `direct_webhook` intake, it offers to write the same two files into the stack root, reusing `API_PUBLIC_URL`/`FRONTEND_URL` from `.env` for the public URL (or asking when neither is set) and leaving an existing manifest untouched unless you confirm a regenerate.
diff --git a/packages/cli/src/commands/checkCommands.ts b/packages/cli/src/commands/checkCommands.ts
index ad09b37fb..0e1b526a9 100644
--- a/packages/cli/src/commands/checkCommands.ts
+++ b/packages/cli/src/commands/checkCommands.ts
@@ -376,11 +376,14 @@ export async function runChecks(options: RunChecksOptions = {}): Promise string | undefined): boolean
* The mode itself comes from @propr/shared's resolveGithubAuthMode — the same
* function the backend uses — so this check cannot drift from boot behavior.
*/
-export function checkGithubAuth(env: Record, cfg: OrchestratorConfig): CheckResult[] {
+export function checkGithubAuth(env: Record, cfg: OrchestratorConfig, rootDir?: string): CheckResult[] {
const val = (k: string): string | undefined => process.env[k] ?? env[k];
const out: CheckResult[] = [];
@@ -583,7 +622,7 @@ export function checkGithubAuth(env: Record, cfg: OrchestratorCo
// mean the user generated the manifest but has not finished filling it in —
// point them at the manifest command and the files it writes.
const directWebhook = isDirectWebhookIntake(val);
- const manifestSuffix = directWebhook ? ` ${DIRECT_WEBHOOK_MANIFEST_HINT}` : "";
+ const manifestSuffix = directWebhook ? ` ${directWebhookManifestHint(rootDir)}` : "";
const appId = val("GH_APP_ID");
const installationId = val("GH_INSTALLATION_ID");
@@ -649,7 +688,7 @@ export function checkGithubAuth(env: Record, cfg: OrchestratorCo
* Reuses the shared validateIntakeModePrerequisites helper so `propr check`
* and the backend boot path agree on what each mode requires.
*/
-export function checkGithubIntakeMode(env: Record): CheckResult[] {
+export function checkGithubIntakeMode(env: Record, rootDir?: string): CheckResult[] {
const val = (k: string): string | undefined => process.env[k] ?? env[k];
const out: CheckResult[] = [];
@@ -723,7 +762,7 @@ export function checkGithubIntakeMode(env: Record): CheckResult[
// Direct webhook prerequisites (own GitHub App + webhook secret) are exactly
// what `propr github-app manifest` scaffolds, so attach that next-action hint
// when the failure is for direct webhook intake.
- const intakeFix = intakeMode === "direct_webhook" ? DIRECT_WEBHOOK_MANIFEST_HINT : undefined;
+ const intakeFix = intakeMode === "direct_webhook" ? directWebhookManifestHint(rootDir) : undefined;
for (const error of errors) {
out.push({ name: "GitHub intake mode", status: "fail", detail: error, group: "GitHub", ...(intakeFix ? { fix: intakeFix } : {}) });
}
diff --git a/test/cliCheckGithubGuidance.test.ts b/test/cliCheckGithubGuidance.test.ts
index dde3eafad..ff32b8d36 100644
--- a/test/cliCheckGithubGuidance.test.ts
+++ b/test/cliCheckGithubGuidance.test.ts
@@ -1,5 +1,8 @@
import { describe, test, beforeEach, afterEach } from "node:test";
import assert from "node:assert";
+import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
+import { tmpdir } from "node:os";
+import { join } from "node:path";
import {
checkGithubAuth,
checkGithubIntakeMode,
@@ -71,6 +74,61 @@ describe("propr check — direct webhook own-App guidance", () => {
assert.match(appId?.fix ?? "", /github-app\.env/);
});
+ test("generated-but-incomplete setup points at the existing files instead of regenerating", () => {
+ const rootDir = mkdtempSync(join(tmpdir(), "propr-manifest-"));
+ try {
+ // Simulate `propr github-app manifest` / `propr setup` having already
+ // written the scaffolding into the stack root.
+ writeFileSync(join(rootDir, "github-app-manifest.json"), "{}\n");
+ writeFileSync(join(rootDir, "github-app.env"), "GH_AUTH_MODE=app\n");
+
+ const env = {
+ GH_AUTH_MODE: "app",
+ GITHUB_EVENT_INTAKE_MODE: "direct_webhook",
+ } as Record;
+
+ const results = checkGithubAuth(env, cfg, rootDir);
+
+ const appId = results.find((r) => r.name === "GH_APP_ID");
+ assert.strictEqual(appId?.status, "fail");
+ // Still names the command (so it stays discoverable) ...
+ assert.match(appId?.fix ?? "", new RegExp(MANIFEST_COMMAND));
+ // ... but recognizes the generated files and the stack root instead of
+ // telling the user to generate a manifest they already have.
+ assert.match(appId?.fix ?? "", /Found generated/);
+ assert.match(appId?.fix ?? "", new RegExp(rootDir.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")));
+ assert.doesNotMatch(appId?.fix ?? "", /Generate a ready-to-fill manifest/);
+ } finally {
+ rmSync(rootDir, { recursive: true, force: true });
+ }
+ });
+
+ test("intake prerequisite failures recognize generated files too", () => {
+ const rootDir = mkdtempSync(join(tmpdir(), "propr-manifest-"));
+ try {
+ writeFileSync(join(rootDir, "github-app.env"), "GH_AUTH_MODE=app\n");
+
+ const env = {
+ GH_AUTH_MODE: "app",
+ GITHUB_EVENT_INTAKE_MODE: "direct_webhook",
+ // No GH_WEBHOOK_SECRET → prerequisite failure.
+ } as Record;
+
+ const results = checkGithubIntakeMode(env, rootDir);
+ const failures = results.filter(
+ (r) => r.name === "GitHub intake mode" && r.status === "fail",
+ );
+
+ assert.ok(failures.length >= 1);
+ for (const f of failures) {
+ assert.match(f.fix ?? "", new RegExp(MANIFEST_COMMAND));
+ assert.match(f.fix ?? "", /Found generated/);
+ }
+ } finally {
+ rmSync(rootDir, { recursive: true, force: true });
+ }
+ });
+
test("own-App failures stay manifest-free when the default routing mode is used", () => {
const env = {
GH_AUTH_MODE: "app",