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.
- package/README.md +4 -4
- package/dist/api/relay.js +10 -0
- package/dist/assets/env.example.txt +93 -57
- package/dist/auth/githubLogin.js +66 -0
- package/dist/commands/agentCommands.js +74 -0
- package/dist/commands/agentValidation.js +548 -0
- package/dist/commands/checkCommands.js +981 -76
- package/dist/commands/imageCommands.js +60 -0
- package/dist/commands/index.js +2 -0
- package/dist/commands/initStack.js +50 -1
- package/dist/commands/relayCommands.js +45 -12
- package/dist/commands/setup/agents.js +185 -0
- package/dist/commands/setup/engine.js +956 -0
- package/dist/commands/setup/github.js +181 -0
- package/dist/commands/setup/sequential.js +501 -0
- package/dist/commands/setup/state.js +242 -0
- package/dist/commands/setup/types.js +85 -0
- package/dist/commands/setupCommand.js +85 -0
- package/dist/commands/systemCommands.js +49 -2
- package/dist/index.js +13 -45
- package/dist/orchestrator/manifest.json +10 -10
- package/dist/orchestrator/orchestrator.mjs +513 -61
- package/dist/tui/AgentTableApp.js +86 -0
- package/dist/tui/CheckApp.js +202 -0
- package/dist/tui/SetupApp.js +586 -0
- package/dist/tui/SetupApp.test.js +172 -0
- package/dist/tui/app.js +84 -0
- package/dist/tui/render.js +11 -0
- package/dist/utils/envFile.js +45 -0
- package/dist/vendor/shared/githubEventIntakeMode.js +41 -0
- package/dist/vendor/shared/index.js +16 -0
- package/dist/vendor/shared/intakeModePrerequisites.js +76 -0
- package/dist/vendor/shared/modelDefinitions.js +4 -4
- package/dist/vendor/shared/proprServiceUrls.js +27 -0
- package/dist/vendor/shared/statusKeys.js +14 -0
- package/dist/vendor/shared/validateRoutingUrl.js +46 -0
- package/package.json +2 -2
- package/dist/assets/.env.example +0 -183
|
@@ -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: "[0m",
|
|
29
|
+
bold: "[1m",
|
|
30
|
+
dim: "[2m",
|
|
31
|
+
red: "[31m",
|
|
32
|
+
green: "[32m",
|
|
33
|
+
yellow: "[33m",
|
|
34
|
+
cyan: "[36m",
|
|
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
|
+
}
|