propr-cli 0.8.3 → 0.8.4

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 (38) hide show
  1. package/README.md +4 -4
  2. package/dist/api/relay.js +10 -0
  3. package/dist/assets/env.example.txt +93 -57
  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 +2 -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/systemCommands.js +49 -2
  20. package/dist/index.js +13 -45
  21. package/dist/orchestrator/manifest.json +10 -10
  22. package/dist/orchestrator/orchestrator.mjs +513 -61
  23. package/dist/tui/AgentTableApp.js +86 -0
  24. package/dist/tui/CheckApp.js +202 -0
  25. package/dist/tui/SetupApp.js +586 -0
  26. package/dist/tui/SetupApp.test.js +172 -0
  27. package/dist/tui/app.js +84 -0
  28. package/dist/tui/render.js +11 -0
  29. package/dist/utils/envFile.js +45 -0
  30. package/dist/vendor/shared/githubEventIntakeMode.js +41 -0
  31. package/dist/vendor/shared/index.js +16 -0
  32. package/dist/vendor/shared/intakeModePrerequisites.js +76 -0
  33. package/dist/vendor/shared/modelDefinitions.js +4 -4
  34. package/dist/vendor/shared/proprServiceUrls.js +27 -0
  35. package/dist/vendor/shared/statusKeys.js +14 -0
  36. package/dist/vendor/shared/validateRoutingUrl.js +46 -0
  37. package/package.json +2 -2
  38. package/dist/assets/.env.example +0 -183
