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,181 @@
1
+ /**
2
+ * GitHub event-intake + user-whitelist helpers for `propr setup`.
3
+ *
4
+ * Two concerns the setup wizard must guide a new user through, factored out of
5
+ * the engine so the decision logic lives in one tested place and both renderers
6
+ * (Ink + readline) share it:
7
+ *
8
+ * - **Intake mode** — how the backend learns about GitHub events, selected by
9
+ * the `GITHUB_EVENT_INTAKE_MODE` `.env` key (the legacy `ENABLE_GITHUB_WEBHOOKS`
10
+ * boolean is deprecated and no longer selects the mode). Three paths:
11
+ * routing_websocket — events stream over the hosted ProPR routing
12
+ * WebSocket; no inbound webhook listener and no own
13
+ * GitHub App required. The default, and only usable
14
+ * with relay auth (PROPR_GH_RELAY_TOKEN).
15
+ * polling — the daemon polls the GitHub API on an interval; works
16
+ * with any usable GitHub auth and needs no inbound URL.
17
+ * direct_webhook — GitHub posts directly to the local API; requires an
18
+ * own GitHub App plus a signing secret so forged
19
+ * payloads are rejected.
20
+ * {@link buildIntakeEnvVars} turns a chosen mode into the exact `.env` keys
21
+ * (`GITHUB_EVENT_INTAKE_MODE`, and `GH_WEBHOOK_SECRET` for direct webhooks),
22
+ * refusing to produce a direct_webhook config without a secret — the API
23
+ * would otherwise refuse to boot.
24
+ *
25
+ * - **User whitelist** — which GitHub users may trigger ProPR. Saved through
26
+ * the settings API when the backend is running (a partial update that never
27
+ * clobbers unrelated settings), and mirrored into `.env` so the value
28
+ * survives a restart. {@link saveWhitelist} owns that routing and degrades to
29
+ * an `.env`-only write when the backend is down or the API call fails.
30
+ *
31
+ * Like the rest of the setup module these helpers are UI-agnostic and free of
32
+ * Docker/network imports: side effects are passed in as callbacks so the engine
33
+ * binds them to the real API/`.env` and tests drive the whole thing in memory.
34
+ */
35
+ /** Documentation surfaced in the intake prompt's detail text. */
36
+ export const INTAKE_DOCS_URL = "https://docs.propr.dev/docs/architecture/daemon";
37
+ /** Documentation for configuring direct webhook delivery. */
38
+ export const WEBHOOK_DOCS_URL = "https://docs.propr.dev/docs/tutorials/setup-server";
39
+ /** Thrown when an intake selection is missing required input (e.g. a webhook secret). */
40
+ export class IntakeConfigError extends Error {
41
+ constructor(message) {
42
+ super(message);
43
+ this.name = "IntakeConfigError";
44
+ }
45
+ }
46
+ /**
47
+ * The intake mode to pre-select for a given GitHub auth mode. The hosted routing
48
+ * WebSocket is the product default, but it only works with relay auth (it needs
49
+ * a relay token and the shared ProPR App), so it's recommended only when relay
50
+ * auth is configured. Every other auth mode falls back to polling, which works
51
+ * with any usable GitHub auth and needs no inbound network exposure — and unlike
52
+ * direct webhooks requires no public URL or own GitHub App.
53
+ */
54
+ export function defaultIntakeMode(authMode) {
55
+ return authMode === "relay" ? "routing_websocket" : "polling";
56
+ }
57
+ /**
58
+ * The intake choice the prompt should pre-select.
59
+ *
60
+ * On a re-run where `.env` already carries an intake decision
61
+ * (`GITHUB_EVENT_INTAKE_MODE` is set), the safe default is `"keep"`: a blank Enter
62
+ * must never silently rewrite a working config — e.g. an existing
63
+ * `direct_webhook` install must not flip to `routing_websocket` just because the
64
+ * auth-derived recommendation differs. This upholds the setup engine's re-run
65
+ * safety model (keep existing config unless the user explicitly changes it). Only
66
+ * on a fresh install, with no intake config yet, do we fall back to the
67
+ * auth-derived recommendation from {@link defaultIntakeMode}.
68
+ */
69
+ export function defaultIntakeChoice(authMode, opts) {
70
+ return opts.intakeConfigured ? "keep" : defaultIntakeMode(authMode);
71
+ }
72
+ /**
73
+ * Translate a chosen {@link GithubIntakeMode} into the `.env` keys it implies.
74
+ * The mode is selected by `GITHUB_EVENT_INTAKE_MODE`, the value the backend boot
75
+ * path resolves (see resolveGithubEventIntakeMode); the deprecated
76
+ * `ENABLE_GITHUB_WEBHOOKS` boolean is intentionally never written here.
77
+ *
78
+ * - `routing_websocket` / `polling` set `GITHUB_EVENT_INTAKE_MODE` to the mode
79
+ * and nothing else — routing events arrive over the relay WebSocket and
80
+ * polling pulls them from the API, neither needing a local webhook listener.
81
+ * A previously recorded `GH_WEBHOOK_SECRET` is intentionally *not* cleared:
82
+ * `applyEnvSelection`/`upsertEnvVars` only set keys, never remove them. The
83
+ * leftover secret is inert while not in direct_webhook mode (the API never
84
+ * reads it), but callers wanting a pristine `.env` must remove it by hand.
85
+ * - `direct_webhook` records the signing secret alongside the mode. An
86
+ * empty/whitespace secret is rejected with {@link IntakeConfigError}: the API
87
+ * refuses to boot in direct_webhook mode with no secret, so writing it would
88
+ * only break startup.
89
+ */
90
+ export function buildIntakeEnvVars(mode, opts = {}) {
91
+ switch (mode) {
92
+ case "routing_websocket":
93
+ case "polling":
94
+ return { GITHUB_EVENT_INTAKE_MODE: mode };
95
+ case "direct_webhook": {
96
+ const secret = (opts.webhookSecret ?? "").trim();
97
+ if (!secret) {
98
+ throw new IntakeConfigError("A webhook secret is required for direct webhooks — the API refuses to start without one.");
99
+ }
100
+ return { GITHUB_EVENT_INTAKE_MODE: "direct_webhook", GH_WEBHOOK_SECRET: secret };
101
+ }
102
+ }
103
+ }
104
+ /** A short, human-readable label for an intake mode, shared by both renderers. */
105
+ export function intakeModeLabel(mode) {
106
+ switch (mode) {
107
+ case "routing_websocket":
108
+ return "ProPR routing WebSocket (hosted relay)";
109
+ case "polling":
110
+ return "polling (no inbound webhooks)";
111
+ case "direct_webhook":
112
+ return "direct webhooks (signing secret recorded)";
113
+ }
114
+ }
115
+ /**
116
+ * The intake modes to show for a given GitHub auth mode, in display order, each
117
+ * flagged available or not. Unavailable modes are intentionally still returned
118
+ * so the prompt can show them inactive with the reason — a new user sees the
119
+ * full set and learns why a path is closed rather than wondering where it went.
120
+ *
121
+ * The availability rules mirror {@link validateIntakeModePrerequisites} so the
122
+ * prompt and the backend boot-time check can never disagree:
123
+ * - routing_websocket needs the ProPR token relay; a custom GitHub App can't use it.
124
+ * - direct_webhook needs your own GitHub App; the ProPR relay can't deliver to it.
125
+ * - polling works with either usable auth, but is not recommended for production.
126
+ */
127
+ export function intakeModeOptions(authMode) {
128
+ const relay = authMode === "relay";
129
+ const app = authMode === "app";
130
+ return [
131
+ {
132
+ mode: "routing_websocket",
133
+ available: relay,
134
+ note: relay
135
+ ? undefined
136
+ : "needs the ProPR GitHub App (token relay); not available with a custom GitHub App",
137
+ },
138
+ {
139
+ mode: "polling",
140
+ available: relay || app,
141
+ note: relay || app
142
+ ? "not recommended for production: subject to GitHub API rate limits and delayed event detection (depends on the polling interval and the number of repos/PRs/issues)"
143
+ : "needs usable GitHub auth — configure the token relay or a custom GitHub App first",
144
+ },
145
+ {
146
+ mode: "direct_webhook",
147
+ available: app,
148
+ note: app
149
+ ? undefined
150
+ : "needs your own custom GitHub App; not available with the ProPR token relay",
151
+ },
152
+ ];
153
+ }
154
+ /**
155
+ * Persist the user whitelist, preferring the settings API when the backend is
156
+ * running so the change takes effect immediately without a restart, and always
157
+ * mirroring into `.env` so it survives one. If the API call fails we fall back
158
+ * to the `.env` write and report the error rather than abort setup.
159
+ *
160
+ * The settings-API path issues a *partial* update (only the whitelist key), so
161
+ * unrelated settings are never overwritten.
162
+ */
163
+ export async function saveWhitelist(params) {
164
+ const { users, backendRunning, saveViaSettings, saveViaEnv } = params;
165
+ if (backendRunning) {
166
+ try {
167
+ await saveViaSettings(users);
168
+ // Mirror into `.env` so the whitelist persists across `propr start`.
169
+ saveViaEnv(users);
170
+ return { target: "settings", count: users.length };
171
+ }
172
+ catch (error) {
173
+ // The backend rejected the update (or was unreachable after all) — keep
174
+ // the value in `.env` so it is not lost, and surface why.
175
+ saveViaEnv(users);
176
+ return { target: "env", count: users.length, error: error.message };
177
+ }
178
+ }
179
+ saveViaEnv(users);
180
+ return { target: "env", count: users.length };
181
+ }
@@ -0,0 +1,501 @@
1
+ /**
2
+ * Sequential (readline) fallback wizard for `propr setup`.
3
+ *
4
+ * The setup engine (./engine.ts) is UI-agnostic: it drives the flow, emits
5
+ * progress through a {@link SetupReporter}, and collects decisions through
6
+ * optional {@link SetupPrompts} hooks. The Ink TUI (../../tui/SetupApp.tsx)
7
+ * wires those seams to a full-screen React view; this module wires the exact
8
+ * same seams to plain `readline/promises` prompts and line-by-line output.
9
+ *
10
+ * It exists for every terminal where Ink can't run — no raw-mode support, SSH
11
+ * shells, CI-like environments, and an explicit `--no-tui`. The flow, the
12
+ * decision logic, and the safe-default contract are all the engine's; only the
13
+ * rendering differs, so the two paths can never drift in behaviour.
14
+ *
15
+ * Prompting requires an interactive stdin. When stdin is not a TTY there is no
16
+ * one to answer, so {@link runSequentialSetup} fails fast with actionable
17
+ * guidance instead of hanging on a prompt that can never be satisfied.
18
+ *
19
+ * The I/O is abstracted behind {@link SequentialIo} so the prompt mapping and
20
+ * reporter can be unit-tested with a scripted in-memory transcript — no TTY,
21
+ * Docker, or readline required.
22
+ */
23
+ import { createInterface } from "node:readline/promises";
24
+ import { DEFAULT_PROPR_GH_RELAY_URL } from "../../vendor/shared/index.js";
25
+ import { INTAKE_DOCS_URL, WEBHOOK_DOCS_URL, intakeModeOptions, } from "./github.js";
26
+ import { runSetup, } from "./engine.js";
27
+ const ANSI = {
28
+ reset: "",
29
+ bold: "",
30
+ dim: "",
31
+ red: "",
32
+ green: "",
33
+ yellow: "",
34
+ cyan: "",
35
+ };
36
+ function makePaint(enabled) {
37
+ return (text, ...codes) => (enabled ? `${codes.join("")}${text}${ANSI.reset}` : text);
38
+ }
39
+ /**
40
+ * A {@link SequentialIo} backed by `readline/promises` over the given streams.
41
+ * Masked questions suppress keystroke echo by intercepting readline's internal
42
+ * output writer — the prompt itself is drawn normally, only what the user types
43
+ * afterwards is hidden (the same behaviour as a password prompt).
44
+ */
45
+ export function createReadlineIo(input = process.stdin, output = process.stdout) {
46
+ const rl = createInterface({ input, output });
47
+ let muted = false;
48
+ // readline echoes input by writing through `_writeToOutput`; override it so a
49
+ // masked question can swallow keystroke echo while still emitting newlines so
50
+ // the cursor advances when the user submits. Falls back gracefully: if the
51
+ // internal hook is ever absent, input simply stays visible.
52
+ const internal = rl;
53
+ if (typeof internal._writeToOutput === "function") {
54
+ internal._writeToOutput = (chunk) => {
55
+ if (muted) {
56
+ if (chunk.includes("\n"))
57
+ output.write("\n");
58
+ return;
59
+ }
60
+ output.write(chunk);
61
+ };
62
+ }
63
+ return {
64
+ print(line = "") {
65
+ output.write(`${line}\n`);
66
+ },
67
+ async ask(question, opts) {
68
+ // Draw the prompt unmuted, then mute so only the typed answer is hidden.
69
+ const answer = rl.question(question);
70
+ muted = Boolean(opts?.mask);
71
+ try {
72
+ return await answer;
73
+ }
74
+ finally {
75
+ muted = false;
76
+ }
77
+ },
78
+ close() {
79
+ rl.close();
80
+ },
81
+ };
82
+ }
83
+ /** Print a prompt's heading: a blank line, the title, then an optional detail. */
84
+ function printHeading(io, paint, title, detail) {
85
+ io.print();
86
+ io.print(paint(title, ANSI.bold, ANSI.cyan));
87
+ if (detail)
88
+ io.print(paint(detail, ANSI.dim));
89
+ }
90
+ async function promptConfirm(io, paint, req) {
91
+ printHeading(io, paint, req.title, req.detail);
92
+ const def = req.defaultValue ?? false;
93
+ const suffix = def ? "[Y/n]" : "[y/N]";
94
+ for (;;) {
95
+ const answer = (await io.ask(` ${paint("❯", ANSI.cyan)} ${suffix} `)).trim().toLowerCase();
96
+ if (answer === "")
97
+ return def;
98
+ if (answer === "y" || answer === "yes")
99
+ return true;
100
+ if (answer === "n" || answer === "no")
101
+ return false;
102
+ io.print(paint(" Please answer y or n.", ANSI.yellow));
103
+ }
104
+ }
105
+ async function promptInput(io, paint, req) {
106
+ printHeading(io, paint, req.title, req.detail);
107
+ const def = req.defaultValue ?? "";
108
+ const shownDefault = req.mask ? "•".repeat(def.length) : def;
109
+ const hint = def ? paint(` (blank → ${shownDefault})`, ANSI.dim) : "";
110
+ const answer = await io.ask(` ${paint("❯", ANSI.cyan)}${hint} `, { mask: req.mask });
111
+ // Mirror the Ink input exactly: a blank entry falls back to the default,
112
+ // otherwise the raw text is returned for the engine hook to trim/parse.
113
+ return answer.length > 0 ? answer : def;
114
+ }
115
+ async function promptSelect(io, paint, req) {
116
+ // A single-choice prompt with no options is a caller bug; fail loudly with a
117
+ // clear message rather than crash on an out-of-range default index below.
118
+ if (req.options.length === 0) {
119
+ throw new Error(`Cannot prompt "${req.title}": no options were provided.`);
120
+ }
121
+ // Likewise, a prompt whose every option is disabled has no valid answer.
122
+ const firstEnabled = req.options.findIndex((o) => !o.disabled);
123
+ if (firstEnabled === -1) {
124
+ throw new Error(`Cannot prompt "${req.title}": every option is unavailable.`);
125
+ }
126
+ printHeading(io, paint, req.title, req.detail);
127
+ // Never pre-select a disabled option: a blank Enter must land on something the
128
+ // user can actually pick.
129
+ let defaultIndex = Math.min(Math.max(req.defaultIndex ?? 0, 0), req.options.length - 1);
130
+ if (req.options[defaultIndex].disabled)
131
+ defaultIndex = firstEnabled;
132
+ req.options.forEach((option, index) => {
133
+ const marker = index === defaultIndex ? paint("›", ANSI.cyan) : " ";
134
+ const hint = option.hint ? paint(` (${option.hint})`, ANSI.dim) : "";
135
+ const label = option.disabled ? paint(`${option.label} — unavailable`, ANSI.dim) : option.label;
136
+ io.print(` ${marker} ${index + 1}) ${label}${hint}`);
137
+ });
138
+ for (;;) {
139
+ const answer = (await io.ask(` ${paint("❯", ANSI.cyan)} choose 1-${req.options.length} (blank → ${defaultIndex + 1}) `)).trim();
140
+ if (answer === "")
141
+ return req.options[defaultIndex].value;
142
+ const n = Number(answer);
143
+ if (Number.isInteger(n) && n >= 1 && n <= req.options.length) {
144
+ const picked = req.options[n - 1];
145
+ if (picked.disabled) {
146
+ io.print(paint(` Option ${n} is unavailable: ${picked.hint ?? "not valid for the current GitHub auth mode"}.`, ANSI.yellow));
147
+ continue;
148
+ }
149
+ return picked.value;
150
+ }
151
+ io.print(paint(` Enter a number between 1 and ${req.options.length}.`, ANSI.yellow));
152
+ }
153
+ }
154
+ async function promptMultiSelect(io, paint, req) {
155
+ printHeading(io, paint, req.title, req.detail);
156
+ // Nothing to choose from: note it and return an empty set rather than pose a
157
+ // prompt whose only valid answer is "blank" with an "between 1 and 0" error.
158
+ if (req.options.length === 0) {
159
+ io.print(paint(" (no options available)", ANSI.dim));
160
+ return [];
161
+ }
162
+ const defaults = new Set(req.defaultSelected ?? []);
163
+ req.options.forEach((option, index) => {
164
+ const checked = defaults.has(option.value) ? "[x]" : "[ ]";
165
+ const hint = option.hint ? paint(` (${option.hint})`, ANSI.dim) : "";
166
+ io.print(` ${index + 1}) ${checked} ${option.label}${hint}`);
167
+ });
168
+ io.print(paint(' Enter comma-separated numbers to select, blank to keep the defaults, or "none" for an empty set.', ANSI.dim));
169
+ for (;;) {
170
+ const answer = (await io.ask(` ${paint("❯", ANSI.cyan)} numbers `)).trim();
171
+ if (answer === "")
172
+ return req.options.filter((o) => defaults.has(o.value)).map((o) => o.value);
173
+ if (answer.toLowerCase() === "none")
174
+ return [];
175
+ const nums = answer
176
+ .split(",")
177
+ .map((part) => part.trim())
178
+ .filter(Boolean)
179
+ .map(Number);
180
+ if (nums.length > 0 && nums.every((n) => Number.isInteger(n) && n >= 1 && n <= req.options.length)) {
181
+ // De-dupe (first occurrence wins) and preserve option order for a stable result.
182
+ const picked = new Set(nums.map((n) => req.options[n - 1].value));
183
+ return req.options.filter((o) => picked.has(o.value)).map((o) => o.value);
184
+ }
185
+ io.print(paint(` Enter numbers between 1 and ${req.options.length}, separated by commas.`, ANSI.yellow));
186
+ }
187
+ }
188
+ // ---------------------------------------------------------------------------
189
+ // Engine prompt hooks.
190
+ // ---------------------------------------------------------------------------
191
+ /**
192
+ * Map the engine's typed prompt hooks onto the sequential primitives. The hook
193
+ * bodies mirror {@link buildSetupPrompts} in ../../tui/SetupApp.tsx one-for-one
194
+ * so both renderers honour the same safe defaults: a blank input or a "keep"
195
+ * choice leaves existing configuration untouched.
196
+ */
197
+ export function buildSequentialPrompts(io, paint = makePaint(false)) {
198
+ return {
199
+ async resolveStackRoot({ currentRoot, init }) {
200
+ const entered = await promptInput(io, paint, {
201
+ title: "Stack root directory",
202
+ detail: init.initialized
203
+ ? `A stack already exists at ${currentRoot}.`
204
+ : `The stack will be scaffolded at ${currentRoot}.`,
205
+ defaultValue: currentRoot,
206
+ });
207
+ const rootDir = entered.trim() || currentRoot;
208
+ // Only offer a re-scaffold when the resolved root already looks complete;
209
+ // an incomplete root is scaffolded by the engine regardless.
210
+ let reinitialize = false;
211
+ if (init.initialized && rootDir === currentRoot) {
212
+ reinitialize = await promptConfirm(io, paint, {
213
+ title: "Re-scaffold the stack?",
214
+ detail: "Fill in any missing files. Your existing .env is preserved.",
215
+ defaultValue: false,
216
+ });
217
+ }
218
+ return { rootDir, reinitialize };
219
+ },
220
+ async selectAgents({ available, detected }) {
221
+ const detectedSet = new Set(detected);
222
+ return promptMultiSelect(io, paint, {
223
+ title: "Select agents to enable",
224
+ detail: "Their images are pulled and host credentials recorded in .env.",
225
+ options: available.map((type) => ({
226
+ label: type,
227
+ value: type,
228
+ hint: detectedSet.has(type) ? "detected" : undefined,
229
+ })),
230
+ defaultSelected: detected,
231
+ });
232
+ },
233
+ async configureGithubAuth({ current }) {
234
+ // Token relay (the hosted ProPR GitHub App) leads as the recommended path.
235
+ // "Keep current configuration" is offered only when there is an existing
236
+ // config to keep — on a fresh install there is nothing to preserve, so the
237
+ // relay option is the first (and default) choice.
238
+ const options = [];
239
+ if (current.mode !== "none") {
240
+ options.push({ label: "Keep current configuration", value: "keep", hint: current.mode });
241
+ }
242
+ options.push({ label: "Token relay (use the ProPR GitHub App)", value: "relay" });
243
+ options.push({ label: "Custom GitHub App (set up your own GitHub App)", value: "app" });
244
+ const choice = await promptSelect(io, paint, {
245
+ title: "GitHub authentication",
246
+ detail: `Currently detected: ${current.mode}.`,
247
+ options,
248
+ defaultIndex: 0,
249
+ });
250
+ if (choice === "keep")
251
+ return { keep: true };
252
+ // Switching to a real auth mode must explicitly turn demo mode off:
253
+ // detectGithubAuthMode reads PROPR_DEMO_MODE, so a leftover
254
+ // PROPR_DEMO_MODE=true would keep resolving as demo and ignore the App/relay
255
+ // config the user just entered.
256
+ if (choice === "relay") {
257
+ // No manual URL/token entry: the engine enrolls with the hosted relay
258
+ // using the stored `propr login` token, discovers the installation, and
259
+ // mints the token. Only the relay base URL is asked, prefilled with the
260
+ // hosted default (Enter accepts it; override for a self-hosted relay).
261
+ const relayUrl = await promptInput(io, paint, {
262
+ title: "Relay URL",
263
+ detail: "Press Enter for the hosted ProPR relay; override only for a self-hosted relay.",
264
+ defaultValue: DEFAULT_PROPR_GH_RELAY_URL,
265
+ });
266
+ return { mode: "relay", enrollRelay: { relayUrl: relayUrl.trim() || DEFAULT_PROPR_GH_RELAY_URL } };
267
+ }
268
+ const appId = await promptInput(io, paint, { title: "GitHub App ID", defaultValue: "" });
269
+ // The CLI stack bind-mounts the key from the host via HOST_GH_PRIVATE_KEY
270
+ // (NOT the in-container GH_PRIVATE_KEY_PATH, which is the launcher path) —
271
+ // so `propr start` can find it. Ask for the host path and write that key.
272
+ const privateKeyPath = await promptInput(io, paint, { title: "Host path to the App private key (.pem)", defaultValue: "" });
273
+ const installationId = await promptInput(io, paint, { title: "Installation ID", defaultValue: "" });
274
+ return {
275
+ mode: "app",
276
+ vars: { PROPR_DEMO_MODE: "false", GH_AUTH_MODE: "app", GH_APP_ID: appId, HOST_GH_PRIVATE_KEY: privateKeyPath, GH_INSTALLATION_ID: installationId },
277
+ };
278
+ },
279
+ async confirmGithubLogin({ reason }) {
280
+ return promptConfirm(io, paint, {
281
+ title: "Log in to GitHub now?",
282
+ detail: `${reason} Runs \`gh auth login\` (the GitHub CLI).`,
283
+ defaultValue: true,
284
+ });
285
+ },
286
+ async selectInstallation({ installations }) {
287
+ const choice = await promptSelect(io, paint, {
288
+ title: "Choose a GitHub App installation",
289
+ detail: "Your account can access more than one; the relay token is minted for the one you pick.",
290
+ options: installations.map((i) => ({
291
+ label: `${i.account_login} (${i.account_type})`,
292
+ value: String(i.installation_id),
293
+ hint: String(i.installation_id),
294
+ })),
295
+ defaultIndex: 0,
296
+ });
297
+ return choice;
298
+ },
299
+ async configureIntake({ authMode, defaultMode, currentMode }) {
300
+ // Only some intake modes are valid for the chosen auth mode (e.g. direct
301
+ // webhooks need an own GitHub App, the routing WebSocket needs the ProPR
302
+ // relay). Show every mode, but mark the unsupported ones inactive with the
303
+ // reason so the user understands why a path is closed.
304
+ const baseLabel = {
305
+ routing_websocket: "Routing WebSocket — hosted ProPR relay (recommended)",
306
+ polling: "Polling (no inbound webhooks)",
307
+ direct_webhook: "Direct webhooks (own GitHub App + a signing secret)",
308
+ };
309
+ const options = intakeModeOptions(authMode).map((opt) => ({
310
+ label: baseLabel[opt.mode],
311
+ value: opt.mode,
312
+ hint: opt.note,
313
+ disabled: !opt.available,
314
+ }));
315
+ options.push({ label: "Keep current", value: "keep", hint: currentMode });
316
+ let defaultIndex = Math.max(0, options.findIndex((o) => o.value === defaultMode));
317
+ // If the recommended default isn't valid for this auth mode, fall back to
318
+ // the first selectable option rather than pre-selecting a disabled one.
319
+ if (options[defaultIndex]?.disabled)
320
+ defaultIndex = options.findIndex((o) => !o.disabled);
321
+ const choice = await promptSelect(io, paint, {
322
+ title: "GitHub event intake",
323
+ detail: `How the backend receives GitHub events. Docs: ${INTAKE_DOCS_URL}`,
324
+ options,
325
+ defaultIndex,
326
+ });
327
+ if (choice === "keep")
328
+ return { keep: true };
329
+ if (choice === "direct_webhook") {
330
+ // The API refuses to boot in direct_webhook mode with no secret — keep
331
+ // asking until a non-empty secret is entered.
332
+ let secret = "";
333
+ while (secret === "") {
334
+ secret = (await promptInput(io, paint, {
335
+ title: "Webhook signing secret",
336
+ detail: `Verifies GitHub webhook signatures; forged payloads are rejected. Docs: ${WEBHOOK_DOCS_URL}`,
337
+ mask: true,
338
+ })).trim();
339
+ if (secret === "")
340
+ io.print(paint(" A webhook secret is required.", ANSI.yellow));
341
+ }
342
+ return { mode: "direct_webhook", webhookSecret: secret };
343
+ }
344
+ return { mode: choice };
345
+ },
346
+ async confirmStartStack({ rootDir, alreadyRunning }) {
347
+ // A running stack is reused without prompting — nothing to start.
348
+ if (alreadyRunning)
349
+ return true;
350
+ return promptConfirm(io, paint, {
351
+ title: "Start the stack now?",
352
+ detail: `Launch the local control-plane services in ${rootDir}.`,
353
+ defaultValue: true,
354
+ });
355
+ },
356
+ async confirmAgentLogin({ candidates }) {
357
+ return promptMultiSelect(io, paint, {
358
+ title: "Authenticate agents through their images?",
359
+ detail: 'Log in inside each agent\'s Docker image; credentials are written to the mounted host directory. Blank or "none" skips.',
360
+ options: candidates.map((type) => ({ label: type, value: type })),
361
+ defaultSelected: [],
362
+ });
363
+ },
364
+ async configureWhitelist({ current, demoMode }) {
365
+ if (demoMode)
366
+ return null;
367
+ const entered = await promptInput(io, paint, {
368
+ title: "Allowed GitHub usernames",
369
+ detail: 'Comma-separated; only these users can trigger ProPR. Blank keeps the current value, "none" clears it.',
370
+ defaultValue: current.join(", "),
371
+ });
372
+ const trimmed = entered.trim();
373
+ if (trimmed === "")
374
+ return null;
375
+ // An explicit "none" empties the whitelist — a discoverable affordance that
376
+ // mirrors agent selection, instead of needing a bare comma to clear it.
377
+ if (trimmed.toLowerCase() === "none")
378
+ return [];
379
+ return trimmed
380
+ .split(",")
381
+ .map((entry) => entry.trim())
382
+ .filter(Boolean);
383
+ },
384
+ async addRepository() {
385
+ const add = await promptConfirm(io, paint, {
386
+ title: "Connect a repository now?",
387
+ detail: "Optionally add a first repository for ProPR to monitor.",
388
+ defaultValue: false,
389
+ });
390
+ if (!add)
391
+ return null;
392
+ const fullName = (await promptInput(io, paint, { title: "Repository (owner/repo)", defaultValue: "" })).trim();
393
+ if (!fullName)
394
+ return null;
395
+ const baseBranch = (await promptInput(io, paint, { title: "Base branch (optional, blank for the default)", defaultValue: "" })).trim();
396
+ return { fullName, baseBranch: baseBranch || undefined };
397
+ },
398
+ async launchUi({ url }) {
399
+ if (!url)
400
+ return false;
401
+ return promptConfirm(io, paint, { title: "Open the ProPR web UI?", detail: url, defaultValue: false });
402
+ },
403
+ };
404
+ }
405
+ // ---------------------------------------------------------------------------
406
+ // Progress reporting.
407
+ // ---------------------------------------------------------------------------
408
+ /** Terminal glyph + color for a settled step, mirroring the Ink status row. */
409
+ const SETTLED_GLYPH = {
410
+ done: { glyph: "✓", color: ANSI.green },
411
+ skipped: { glyph: "−", color: ANSI.dim },
412
+ warning: { glyph: "!", color: ANSI.yellow },
413
+ failed: { glyph: "✗", color: ANSI.red },
414
+ };
415
+ /**
416
+ * Build the reporter that streams the engine's progress as plain lines: a
417
+ * heading when each step starts, a glyph + detail when it settles, and any log
418
+ * lines in between. `onState` is intentionally unused — replaying the whole
419
+ * step list on every transition reads as noise in a scrolling log, whereas the
420
+ * Ink view repaints it in place.
421
+ */
422
+ export function buildSequentialReporter(io, paint = makePaint(false)) {
423
+ return {
424
+ onStepStart(step) {
425
+ io.print();
426
+ const optional = step.optional ? paint(" (optional)", ANSI.dim) : "";
427
+ io.print(`${paint("▶", ANSI.cyan, ANSI.bold)} ${paint(step.title, ANSI.bold)}${optional}`);
428
+ io.print(paint(` ${step.description}`, ANSI.dim));
429
+ },
430
+ onStepSettled(step) {
431
+ if (step.status === "active" || step.status === "pending")
432
+ return;
433
+ const { glyph, color } = SETTLED_GLYPH[step.status];
434
+ io.print(` ${paint(glyph, color)} ${step.detail ?? step.status}`);
435
+ if (step.nextAction)
436
+ io.print(` ${paint("↳", ANSI.dim)} ${step.nextAction}`);
437
+ },
438
+ onLog(line) {
439
+ io.print(paint(` · ${line}`, ANSI.dim));
440
+ },
441
+ };
442
+ }
443
+ // ---------------------------------------------------------------------------
444
+ // Entry point.
445
+ // ---------------------------------------------------------------------------
446
+ /**
447
+ * Thrown when the sequential wizard cannot prompt because stdin is not an
448
+ * interactive terminal. Carries actionable guidance in its message so the
449
+ * command layer can print it verbatim and exit non-zero.
450
+ */
451
+ export class SequentialSetupUnavailableError extends Error {
452
+ constructor(message) {
453
+ super(message);
454
+ this.name = "SequentialSetupUnavailableError";
455
+ }
456
+ }
457
+ /**
458
+ * Run `propr setup` through the readline fallback wizard, reusing the shared
459
+ * engine. Fails fast (without prompting) when stdin is not interactive.
460
+ *
461
+ * @throws {SequentialSetupUnavailableError} when stdin is not a TTY and no I/O
462
+ * seam was injected — there is no one to answer the prompts.
463
+ */
464
+ export async function runSequentialSetup(options = {}) {
465
+ const { io: injectedIo, input = process.stdin, output = process.stdout, color, ...setupOptions } = options;
466
+ // No interactive stdin means no one can answer the prompts; fail with guidance
467
+ // rather than hang. Skipped when a test injects its own scripted I/O.
468
+ if (!injectedIo && !input.isTTY) {
469
+ throw new SequentialSetupUnavailableError([
470
+ "`propr setup` needs an interactive terminal to ask you questions, but stdin is not a TTY",
471
+ "(it looks piped, redirected, or running under CI).",
472
+ "",
473
+ "Run `propr setup` directly in an interactive shell, or configure the stack without prompts:",
474
+ " • `propr init stack`, then edit <root>/.env by hand and run `propr start`",
475
+ " • or pre-set the values in <root>/.env before re-running setup",
476
+ ].join("\n"));
477
+ }
478
+ const colorEnabled = color ?? (Boolean(output.isTTY) && process.env.NO_COLOR === undefined);
479
+ const paint = makePaint(colorEnabled);
480
+ const io = injectedIo ?? createReadlineIo(input, output);
481
+ io.print(paint("ProPR setup", ANSI.bold));
482
+ io.print(paint("Running the sequential wizard (no interactive TUI).", ANSI.dim));
483
+ try {
484
+ const result = await runSetup({
485
+ ...setupOptions,
486
+ prompts: buildSequentialPrompts(io, paint),
487
+ reporter: buildSequentialReporter(io, paint),
488
+ });
489
+ io.print();
490
+ if (result.completed) {
491
+ io.print(paint("✓ Setup complete.", ANSI.green, ANSI.bold));
492
+ }
493
+ else {
494
+ io.print(paint("✗ Setup did not finish — see the failed step above and re-run `propr setup`.", ANSI.red, ANSI.bold));
495
+ }
496
+ return result;
497
+ }
498
+ finally {
499
+ io.close();
500
+ }
501
+ }