propr-cli 0.8.3 → 0.8.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (44) hide show
  1. package/README.md +4 -4
  2. package/dist/api/relay.js +10 -0
  3. package/dist/assets/env.example.txt +182 -59
  4. package/dist/auth/githubLogin.js +66 -0
  5. package/dist/commands/agentCommands.js +74 -0
  6. package/dist/commands/agentValidation.js +548 -0
  7. package/dist/commands/checkCommands.js +981 -76
  8. package/dist/commands/imageCommands.js +60 -0
  9. package/dist/commands/index.js +3 -0
  10. package/dist/commands/initStack.js +50 -1
  11. package/dist/commands/relayCommands.js +45 -12
  12. package/dist/commands/setup/agents.js +185 -0
  13. package/dist/commands/setup/engine.js +956 -0
  14. package/dist/commands/setup/github.js +181 -0
  15. package/dist/commands/setup/sequential.js +501 -0
  16. package/dist/commands/setup/state.js +242 -0
  17. package/dist/commands/setup/types.js +85 -0
  18. package/dist/commands/setupCommand.js +85 -0
  19. package/dist/commands/stackCommands.js +14 -2
  20. package/dist/commands/systemCommands.js +49 -2
  21. package/dist/commands/tunnelCommand.js +562 -0
  22. package/dist/config/ConfigManager.js +22 -0
  23. package/dist/config/types.js +1 -0
  24. package/dist/index.js +14 -45
  25. package/dist/orchestrator/format.js +46 -0
  26. package/dist/orchestrator/index.js +7 -2
  27. package/dist/orchestrator/manifest.json +12 -11
  28. package/dist/orchestrator/orchestrator.mjs +872 -73
  29. package/dist/tui/AgentTableApp.js +86 -0
  30. package/dist/tui/CheckApp.js +202 -0
  31. package/dist/tui/SetupApp.js +586 -0
  32. package/dist/tui/SetupApp.test.js +172 -0
  33. package/dist/tui/app.js +84 -0
  34. package/dist/tui/render.js +28 -2
  35. package/dist/utils/envFile.js +45 -0
  36. package/dist/vendor/shared/githubEventIntakeMode.js +41 -0
  37. package/dist/vendor/shared/index.js +17 -0
  38. package/dist/vendor/shared/intakeModePrerequisites.js +76 -0
  39. package/dist/vendor/shared/modelDefinitions.js +4 -4
  40. package/dist/vendor/shared/proprCompatibility.js +70 -0
  41. package/dist/vendor/shared/proprServiceUrls.js +124 -0
  42. package/dist/vendor/shared/statusKeys.js +14 -0
  43. package/dist/vendor/shared/validateRoutingUrl.js +46 -0
  44. package/package.json +3 -3