@@ -0,0 +1,586 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ /**
3
+ * Interactive Ink view for `propr setup`.
4
+ *
5
+ * The setup engine (../commands/setup/engine.ts) is UI-agnostic: it emits state
6
+ * through a {@link SetupReporter} and collects user decisions through optional
7
+ * {@link SetupPrompts} hooks. This module bridges both seams to Ink.
8
+ *
9
+ * - {@link SetupBridge} is the pub/sub bridge. The engine's reporter pushes
10
+ * state/log events into it; its prompt primitives (confirm / input / single
11
+ * choice / multi choice) let the engine's prompt hooks request a decision and
12
+ * await the user's answer. Only one prompt is ever in flight because the
13
+ * engine runs steps sequentially.
14
+ * - {@link SetupApp} subscribes to the bridge, renders every step with its
15
+ * status/title/detail, streams recent log lines, and renders the active
16
+ * prompt with keyboard navigation (choices) or text entry (inputs).
17
+ * - {@link buildSetupPrompts} maps the engine's typed prompt hooks onto the
18
+ * bridge primitives.
19
+ *
20
+ * Ctrl-C cancels: it rejects any in-flight prompt (so the engine unwinds and
21
+ * `runSetup` resolves) and exits the Ink app so no session is left running.
22
+ */
23
+ import { useEffect, useReducer, useState } from "react";
24
+ import { Box, Text, useApp, useInput } from "ink";
25
+ import { DEFAULT_PROPR_GH_RELAY_URL } from "../vendor/shared/index.js";
26
+ import { INTAKE_DOCS_URL, WEBHOOK_DOCS_URL, intakeModeOptions, } from "../commands/setup/github.js";
27
+ const SPINNER = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
28
+ const MAX_LOG_LINES = 8;
29
+ /** Thrown into a pending prompt when the user cancels with Ctrl-C. */
30
+ export class SetupCancelledError extends Error {
31
+ constructor() {
32
+ super("setup cancelled");
33
+ this.name = "SetupCancelledError";
34
+ }
35
+ }
36
+ /**
37
+ * Pub/sub bridge between the async setup engine and the React tree, plus the
38
+ * prompt primitives the engine's hooks call to collect a decision and wait for
39
+ * the user. The same history-replay pattern as CheckHub guarantees that events
40
+ * emitted before the component subscribes are still delivered exactly once.
41
+ */
42
+ export class SetupBridge {
43
+ listeners = new Set();
44
+ history = [];
45
+ nextId = 1;
46
+ resolvers = new Map();
47
+ rejecters = new Map();
48
+ cancelled = false;
49
+ push(event) {
50
+ this.history.push(event);
51
+ for (const listener of [...this.listeners])
52
+ listener(event);
53
+ }
54
+ subscribe(listener) {
55
+ this.listeners.add(listener);
56
+ for (const event of this.history)
57
+ listener(event);
58
+ return () => {
59
+ this.listeners.delete(listener);
60
+ };
61
+ }
62
+ // --- engine → UI -------------------------------------------------------
63
+ emitState(state) {
64
+ this.push({ type: "state", state });
65
+ }
66
+ emitLog(line) {
67
+ this.push({ type: "log", line });
68
+ }
69
+ /** Reflect the final state and tell the view the engine is finished. */
70
+ finish(state) {
71
+ this.push({ type: "state", state });
72
+ this.push({ type: "done" });
73
+ }
74
+ // --- engine prompt primitives (return a promise the UI resolves) -------
75
+ request(make) {
76
+ // After cancellation every further prompt rejects immediately so the engine
77
+ // unwinds instead of blocking on a view that is already gone.
78
+ if (this.cancelled)
79
+ return Promise.reject(new SetupCancelledError());
80
+ const id = this.nextId++;
81
+ return new Promise((resolve, reject) => {
82
+ this.resolvers.set(id, resolve);
83
+ this.rejecters.set(id, reject);
84
+ this.push({ type: "prompt", prompt: make(id) });
85
+ });
86
+ }
87
+ confirm(req) {
88
+ return this.request((id) => ({
89
+ id,
90
+ kind: "confirm",
91
+ title: req.title,
92
+ detail: req.detail,
93
+ defaultValue: req.defaultValue ?? false,
94
+ }));
95
+ }
96
+ input(req) {
97
+ return this.request((id) => ({
98
+ id,
99
+ kind: "input",
100
+ title: req.title,
101
+ detail: req.detail,
102
+ defaultValue: req.defaultValue ?? "",
103
+ placeholder: req.placeholder,
104
+ mask: req.mask,
105
+ }));
106
+ }
107
+ select(req) {
108
+ return this.request((id) => ({
109
+ id,
110
+ kind: "select",
111
+ title: req.title,
112
+ detail: req.detail,
113
+ options: req.options,
114
+ defaultIndex: req.defaultIndex ?? 0,
115
+ }));
116
+ }
117
+ multiSelect(req) {
118
+ return this.request((id) => ({
119
+ id,
120
+ kind: "multi",
121
+ title: req.title,
122
+ detail: req.detail,
123
+ options: req.options,
124
+ defaultSelected: req.defaultSelected ?? [],
125
+ }));
126
+ }
127
+ // --- UI → engine -------------------------------------------------------
128
+ /** Resolve the in-flight prompt with the user's answer. */
129
+ resolve(id, value) {
130
+ const resolve = this.resolvers.get(id);
131
+ if (!resolve)
132
+ return;
133
+ this.resolvers.delete(id);
134
+ this.rejecters.delete(id);
135
+ this.push({ type: "prompt-done", id });
136
+ resolve(value);
137
+ }
138
+ /** Reject any in-flight prompt; further prompts reject immediately. */
139
+ cancel() {
140
+ this.cancelled = true;
141
+ const pending = [...this.rejecters.entries()];
142
+ this.resolvers.clear();
143
+ this.rejecters.clear();
144
+ for (const [, reject] of pending)
145
+ reject(new SetupCancelledError());
146
+ }
147
+ }
148
+ /**
149
+ * Map the engine's typed prompt hooks onto the bridge primitives. Every hook
150
+ * keeps the engine's safe-default contract: a blank input or a "keep" choice
151
+ * leaves existing configuration untouched.
152
+ */
153
+ export function buildSetupPrompts(bridge) {
154
+ return {
155
+ async resolveStackRoot({ currentRoot, init }) {
156
+ const entered = await bridge.input({
157
+ title: "Stack root directory",
158
+ detail: init.initialized
159
+ ? `A stack already exists at ${currentRoot}.`
160
+ : `The stack will be scaffolded at ${currentRoot}.`,
161
+ defaultValue: currentRoot,
162
+ });
163
+ const rootDir = entered.trim() || currentRoot;
164
+ // Only offer a re-scaffold when the resolved root already looks complete;
165
+ // an incomplete root is scaffolded by the engine regardless.
166
+ let reinitialize = false;
167
+ if (init.initialized && rootDir === currentRoot) {
168
+ reinitialize = await bridge.confirm({
169
+ title: "Re-scaffold the stack?",
170
+ detail: "Fill in any missing files. Your existing .env is preserved.",
171
+ defaultValue: false,
172
+ });
173
+ }
174
+ return { rootDir, reinitialize };
175
+ },
176
+ async selectAgents({ available, detected }) {
177
+ const detectedSet = new Set(detected);
178
+ return bridge.multiSelect({
179
+ title: "Select agents to enable",
180
+ detail: "Their images are pulled and host credentials recorded in .env.",
181
+ options: available.map((type) => ({
182
+ label: type,
183
+ value: type,
184
+ hint: detectedSet.has(type) ? "detected" : undefined,
185
+ })),
186
+ defaultSelected: detected,
187
+ });
188
+ },
189
+ async configureGithubAuth({ current }) {
190
+ // Token relay (the hosted ProPR GitHub App) leads as the recommended path.
191
+ // "Keep current configuration" is offered only when there is an existing
192
+ // config to keep — on a fresh install there is nothing to preserve, so the
193
+ // relay option is the first (and default) choice.
194
+ const options = [];
195
+ if (current.mode !== "none") {
196
+ options.push({ label: "Keep current configuration", value: "keep", hint: current.mode });
197
+ }
198
+ options.push({ label: "Token relay (use the ProPR GitHub App)", value: "relay" });
199
+ options.push({ label: "Custom GitHub App (set up your own GitHub App)", value: "app" });
200
+ const choice = await bridge.select({
201
+ title: "GitHub authentication",
202
+ detail: `Currently detected: ${current.mode}.`,
203
+ options,
204
+ defaultIndex: 0,
205
+ });
206
+ if (choice === "keep")
207
+ return { keep: true };
208
+ // Switching to a real auth mode must explicitly turn demo mode off:
209
+ // detectGithubAuthMode reads PROPR_DEMO_MODE, so a leftover
210
+ // PROPR_DEMO_MODE=true would keep resolving as demo and ignore the App/relay
211
+ // config the user just entered.
212
+ if (choice === "relay") {
213
+ // No manual URL/token entry: the engine enrolls with the hosted relay
214
+ // using the stored `propr login` token, discovers the installation, and
215
+ // mints the token. Only the relay base URL is asked, prefilled with the
216
+ // hosted default (Enter accepts it; override for a self-hosted relay).
217
+ const relayUrl = await bridge.input({
218
+ title: "Relay URL",
219
+ detail: "Press Enter for the hosted ProPR relay; override only for a self-hosted relay.",
220
+ defaultValue: DEFAULT_PROPR_GH_RELAY_URL,
221
+ });
222
+ return { mode: "relay", enrollRelay: { relayUrl: relayUrl.trim() || DEFAULT_PROPR_GH_RELAY_URL } };
223
+ }
224
+ const appId = await bridge.input({ title: "GitHub App ID", defaultValue: "" });
225
+ // The CLI stack bind-mounts the key from the host via HOST_GH_PRIVATE_KEY
226
+ // (NOT the in-container GH_PRIVATE_KEY_PATH, which is the launcher path) —
227
+ // so `propr start` can find it. Ask for the host path and write that key.
228
+ const privateKeyPath = await bridge.input({ title: "Host path to the App private key (.pem)", defaultValue: "" });
229
+ const installationId = await bridge.input({ title: "Installation ID", defaultValue: "" });
230
+ return {
231
+ mode: "app",
232
+ vars: { PROPR_DEMO_MODE: "false", GH_AUTH_MODE: "app", GH_APP_ID: appId, HOST_GH_PRIVATE_KEY: privateKeyPath, GH_INSTALLATION_ID: installationId },
233
+ };
234
+ },
235
+ // Note: confirmGithubLogin is intentionally not implemented here. The
236
+ // interactive `gh auth login` would have to take over the terminal mid-render,
237
+ // which the full-screen Ink wizard can't do cleanly — so relay enrollment
238
+ // without a stored token surfaces "run `propr login`" guidance instead
239
+ // (see enrollRelayForSetup in engine.ts).
240
+ async selectInstallation({ installations }) {
241
+ return bridge.select({
242
+ title: "Choose a GitHub App installation",
243
+ detail: "Your account can access more than one; the relay token is minted for the one you pick.",
244
+ options: installations.map((i) => ({
245
+ label: `${i.account_login} (${i.account_type})`,
246
+ value: String(i.installation_id),
247
+ hint: String(i.installation_id),
248
+ })),
249
+ defaultIndex: 0,
250
+ });
251
+ },
252
+ async configureIntake({ authMode, defaultMode, currentMode }) {
253
+ // Only some intake modes are valid for the chosen auth mode (e.g. direct
254
+ // webhooks need an own GitHub App, the routing WebSocket needs the ProPR
255
+ // relay). Show every mode, but mark the unsupported ones inactive with the
256
+ // reason so the user understands why a path is closed.
257
+ const baseLabel = {
258
+ routing_websocket: "Routing WebSocket — hosted ProPR relay (recommended)",
259
+ polling: "Polling (no inbound webhooks)",
260
+ direct_webhook: "Direct webhooks (own GitHub App + a signing secret)",
261
+ };
262
+ const options = intakeModeOptions(authMode).map((opt) => ({
263
+ label: baseLabel[opt.mode],
264
+ value: opt.mode,
265
+ hint: opt.note,
266
+ disabled: !opt.available,
267
+ }));
268
+ options.push({ label: "Keep current", value: "keep", hint: currentMode });
269
+ let defaultIndex = Math.max(0, options.findIndex((o) => o.value === defaultMode));
270
+ // If the recommended default isn't valid for this auth mode, fall back to
271
+ // the first selectable option rather than pre-selecting a disabled one.
272
+ if (options[defaultIndex]?.disabled)
273
+ defaultIndex = options.findIndex((o) => !o.disabled);
274
+ const choice = await bridge.select({
275
+ title: "GitHub event intake",
276
+ detail: `How the backend receives GitHub events. Docs: ${INTAKE_DOCS_URL}`,
277
+ options,
278
+ defaultIndex,
279
+ });
280
+ if (choice === "keep")
281
+ return { keep: true };
282
+ if (choice === "direct_webhook") {
283
+ // The API refuses to boot in direct_webhook mode with no secret — keep
284
+ // asking until a non-empty secret is entered.
285
+ let secret = "";
286
+ while (secret === "") {
287
+ secret = (await bridge.input({
288
+ title: "Webhook signing secret",
289
+ detail: `Verifies GitHub webhook signatures; forged payloads are rejected. Docs: ${WEBHOOK_DOCS_URL}`,
290
+ mask: true,
291
+ })).trim();
292
+ }
293
+ return { mode: "direct_webhook", webhookSecret: secret };
294
+ }
295
+ return { mode: choice };
296
+ },
297
+ async confirmStartStack({ rootDir, alreadyRunning }) {
298
+ // A running stack is reused without prompting — nothing to start.
299
+ if (alreadyRunning)
300
+ return true;
301
+ return bridge.confirm({
302
+ title: "Start the stack now?",
303
+ detail: `Launch the local control-plane services in ${rootDir}.`,
304
+ defaultValue: true,
305
+ });
306
+ },
307
+ async confirmAgentLogin({ candidates }) {
308
+ return bridge.multiSelect({
309
+ title: "Authenticate agents through their images?",
310
+ detail: "Log in inside each agent's Docker image; credentials are written to the mounted host directory. Leave empty to skip.",
311
+ options: candidates.map((type) => ({ label: type, value: type })),
312
+ defaultSelected: [],
313
+ });
314
+ },
315
+ async configureWhitelist({ current, demoMode }) {
316
+ if (demoMode)
317
+ return null;
318
+ const entered = await bridge.input({
319
+ title: "Allowed GitHub usernames",
320
+ detail: 'Comma-separated; only these users can trigger ProPR. Blank keeps the current value, "none" clears it.',
321
+ defaultValue: current.join(", "),
322
+ });
323
+ const trimmed = entered.trim();
324
+ if (trimmed === "")
325
+ return null;
326
+ // An explicit "none" empties the whitelist, mirroring the sequential
327
+ // renderer — without this it would be parsed as a literal username "none".
328
+ if (trimmed.toLowerCase() === "none")
329
+ return [];
330
+ return trimmed
331
+ .split(",")
332
+ .map((entry) => entry.trim())
333
+ .filter(Boolean);
334
+ },
335
+ async addRepository() {
336
+ const add = await bridge.confirm({
337
+ title: "Connect a repository now?",
338
+ detail: "Optionally add a first repository for ProPR to monitor.",
339
+ defaultValue: false,
340
+ });
341
+ if (!add)
342
+ return null;
343
+ const fullName = (await bridge.input({ title: "Repository (owner/repo)", defaultValue: "" })).trim();
344
+ if (!fullName)
345
+ return null;
346
+ const baseBranch = (await bridge.input({ title: "Base branch (optional, blank for the default)", defaultValue: "" })).trim();
347
+ return { fullName, baseBranch: baseBranch || undefined };
348
+ },
349
+ async launchUi({ url }) {
350
+ if (!url)
351
+ return false;
352
+ return bridge.confirm({ title: "Open the ProPR web UI?", detail: url, defaultValue: false });
353
+ },
354
+ };
355
+ }
356
+ // ---------------------------------------------------------------------------
357
+ // Rendering
358
+ // ---------------------------------------------------------------------------
359
+ const STATUS_GLYPH = {
360
+ pending: "○",
361
+ done: "✓",
362
+ skipped: "−",
363
+ warning: "!",
364
+ failed: "✗",
365
+ };
366
+ function statusColor(status) {
367
+ switch (status) {
368
+ case "done":
369
+ return "green";
370
+ case "warning":
371
+ return "yellow";
372
+ case "failed":
373
+ return "red";
374
+ case "active":
375
+ return "cyan";
376
+ default:
377
+ return "gray";
378
+ }
379
+ }
380
+ function StepGlyph({ status, frame }) {
381
+ if (status === "active") {
382
+ return _jsx(Text, { color: "cyan", children: SPINNER[frame % SPINNER.length] });
383
+ }
384
+ return _jsx(Text, { color: statusColor(status), children: STATUS_GLYPH[status] });
385
+ }
386
+ function StepRow({ step, frame }) {
387
+ const active = step.status === "active";
388
+ return (_jsxs(Box, { flexDirection: "column", children: [_jsxs(Box, { children: [_jsx(Box, { flexShrink: 0, marginRight: 1, children: _jsx(StepGlyph, { status: step.status, frame: frame }) }), _jsx(Text, { bold: active, color: active ? "cyan" : undefined, children: step.title }), step.optional ? _jsx(Text, { dimColor: true, children: " (optional)" }) : null] }), step.detail ? (_jsx(Box, { paddingLeft: 2, children: _jsx(Text, { color: statusColor(step.status), dimColor: step.status !== "failed" && step.status !== "warning", children: step.detail }) })) : null, step.nextAction ? (_jsx(Box, { paddingLeft: 2, children: _jsxs(Text, { dimColor: true, children: ["\u21B3 ", step.nextAction] }) })) : null] }));
389
+ }
390
+ function PromptHeader({ prompt }) {
391
+ return (_jsxs(Box, { flexDirection: "column", children: [_jsx(Text, { color: "cyan", bold: true, children: prompt.title }), prompt.detail ? _jsx(Text, { dimColor: true, children: prompt.detail }) : null] }));
392
+ }
393
+ function PromptView(props) {
394
+ const { prompt } = props;
395
+ if (prompt.kind === "confirm") {
396
+ const yes = props.highlighted === 0;
397
+ return (_jsxs(Box, { flexDirection: "column", marginTop: 1, children: [_jsx(PromptHeader, { prompt: prompt }), _jsxs(Box, { marginTop: 1, children: [_jsxs(Text, { color: yes ? "cyan" : undefined, bold: yes, children: [yes ? "❯ " : " ", "Yes"] }), _jsx(Text, { children: " " }), _jsxs(Text, { color: !yes ? "cyan" : undefined, bold: !yes, children: [!yes ? "❯ " : " ", "No"] })] }), _jsx(Box, { marginTop: 1, children: _jsx(Text, { dimColor: true, children: "\u2190/\u2192 or y/n select \u00B7 enter confirm \u00B7 Ctrl-C cancel" }) })] }));
398
+ }
399
+ if (prompt.kind === "input") {
400
+ const shown = prompt.mask ? "•".repeat(props.text.length) : props.text;
401
+ const before = shown.slice(0, props.cursor);
402
+ const at = shown.slice(props.cursor, props.cursor + 1) || " ";
403
+ const after = shown.slice(props.cursor + 1);
404
+ const empty = props.text.length === 0;
405
+ return (_jsxs(Box, { flexDirection: "column", marginTop: 1, children: [_jsx(PromptHeader, { prompt: prompt }), _jsxs(Box, { marginTop: 1, children: [_jsx(Text, { children: "\u276F " }), empty && prompt.placeholder ? (_jsx(Text, { dimColor: true, children: prompt.placeholder })) : (_jsxs(Text, { children: [before, _jsx(Text, { inverse: true, children: at }), after] }))] }), _jsx(Box, { marginTop: 1, children: _jsxs(Text, { dimColor: true, children: ["enter submit", prompt.defaultValue ? ` (blank → ${prompt.mask ? "•".repeat(prompt.defaultValue.length) : prompt.defaultValue})` : "", " \u00B7 Ctrl-C cancel"] }) })] }));
406
+ }
407
+ if (prompt.kind === "select") {
408
+ return (_jsxs(Box, { flexDirection: "column", marginTop: 1, children: [_jsx(PromptHeader, { prompt: prompt }), _jsx(Box, { flexDirection: "column", marginTop: 1, children: prompt.options.map((option, index) => {
409
+ const active = index === props.highlighted;
410
+ const disabled = option.disabled ?? false;
411
+ return (_jsxs(Box, { children: [_jsxs(Text, { color: active ? "cyan" : undefined, bold: active, dimColor: disabled && !active, children: [active ? "❯ " : " ", option.label, disabled ? " — unavailable" : ""] }), option.hint ? _jsxs(Text, { dimColor: true, children: [" (", option.hint, ")"] }) : null] }, option.value));
412
+ }) }), _jsx(Box, { marginTop: 1, children: _jsx(Text, { dimColor: true, children: "\u2191/\u2193 select \u00B7 enter choose \u00B7 Ctrl-C cancel" }) })] }));
413
+ }
414
+ // multi
415
+ return (_jsxs(Box, { flexDirection: "column", marginTop: 1, children: [_jsx(PromptHeader, { prompt: prompt }), _jsx(Box, { flexDirection: "column", marginTop: 1, children: prompt.options.map((option, index) => {
416
+ const active = index === props.highlighted;
417
+ const checked = props.selected.has(option.value);
418
+ return (_jsxs(Box, { children: [_jsxs(Text, { color: active ? "cyan" : undefined, bold: active, children: [active ? "❯ " : " ", checked ? "[x] " : "[ ] ", option.label] }), option.hint ? _jsxs(Text, { dimColor: true, children: [" (", option.hint, ")"] }) : null] }, option.value));
419
+ }) }), _jsx(Box, { marginTop: 1, children: _jsx(Text, { dimColor: true, children: "\u2191/\u2193 move \u00B7 space toggle \u00B7 enter confirm \u00B7 Ctrl-C cancel" }) })] }));
420
+ }
421
+ function uiReducer(state, event) {
422
+ switch (event.type) {
423
+ case "state":
424
+ return { ...state, setup: event.state };
425
+ case "log":
426
+ return { ...state, logs: [...state.logs, event.line].slice(-MAX_LOG_LINES) };
427
+ case "prompt":
428
+ return { ...state, prompt: event.prompt };
429
+ case "prompt-done":
430
+ return state.prompt && state.prompt.id === event.id ? { ...state, prompt: null } : state;
431
+ case "done":
432
+ return { ...state, done: true, prompt: null };
433
+ default:
434
+ return state;
435
+ }
436
+ }
437
+ export function SetupApp({ bridge, onCancel }) {
438
+ const { exit } = useApp();
439
+ const [ui, dispatch] = useReducer(uiReducer, { setup: null, logs: [], prompt: null, done: false });
440
+ const [frame, setFrame] = useState(0);
441
+ // Per-prompt local input state, reset whenever a new prompt arrives.
442
+ const [text, setText] = useState("");
443
+ const [cursor, setCursor] = useState(0);
444
+ const [highlighted, setHighlighted] = useState(0);
445
+ const [selected, setSelected] = useState(() => new Set());
446
+ useEffect(() => bridge.subscribe(dispatch), [bridge]);
447
+ const prompt = ui.prompt;
448
+ const promptId = prompt?.id;
449
+ useEffect(() => {
450
+ if (!prompt)
451
+ return;
452
+ if (prompt.kind === "input") {
453
+ setText("");
454
+ setCursor(0);
455
+ }
456
+ else if (prompt.kind === "confirm") {
457
+ setHighlighted(prompt.defaultValue ? 0 : 1);
458
+ }
459
+ else if (prompt.kind === "select") {
460
+ let idx = Math.min(Math.max(prompt.defaultIndex, 0), prompt.options.length - 1);
461
+ // Never start the highlight on a disabled option.
462
+ if (prompt.options[idx]?.disabled) {
463
+ const firstEnabled = prompt.options.findIndex((o) => !o.disabled);
464
+ if (firstEnabled !== -1)
465
+ idx = firstEnabled;
466
+ }
467
+ setHighlighted(idx);
468
+ }
469
+ else {
470
+ setHighlighted(0);
471
+ setSelected(new Set(prompt.defaultSelected));
472
+ }
473
+ // Keyed on the prompt id so each distinct request reinitializes cleanly.
474
+ }, [promptId]); // eslint-disable-line react-hooks/exhaustive-deps
475
+ // Animate the spinner while the engine is still working.
476
+ useEffect(() => {
477
+ if (ui.done)
478
+ return;
479
+ const timer = setInterval(() => setFrame((f) => f + 1), 80);
480
+ return () => clearInterval(timer);
481
+ }, [ui.done]);
482
+ // Let the final frame paint, then unmount so renderSetupWizard resolves.
483
+ useEffect(() => {
484
+ if (!ui.done)
485
+ return;
486
+ const timer = setTimeout(() => exit(), 40);
487
+ return () => clearTimeout(timer);
488
+ }, [ui.done, exit]);
489
+ useInput((input, key) => {
490
+ if (key.ctrl && input === "c") {
491
+ onCancel?.();
492
+ bridge.cancel();
493
+ exit();
494
+ return;
495
+ }
496
+ if (!prompt)
497
+ return;
498
+ if (prompt.kind === "confirm") {
499
+ if (key.leftArrow || key.rightArrow)
500
+ setHighlighted((h) => (h === 0 ? 1 : 0));
501
+ else if (input.toLowerCase() === "y")
502
+ bridge.resolve(prompt.id, true);
503
+ else if (input.toLowerCase() === "n")
504
+ bridge.resolve(prompt.id, false);
505
+ else if (key.return)
506
+ bridge.resolve(prompt.id, highlighted === 0);
507
+ return;
508
+ }
509
+ if (prompt.kind === "input") {
510
+ if (key.return) {
511
+ const value = text.length > 0 ? text : prompt.defaultValue;
512
+ bridge.resolve(prompt.id, value);
513
+ }
514
+ else if (key.leftArrow) {
515
+ setCursor((c) => Math.max(0, c - 1));
516
+ }
517
+ else if (key.rightArrow) {
518
+ setCursor((c) => Math.min(text.length, c + 1));
519
+ }
520
+ else if (key.backspace || key.delete) {
521
+ if (cursor > 0) {
522
+ setText((t) => t.slice(0, cursor - 1) + t.slice(cursor));
523
+ setCursor((c) => Math.max(0, c - 1));
524
+ }
525
+ }
526
+ else if (input && !key.ctrl && !key.meta && !key.upArrow && !key.downArrow) {
527
+ setText((t) => t.slice(0, cursor) + input + t.slice(cursor));
528
+ setCursor((c) => c + input.length);
529
+ }
530
+ return;
531
+ }
532
+ if (prompt.kind === "select") {
533
+ const count = prompt.options.length;
534
+ // Step over disabled options so the highlight only ever rests on a choice
535
+ // the user can actually pick.
536
+ const step = (from, dir) => {
537
+ for (let k = 1; k <= count; k++) {
538
+ const i = (from + dir * k + count * k) % count;
539
+ if (!prompt.options[i].disabled)
540
+ return i;
541
+ }
542
+ return from;
543
+ };
544
+ if (key.upArrow)
545
+ setHighlighted((h) => step(h, -1));
546
+ else if (key.downArrow)
547
+ setHighlighted((h) => step(h, 1));
548
+ else if (key.return && !prompt.options[highlighted].disabled) {
549
+ bridge.resolve(prompt.id, prompt.options[highlighted].value);
550
+ }
551
+ return;
552
+ }
553
+ // multi
554
+ const count = prompt.options.length;
555
+ // With no options there is nothing to highlight or toggle — mirror the
556
+ // sequential wizard, which skips the prompt entirely and yields []. Only Enter
557
+ // is meaningful (it resolves to the empty set); arrow/space are ignored so we
558
+ // never index an empty array or compute a NaN highlight (`h % 0`).
559
+ if (count === 0) {
560
+ if (key.return)
561
+ bridge.resolve(prompt.id, []);
562
+ return;
563
+ }
564
+ if (key.upArrow)
565
+ setHighlighted((h) => (h - 1 + count) % count);
566
+ else if (key.downArrow)
567
+ setHighlighted((h) => (h + 1) % count);
568
+ else if (input === " ") {
569
+ const value = prompt.options[highlighted].value;
570
+ setSelected((prev) => {
571
+ const next = new Set(prev);
572
+ if (next.has(value))
573
+ next.delete(value);
574
+ else
575
+ next.add(value);
576
+ return next;
577
+ });
578
+ }
579
+ else if (key.return) {
580
+ const chosen = prompt.options.filter((option) => selected.has(option.value)).map((option) => option.value);
581
+ bridge.resolve(prompt.id, chosen);
582
+ }
583
+ });
584
+ const setup = ui.setup;
585
+ return (_jsxs(Box, { flexDirection: "column", children: [_jsxs(Box, { children: [_jsx(Text, { bold: true, children: "ProPR setup" }), setup ? _jsxs(Text, { dimColor: true, children: [" (stack root: ", setup.rootDir, ")"] }) : null] }), _jsx(Box, { flexDirection: "column", marginTop: 1, children: setup ? (setup.steps.map((step) => _jsx(StepRow, { step: step, frame: frame }, step.id))) : (_jsxs(Text, { dimColor: true, children: [SPINNER[frame % SPINNER.length], " preparing\u2026"] })) }), ui.logs.length > 0 && !ui.done ? (_jsx(Box, { flexDirection: "column", marginTop: 1, children: ui.logs.map((line, index) => (_jsx(Text, { dimColor: true, children: line }, index))) })) : null, prompt && !ui.done ? (_jsx(PromptView, { prompt: prompt, text: text, cursor: cursor, highlighted: highlighted, selected: selected })) : null, ui.done ? (_jsx(Box, { marginTop: 1, children: _jsx(Text, { dimColor: true, children: "Setup finished." }) })) : null] }));
586
+ }