@@ -0,0 +1,242 @@
1
+ /**
2
+ * Setup wizard domain helpers.
3
+ *
4
+ * Pure, side-effect-light helpers that the `propr setup` driver and both
5
+ * renderers (Ink TUI and readline fallback) build on:
6
+ * - resolving the stack root (reusing the orchestrator's precedence rules),
7
+ * - inspecting whether the stack is already initialized,
8
+ * - reading and *safely* editing .env (non-destructive by default),
9
+ * - constructing and transitioning the {@link SetupState} step model.
10
+ *
11
+ * Nothing here loads the orchestrator's Docker core or renders UI, so the
12
+ * module can be imported and unit-tested without Docker, Ink, or readline.
13
+ * `resolveStackRoot` lives in ../../orchestrator/index.js but only reads config
14
+ * and env — it does not start Docker.
15
+ */
16
+ import { readFileSync, statSync } from "node:fs";
17
+ import { join } from "node:path";
18
+ import { resolveGithubAuthMode } from "../../vendor/shared/index.js";
19
+ import { resolveStackRoot } from "../../orchestrator/index.js";
20
+ import { clearEnvKeys as clearEnvFileKeys, upsertEnvVars } from "../../utils/envFile.js";
21
+ import { SETUP_STEP_DEFINITIONS, } from "./types.js";
22
+ /**
23
+ * Sub-directories scaffoldStack creates under the stack root. Exported so the
24
+ * setup driver and tests can create/check the same scaffold shape without
25
+ * duplicating these names.
26
+ */
27
+ export const STACK_SUBDIRS = ["data", "logs", "repos"];
28
+ /** True only when `path` exists and is a directory. Missing paths read false. */
29
+ function isDirectory(path) {
30
+ try {
31
+ return statSync(path).isDirectory();
32
+ }
33
+ catch {
34
+ return false;
35
+ }
36
+ }
37
+ /** True only when `path` exists and is a regular file. Missing paths read false. */
38
+ function isFile(path) {
39
+ try {
40
+ return statSync(path).isFile();
41
+ }
42
+ catch {
43
+ return false;
44
+ }
45
+ }
46
+ /** True when a value is missing or contains only whitespace. */
47
+ function isBlank(value) {
48
+ return value === undefined || value.trim() === "";
49
+ }
50
+ /**
51
+ * Resolve the stack root for setup, reusing the orchestrator's precedence:
52
+ * explicit flag → PROPR_ROOT env → saved config stackRoot → cwd. Does not load
53
+ * Docker.
54
+ */
55
+ export function resolveSetupRoot(configManager, flagRoot) {
56
+ return resolveStackRoot(configManager, flagRoot);
57
+ }
58
+ /** Absolute path to the .env file for a given stack root. */
59
+ export function envPathFor(rootDir) {
60
+ return join(rootDir, ".env");
61
+ }
62
+ /**
63
+ * Inspect whether the stack at `rootDir` looks initialized. Read-only — never
64
+ * creates anything — so callers can decide whether to skip or re-run
65
+ * scaffolding. A plain file standing in for an expected directory (or vice
66
+ * versa) counts as *not* initialized, matching what the runtime requires.
67
+ */
68
+ export function inspectStackInit(rootDir) {
69
+ const envExists = isFile(envPathFor(rootDir));
70
+ const dirs = {};
71
+ for (const sub of STACK_SUBDIRS) {
72
+ dirs[sub] = isDirectory(join(rootDir, sub));
73
+ }
74
+ const initialized = envExists && STACK_SUBDIRS.every((sub) => dirs[sub]);
75
+ return { rootDir, envExists, dirs, initialized };
76
+ }
77
+ /** Convenience predicate over {@link inspectStackInit}. */
78
+ export function isStackInitialized(rootDir) {
79
+ return inspectStackInit(rootDir).initialized;
80
+ }
81
+ /**
82
+ * Parse the .env at `rootDir` into a flat map. Returns `{}` when the file is
83
+ * absent. Mirrors the assignment shape the rest of the stack relies on:
84
+ * `KEY=value`, optionally `export `-prefixed, ignoring blanks and comments.
85
+ * For unquoted values a trailing ` # comment` is stripped, matching the
86
+ * orchestrator's env-file reader (and the round-trip that {@link upsertEnvVars}
87
+ * guards against); surrounding quotes on quoted values are stripped and their
88
+ * contents kept verbatim. This is intentionally a lightweight reader, not a
89
+ * full dotenv implementation — it does not handle escaped quotes or multiline
90
+ * values.
91
+ */
92
+ export function readEnvVars(rootDir) {
93
+ const envPath = envPathFor(rootDir);
94
+ // Treat anything that is not a regular file (absent, a directory, a broken
95
+ // symlink) as "no vars", matching inspectStackInit's `isFile` guard, so a
96
+ // malformed stack surfaces as not-initialized instead of crashing the read.
97
+ if (!isFile(envPath))
98
+ return {};
99
+ const vars = {};
100
+ for (const line of readFileSync(envPath, "utf-8").split(/\r?\n/)) {
101
+ const match = line.match(/^\s*(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)\s*=(.*)$/);
102
+ if (!match)
103
+ continue;
104
+ const [, key, rawValue] = match;
105
+ const trimmed = rawValue.trim();
106
+ const quoted = trimmed.match(/^(["'])(.*)\1$/);
107
+ // Quoted values keep their contents verbatim; unquoted values drop a
108
+ // trailing inline comment so reads agree with what upsertEnvVars allows.
109
+ vars[key] = quoted ? quoted[2] : trimmed.replace(/\s+#.*$/, "");
110
+ }
111
+ return vars;
112
+ }
113
+ /** True when `key` is present in .env with a non-blank value. */
114
+ export function hasEnvValue(rootDir, key) {
115
+ return !isBlank(readEnvVars(rootDir)[key]);
116
+ }
117
+ /**
118
+ * Safely edit .env for a setup step.
119
+ *
120
+ * Non-destructive by default: a key is only written when it is currently
121
+ * absent/empty, so re-running `propr setup` never clobbers values the user
122
+ * already set. Pass `{ overwrite: true }` for steps where the user explicitly
123
+ * selected a new value and intends to replace whatever is there.
124
+ *
125
+ * Blank selections (empty or whitespace-only) are ignored entirely — a step
126
+ * that has nothing to write must not blank out an existing value. Writes go
127
+ * through
128
+ * {@link upsertEnvVars}, which preserves unrelated lines and tightens the
129
+ * file's permissions.
130
+ */
131
+ export function applyEnvSelection(rootDir, vars, opts = {}) {
132
+ const existing = readEnvVars(rootDir);
133
+ const toWrite = {};
134
+ const written = [];
135
+ const skipped = [];
136
+ for (const [key, value] of Object.entries(vars)) {
137
+ if (isBlank(value))
138
+ continue; // never blank out an existing value
139
+ const alreadySet = !isBlank(existing[key]);
140
+ if (alreadySet && !opts.overwrite) {
141
+ skipped.push(key);
142
+ continue;
143
+ }
144
+ toWrite[key] = value;
145
+ written.push(key);
146
+ }
147
+ if (written.length > 0) {
148
+ upsertEnvVars(envPathFor(rootDir), toWrite);
149
+ }
150
+ return { written, skipped };
151
+ }
152
+ /**
153
+ * Remove `keys` from the stack's `.env` entirely.
154
+ *
155
+ * {@link applyEnvSelection} can only set keys (and deliberately ignores blank
156
+ * values so it never clobbers a value the user set), so it cannot *clear* a key:
157
+ * writing `KEY=` would leave an empty assignment that reads back as a set-but-
158
+ * empty value. Setup steps that must genuinely drop a stale key — clearing the
159
+ * user whitelist back to "none", removing a key when switching modes — call this
160
+ * instead. A missing `.env` or absent keys are no-ops.
161
+ */
162
+ export function clearEnvKeys(rootDir, keys) {
163
+ clearEnvFileKeys(envPathFor(rootDir), keys);
164
+ }
165
+ /**
166
+ * Infer the current GitHub auth mode from the stack's .env, so the github-auth
167
+ * step can show what is already configured (and skip prompting when valid).
168
+ * Reuses the shared resolver the backend uses, so the two can't drift.
169
+ */
170
+ export function detectGithubAuthMode(rootDir) {
171
+ const env = readEnvVars(rootDir);
172
+ const truthy = /^(1|true|yes|on)$/i;
173
+ return resolveGithubAuthMode({
174
+ demoMode: truthy.test(env.PROPR_DEMO_MODE ?? ""),
175
+ ghAuthMode: env.GH_AUTH_MODE,
176
+ relayUrl: env.PROPR_GH_RELAY_URL,
177
+ relayToken: env.PROPR_GH_RELAY_TOKEN,
178
+ appId: env.GH_APP_ID,
179
+ // The CLI stack records the App key as HOST_GH_PRIVATE_KEY (the orchestrator
180
+ // bind-mounts it and sets the in-container GH_PRIVATE_KEY_PATH to that path),
181
+ // so accept either when inferring app mode — otherwise a stack configured by
182
+ // `propr setup` would resolve as "none" despite being fully set up.
183
+ privateKeyPath: env.GH_PRIVATE_KEY_PATH ?? env.HOST_GH_PRIVATE_KEY,
184
+ installationId: env.GH_INSTALLATION_ID,
185
+ });
186
+ }
187
+ /** Build the initial, all-`pending` setup state for a resolved stack root. */
188
+ export function createSetupState(rootDir) {
189
+ return {
190
+ rootDir,
191
+ steps: SETUP_STEP_DEFINITIONS.map((def) => ({ ...def, status: "pending" })),
192
+ };
193
+ }
194
+ /** Look up a step by id. */
195
+ export function getStep(state, id) {
196
+ return state.steps.find((step) => step.id === id);
197
+ }
198
+ /**
199
+ * Return a new state with `id`'s step patched. Immutable so renderers can diff
200
+ * by reference; unknown ids return the state unchanged.
201
+ */
202
+ export function updateStep(state, id, patch) {
203
+ let changed = false;
204
+ const steps = state.steps.map((step) => {
205
+ if (step.id !== id)
206
+ return step;
207
+ changed = true;
208
+ return { ...step, ...patch };
209
+ });
210
+ return changed ? { ...state, steps } : state;
211
+ }
212
+ /**
213
+ * The next step the wizard should act on: the first one still `pending`. Used
214
+ * by the sequential renderer to drive the flow and by the TUI to highlight the
215
+ * current step.
216
+ *
217
+ * A failed required step blocks everything after it (see the `failed` status in
218
+ * ./types.ts), so once one is encountered there is no next step until it is
219
+ * retried — `undefined` is returned. Failed *optional* steps don't block.
220
+ */
221
+ export function nextPendingStep(state) {
222
+ // Scan for a blocking failure first so the "a failed required step blocks
223
+ // everything after it" contract holds even if state was patched out of
224
+ // order (e.g. a later step failed before an earlier one finished).
225
+ if (state.steps.some((step) => !step.optional && step.status === "failed")) {
226
+ return undefined;
227
+ }
228
+ return state.steps.find((step) => step.status === "pending");
229
+ }
230
+ /**
231
+ * True once every required step has reached a terminal, non-failed state.
232
+ * Optional steps never block completion; a single failed required step does.
233
+ */
234
+ export function isSetupComplete(state) {
235
+ return state.steps.every((step) => {
236
+ if (step.status === "failed")
237
+ return false;
238
+ if (step.optional)
239
+ return true;
240
+ return step.status === "done" || step.status === "skipped" || step.status === "warning";
241
+ });
242
+ }
@@ -0,0 +1,85 @@
1
+ /**
2
+ * Setup wizard domain types.
3
+ *
4
+ * `propr setup` walks a new user through getting a local control-plane stack
5
+ * running end to end. The flow coordinates several existing commands
6
+ * (environment checks, stack scaffolding, image pulls, agent + GitHub
7
+ * configuration, stack startup, whitelist + repo setup, and UI launch).
8
+ *
9
+ * These types are intentionally free of any rendering concern so the same
10
+ * step/status model can drive an Ink TUI and a plain readline fallback. They
11
+ * carry no Docker, Ink, or readline imports — see ./state.ts for the pure
12
+ * helpers that compute and transition this state.
13
+ */
14
+ /**
15
+ * Canonical, ordered step definitions. All start `pending`; renderers and the
16
+ * command driver transition them via the helpers in ./state.ts.
17
+ */
18
+ export const SETUP_STEP_DEFINITIONS = [
19
+ {
20
+ id: "check",
21
+ title: "Environment checks",
22
+ description: "Verify Docker, images, and agent credentials are ready.",
23
+ optional: false,
24
+ },
25
+ {
26
+ id: "init-stack",
27
+ title: "Initialize stack",
28
+ description: "Scaffold the stack root (.env, data/, logs/, repos/).",
29
+ optional: false,
30
+ },
31
+ {
32
+ id: "pull-images",
33
+ title: "Pull images",
34
+ description: "Download the ProPR service and agent container images.",
35
+ optional: false,
36
+ },
37
+ {
38
+ id: "configure-agents",
39
+ title: "Configure agents",
40
+ description: "Record detected host agent-credential directories in .env.",
41
+ optional: false,
42
+ },
43
+ {
44
+ id: "github-auth",
45
+ title: "GitHub authentication",
46
+ description: "Choose how the backend authenticates to GitHub.",
47
+ optional: false,
48
+ },
49
+ {
50
+ id: "intake",
51
+ title: "GitHub intake",
52
+ description: "Choose how the backend ingests GitHub events (routing WebSocket, polling, or direct webhooks).",
53
+ optional: false,
54
+ },
55
+ {
56
+ id: "start-stack",
57
+ title: "Start stack",
58
+ description: "Launch the local control-plane services.",
59
+ optional: false,
60
+ },
61
+ {
62
+ id: "enable-agents",
63
+ title: "Enable agents",
64
+ description: "Enable the selected agents in the backend and authenticate through their images.",
65
+ optional: false,
66
+ },
67
+ {
68
+ id: "whitelist",
69
+ title: "Whitelist setup",
70
+ description: "Restrict which GitHub users may trigger ProPR.",
71
+ optional: false,
72
+ },
73
+ {
74
+ id: "repo",
75
+ title: "Repository setup",
76
+ description: "Optionally connect a first repository to work on.",
77
+ optional: true,
78
+ },
79
+ {
80
+ id: "launch-ui",
81
+ title: "Launch UI",
82
+ description: "Open the ProPR web UI.",
83
+ optional: true,
84
+ },
85
+ ];
@@ -0,0 +1,85 @@
1
+ /**
2
+ * `propr setup` — guided one-time setup for the local ProPR stack.
3
+ *
4
+ * The flow itself lives in the UI-agnostic engine (./setup/engine.ts). This
5
+ * command only chooses how to render it:
6
+ *
7
+ * • The full-screen Ink wizard (../tui/app.tsx) when stdin and stdout are
8
+ * interactive TTYs that support raw mode — the keyboard-driven view.
9
+ * • The sequential readline wizard (./setup/sequential.ts) otherwise: an
10
+ * explicit `--no-tui`, or an interactive terminal that can't enter raw mode
11
+ * (some SSH sessions, minimal/embedded terminals). It prompts line by line.
12
+ *
13
+ * These two are distinct from the no-stdin case. When stdin is not a TTY at all
14
+ * (piped, redirected, CI), nobody can answer a prompt, so the sequential wizard
15
+ * fails fast with actionable guidance rather than hanging — see
16
+ * {@link runSequentialSetup}.
17
+ */
18
+ import { Command } from "commander";
19
+ import { createConfigManager } from "../config/index.js";
20
+ import { runSequentialSetup, SequentialSetupUnavailableError } from "./setup/sequential.js";
21
+ /**
22
+ * Whether the full-screen Ink wizard can run. It needs interactive input *and*
23
+ * output plus raw-mode support for its keyboard handling; anything short of
24
+ * that drops to the sequential readline wizard. Kept as a pure function of the
25
+ * streams so the decision is unit-testable without a real terminal.
26
+ */
27
+ export function canRenderInkSetup(stdin = process.stdin, stdout = process.stdout) {
28
+ return Boolean(stdin.isTTY) && Boolean(stdout.isTTY) && typeof stdin.setRawMode === "function";
29
+ }
30
+ export function createSetupCommand() {
31
+ return new Command("setup")
32
+ .description("Guided one-time setup for the local ProPR stack")
33
+ .option("--root <dir>", "Stack root directory (where .env/data/logs/repos live)")
34
+ .option("--no-tui", "Skip the full-screen wizard; prompt line-by-line instead")
35
+ .option("--skip-remote-image-check", "Skip the slow registry round-trip when checking that stack images exist")
36
+ .addHelpText("after", `
37
+ Examples:
38
+ $ propr setup
39
+ $ propr setup --no-tui
40
+ $ propr setup --root ~/propr
41
+ $ propr setup --skip-remote-image-check
42
+
43
+ Setup is safe to re-run at any time: it re-discovers your environment and skips
44
+ steps that are already satisfied, so running it again only fills in what is
45
+ missing — it never undoes existing configuration.
46
+
47
+ The full-screen wizard runs in an interactive terminal. Over SSH, in shells
48
+ without raw-mode support, or with --no-tui, setup falls back to line-by-line
49
+ prompts. When stdin is not a terminal at all (piped, redirected, CI), setup
50
+ cannot prompt and exits with guidance — scaffold non-interactively instead with
51
+ \`propr init stack\`, then edit <root>/.env and run \`propr start\`.
52
+ `)
53
+ .action(async (options) => {
54
+ try {
55
+ const configManager = await createConfigManager();
56
+ const { skipRemoteImageCheck } = options;
57
+ const useInk = options.tui !== false && canRenderInkSetup();
58
+ if (useInk) {
59
+ // Loaded dynamically so the sequential path never pulls in ink/react.
60
+ const { renderSetupWizard } = await import("../tui/app.js");
61
+ const result = await renderSetupWizard({
62
+ configManager,
63
+ root: options.root,
64
+ skipRemoteImageCheck,
65
+ });
66
+ process.exit(result.completed ? 0 : 1);
67
+ }
68
+ const result = await runSequentialSetup({
69
+ configManager,
70
+ root: options.root,
71
+ skipRemoteImageCheck,
72
+ });
73
+ process.exit(result.completed ? 0 : 1);
74
+ }
75
+ catch (error) {
76
+ if (error instanceof SequentialSetupUnavailableError) {
77
+ // Already actionable guidance — print it verbatim, no "Error:" prefix.
78
+ console.error(error.message);
79
+ process.exit(1);
80
+ }
81
+ console.error(`Error during setup: ${error.message}`);
82
+ process.exit(1);
83
+ }
84
+ });
85
+ }
@@ -6,7 +6,7 @@
6
6
  import { Command } from "commander";
7
7
  import { createConfigManager } from "../config/index.js";
8
8
  import { getHostConfig } from "../orchestrator/index.js";
9
- import { renderStatusTable } from "../orchestrator/format.js";
9
+ import { renderStatusTable, renderTunnelSection } from "../orchestrator/format.js";
10
10
  import { printOutput } from "../utils/index.js";
11
11
  /** Creates the `status` command — local stack status. */
12
12
  export function createStackStatusCommand() {
@@ -30,10 +30,22 @@ Examples:
30
30
  process.exit(1);
31
31
  }
32
32
  const status = orch.getStackStatus(cfg);
33
- if (printOutput(status, options.json ?? false))
33
+ // The Cloudflare tunnel is a local managed service, so its health is part
34
+ // of local status. The reachability probe is best-effort with its own
35
+ // timeout and never throws, so it cannot fail the status command.
36
+ const tunnel = await orch.getTunnelStatus(cfg, status);
37
+ if (printOutput({ ...status, tunnel }, options.json ?? false))
34
38
  return;
35
39
  console.log("");
36
40
  console.log(renderStatusTable(status));
41
+ // The tunnel is an optional service most stacks never use. Only show the
42
+ // human-readable Tunnel section when it is configured or enabled, so the
43
+ // common case isn't cluttered with an all-"no" block. (The --json output
44
+ // above always includes the `tunnel` key for scripted consumers.)
45
+ if (tunnel.configured || tunnel.enabled) {
46
+ console.log("");
47
+ console.log(renderTunnelSection(tunnel));
48
+ }
37
49
  console.log("");
38
50
  if (!status.running) {
39
51
  console.log("Stack is not running. Start it with: propr start");
@@ -40,7 +40,13 @@ function displaySystemStatus(status) {
40
40
  "Worker",
41
41
  "Workers Active",
42
42
  "GitHub Auth",
43
+ "GitHub Auth Mode",
44
+ "GitHub Event Intake",
43
45
  "Claude Auth",
46
+ "Routing URL",
47
+ "Routing WebSocket",
48
+ "Last Delivery ID",
49
+ "Last ACK",
44
50
  "Timestamp",
45
51
  ];
46
52
  const maxLabelWidth = Math.max(...labels.map((l) => l.length));
@@ -52,16 +58,49 @@ function displaySystemStatus(status) {
52
58
  console.log(`${"Workers Active".padEnd(maxLabelWidth)} ${status.workerCount}`);
53
59
  }
54
60
  console.log(`${"GitHub Auth".padEnd(maxLabelWidth)} ${formatStatusIndicator(status.githubAuth)}`);
61
+ if (status.githubAuthMode) {
62
+ console.log(`${"GitHub Auth Mode".padEnd(maxLabelWidth)} ${status.githubAuthMode}`);
63
+ }
64
+ if (status.githubEventIntake) {
65
+ console.log(`${"GitHub Event Intake".padEnd(maxLabelWidth)} ${status.githubEventIntake}`);
66
+ }
55
67
  console.log(`${"Claude Auth".padEnd(maxLabelWidth)} ${formatStatusIndicator(status.claudeAuth)}`);
68
+ // Routing WebSocket diagnostics for default (routing_websocket) deployments.
69
+ const routingIntakeActive = status.githubEventIntake === "routing_websocket";
70
+ if (status.routing) {
71
+ const routing = status.routing;
72
+ console.log("");
73
+ console.log(`${"Routing URL".padEnd(maxLabelWidth)} ${routing.routingUrl || "(not set)"}`);
74
+ console.log(`${"Routing WebSocket".padEnd(maxLabelWidth)} ${formatStatusIndicator(routing.connected ? "connected" : "disconnected")}`);
75
+ console.log(`${"Last Delivery ID".padEnd(maxLabelWidth)} ${routing.lastDeliveryId ?? "(none yet)"}`);
76
+ console.log(`${"Last ACK".padEnd(maxLabelWidth)} ${routing.lastAckAt ? new Date(routing.lastAckAt).toLocaleString() : "(none yet)"}`);
77
+ }
78
+ else if (routingIntakeActive) {
79
+ // routing_websocket is the active intake mode but the daemon published no
80
+ // routing state — the default event path is not diagnosable (publisher down
81
+ // or daemon not running). Surface it explicitly rather than rendering nothing.
82
+ console.log("");
83
+ console.log(`${"Routing WebSocket".padEnd(maxLabelWidth)} ${formatStatusIndicator("unknown")} (no routing state published)`);
84
+ }
56
85
  console.log("");
57
86
  console.log(`${"Timestamp".padEnd(maxLabelWidth)} ${new Date(status.timestamp).toLocaleString()}`);
58
87
  console.log("");
59
88
  console.log("=".repeat(50));
89
+ // Routing health counts against overall health whenever routing_websocket is the
90
+ // active intake path: a published-but-disconnected state is unhealthy, and so is
91
+ // a *missing* state (the daemon publisher is not running), since both mean the
92
+ // default event path is not delivering. When routing is not the active mode, an
93
+ // absent routing record is expected and does not affect health.
94
+ const routingStateMissing = routingIntakeActive && !status.routing;
95
+ const routingHealthy = status.routing
96
+ ? status.routing.connected === true
97
+ : !routingIntakeActive;
60
98
  const allHealthy = status.api === "healthy" &&
61
99
  status.redis === "connected" &&
62
100
  status.daemon === "running" &&
63
101
  status.worker === "running" &&
64
- status.githubAuth === "connected";
102
+ status.githubAuth === "connected" &&
103
+ routingHealthy;
65
104
  console.log("");
66
105
  if (allHealthy) {
67
106
  console.log("All systems operational.");
@@ -78,11 +117,19 @@ function displaySystemStatus(status) {
78
117
  console.log(" - No workers active. Check worker processes.");
79
118
  }
80
119
  if (status.githubAuth !== "connected") {
81
- console.log(" - GitHub auth not configured. Check GH_APP_ID, GH_PRIVATE_KEY_PATH, and GH_INSTALLATION_ID.");
120
+ // githubAuth is derived from the resolved auth mode, so a relay deployment
121
+ // only lands here when nothing valid is configured — mention both paths.
122
+ console.log(` - GitHub auth not configured (mode: ${status.githubAuthMode ?? "unknown"}). Set GH_APP_ID, GH_PRIVATE_KEY_PATH, and GH_INSTALLATION_ID for app auth, or PROPR_GH_RELAY_URL and PROPR_GH_RELAY_TOKEN for relay auth.`);
82
123
  }
83
124
  if (status.claudeAuth !== "connected") {
84
125
  console.log(" - Claude auth status unknown or no recent activity.");
85
126
  }
127
+ if (routingStateMissing) {
128
+ console.log(" - Routing WebSocket state unavailable. routing_websocket is the active intake mode but the daemon published no routing state; ensure the daemon is running and check its logs.");
129
+ }
130
+ else if (!routingHealthy) {
131
+ console.log(" - Routing WebSocket disconnected. The daemon is not connected to the routing relay; check PROPR_ROUTING_URL / PROPR_GH_RELAY_TOKEN and the daemon logs.");
132
+ }
86
133
  }
87
134
  }
88
135
  /**