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,172 @@
1
+ /**
2
+ * Tests for the setup prompt bridge. Run with:
3
+ * `npx tsx --test src/tui/SetupApp.test.tsx` (from packages/cli). These exercise
4
+ * the bridge and the engine→bridge prompt mapping without rendering Ink — the
5
+ * React component is driven by the same events these assert on.
6
+ */
7
+ import assert from "node:assert/strict";
8
+ import { test } from "node:test";
9
+ import { SetupBridge, SetupCancelledError, buildSetupPrompts } from "./SetupApp.js";
10
+ /** Subscribe and capture every event the bridge emits. */
11
+ function capture(bridge) {
12
+ const prompts = [];
13
+ bridge.subscribe((event) => {
14
+ if (event.type === "prompt")
15
+ prompts.push(event.prompt);
16
+ });
17
+ return prompts;
18
+ }
19
+ test("confirm resolves with the chosen boolean", async () => {
20
+ const bridge = new SetupBridge();
21
+ const prompts = capture(bridge);
22
+ const answer = bridge.confirm({ title: "Start?", defaultValue: true });
23
+ assert.equal(prompts.length, 1);
24
+ assert.equal(prompts[0].kind, "confirm");
25
+ bridge.resolve(prompts[0].id, false);
26
+ assert.equal(await answer, false);
27
+ });
28
+ test("input resolves with the entered text", async () => {
29
+ const bridge = new SetupBridge();
30
+ const prompts = capture(bridge);
31
+ const answer = bridge.input({ title: "Root", defaultValue: "/x" });
32
+ bridge.resolve(prompts[0].id, "/custom");
33
+ assert.equal(await answer, "/custom");
34
+ });
35
+ test("select returns the chosen option value", async () => {
36
+ const bridge = new SetupBridge();
37
+ const prompts = capture(bridge);
38
+ const answer = bridge.select({
39
+ title: "Auth",
40
+ options: [
41
+ { label: "Keep", value: "keep" },
42
+ { label: "App", value: "app" },
43
+ ],
44
+ });
45
+ bridge.resolve(prompts[0].id, "app");
46
+ assert.equal(await answer, "app");
47
+ });
48
+ test("multiSelect returns the chosen values", async () => {
49
+ const bridge = new SetupBridge();
50
+ const prompts = capture(bridge);
51
+ const answer = bridge.multiSelect({
52
+ title: "Agents",
53
+ options: [
54
+ { label: "claude", value: "claude" },
55
+ { label: "codex", value: "codex" },
56
+ ],
57
+ });
58
+ bridge.resolve(prompts[0].id, ["claude"]);
59
+ assert.deepEqual(await answer, ["claude"]);
60
+ });
61
+ test("cancel rejects the in-flight prompt and all later ones", async () => {
62
+ const bridge = new SetupBridge();
63
+ capture(bridge);
64
+ const pending = bridge.confirm({ title: "Start?" });
65
+ bridge.cancel();
66
+ await assert.rejects(pending, (error) => error instanceof SetupCancelledError);
67
+ // A prompt requested after cancellation rejects immediately.
68
+ await assert.rejects(bridge.input({ title: "Root" }), (error) => error instanceof SetupCancelledError);
69
+ });
70
+ test("late subscribers still receive earlier events via history replay", async () => {
71
+ const bridge = new SetupBridge();
72
+ const answer = bridge.confirm({ title: "Start?" });
73
+ // Subscribe only after the prompt was emitted.
74
+ const prompts = capture(bridge);
75
+ assert.equal(prompts.length, 1, "history replay delivers the prompt to a late subscriber");
76
+ bridge.resolve(prompts[0].id, true);
77
+ assert.equal(await answer, true);
78
+ });
79
+ test("buildSetupPrompts maps agent selection to a multi-choice prompt", async () => {
80
+ const bridge = new SetupBridge();
81
+ const prompts = capture(bridge);
82
+ const hooks = buildSetupPrompts(bridge);
83
+ const chosen = hooks.selectAgents({ available: ["claude", "codex"], detected: ["claude"] });
84
+ assert.equal(prompts[0].kind, "multi");
85
+ if (prompts[0].kind === "multi") {
86
+ assert.deepEqual(prompts[0].defaultSelected, ["claude"]);
87
+ assert.equal(prompts[0].options.find((o) => o.value === "claude")?.hint, "detected");
88
+ }
89
+ bridge.resolve(prompts[0].id, ["claude", "codex"]);
90
+ assert.deepEqual(await chosen, ["claude", "codex"]);
91
+ });
92
+ test("buildSetupPrompts keeps existing GitHub auth when 'keep' is chosen", async () => {
93
+ const bridge = new SetupBridge();
94
+ const prompts = capture(bridge);
95
+ const hooks = buildSetupPrompts(bridge);
96
+ const current = { mode: "app", warnings: [] };
97
+ const decision = hooks.configureGithubAuth({ current });
98
+ assert.equal(prompts[0].kind, "select");
99
+ bridge.resolve(prompts[0].id, "keep");
100
+ assert.deepEqual(await decision, { keep: true });
101
+ });
102
+ test("buildSetupPrompts collects GitHub App vars across chained inputs", async () => {
103
+ const bridge = new SetupBridge();
104
+ const seen = [];
105
+ bridge.subscribe((event) => {
106
+ if (event.type === "prompt") {
107
+ seen.push(event.prompt);
108
+ // Answer each prompt as it arrives so the chained hook can proceed.
109
+ const prompt = event.prompt;
110
+ queueMicrotask(() => {
111
+ if (prompt.kind === "select")
112
+ bridge.resolve(prompt.id, "app");
113
+ else if (prompt.kind === "input")
114
+ bridge.resolve(prompt.id, `val-${prompt.title.length}`);
115
+ });
116
+ }
117
+ });
118
+ const hooks = buildSetupPrompts(bridge);
119
+ const decision = await hooks.configureGithubAuth({ current: { mode: "none", warnings: [] } });
120
+ assert.equal(decision.mode, "app");
121
+ assert.equal(decision.vars?.GH_AUTH_MODE, "app");
122
+ assert.ok(decision.vars?.GH_APP_ID);
123
+ assert.ok(decision.vars?.HOST_GH_PRIVATE_KEY);
124
+ assert.ok(decision.vars?.GH_INSTALLATION_ID);
125
+ });
126
+ test("buildSetupPrompts maps intake selection and chains a masked webhook secret", async () => {
127
+ const bridge = new SetupBridge();
128
+ const seen = [];
129
+ bridge.subscribe((event) => {
130
+ if (event.type === "prompt") {
131
+ seen.push(event.prompt);
132
+ const prompt = event.prompt;
133
+ queueMicrotask(() => {
134
+ if (prompt.kind === "select")
135
+ bridge.resolve(prompt.id, "direct_webhook");
136
+ else if (prompt.kind === "input")
137
+ bridge.resolve(prompt.id, "hook-secret");
138
+ });
139
+ }
140
+ });
141
+ const hooks = buildSetupPrompts(bridge);
142
+ const decision = await hooks.configureIntake({ authMode: "app", defaultMode: "polling", currentMode: "polling" });
143
+ assert.deepEqual(decision, { mode: "direct_webhook", webhookSecret: "hook-secret" });
144
+ assert.equal(seen[0].kind, "select", "the intake mode is a single-choice prompt");
145
+ const secretPrompt = seen.find((p) => p.kind === "input");
146
+ assert.equal(secretPrompt?.kind === "input" && secretPrompt.mask, true, "the secret input is masked");
147
+ });
148
+ test("buildSetupPrompts keeps the current intake when 'keep' is chosen", async () => {
149
+ const bridge = new SetupBridge();
150
+ const prompts = capture(bridge);
151
+ const hooks = buildSetupPrompts(bridge);
152
+ const decision = hooks.configureIntake({ authMode: "none", defaultMode: "polling", currentMode: "direct_webhook" });
153
+ assert.equal(prompts[0].kind, "select");
154
+ bridge.resolve(prompts[0].id, "keep");
155
+ assert.deepEqual(await decision, { keep: true });
156
+ });
157
+ test("buildSetupPrompts skips the whitelist prompt in demo mode", async () => {
158
+ const bridge = new SetupBridge();
159
+ const prompts = capture(bridge);
160
+ const hooks = buildSetupPrompts(bridge);
161
+ const result = await hooks.configureWhitelist({ current: [], demoMode: true });
162
+ assert.equal(result, null);
163
+ assert.equal(prompts.length, 0, "demo mode needs no whitelist input");
164
+ });
165
+ test("buildSetupPrompts parses a comma-separated whitelist", async () => {
166
+ const bridge = new SetupBridge();
167
+ const prompts = capture(bridge);
168
+ const hooks = buildSetupPrompts(bridge);
169
+ const result = hooks.configureWhitelist({ current: ["alice"], demoMode: false });
170
+ bridge.resolve(prompts[0].id, " alice, bob ,, carol ");
171
+ assert.deepEqual(await result, ["alice", "bob", "carol"]);
172
+ });
package/dist/tui/app.js CHANGED
@@ -1,9 +1,93 @@
1
1
  import { jsx as _jsx } from "react/jsx-runtime";
2
2
  import { render } from "ink";
3
3
  import { StartApp } from "./StartApp.js";
4
+ import { CheckApp, CheckHub } from "./CheckApp.js";
5
+ import { AgentTableApp, AgentTableHub } from "./AgentTableApp.js";
6
+ import { SetupApp, SetupBridge, buildSetupPrompts } from "./SetupApp.js";
7
+ import { runChecks } from "../commands/checkCommands.js";
8
+ import { validateAgents, agentTypesFor } from "../commands/agentValidation.js";
9
+ import { runSetup } from "../commands/setup/engine.js";
4
10
  export async function renderDashboard(props) {
5
11
  const result = { outcome: "background" };
6
12
  const instance = render(_jsx(StartApp, { orch: props.orch, cfg: props.cfg, configManager: props.configManager, onResult: (o) => { result.outcome = o; } }), { exitOnCtrlC: false });
7
13
  await instance.waitUntilExit();
8
14
  return result.outcome;
9
15
  }
16
+ /**
17
+ * Render a single live check pass in an interactive terminal. The check engine
18
+ * streams results into the Ink view (spinners while slow checks run); with
19
+ * `fix`, the view ends in an arrow-key remediation menu. Resolves with the
20
+ * finished outcome and the remediation key the user selected (if any) so the
21
+ * caller can run that action outside the Ink tree.
22
+ */
23
+ export async function renderLiveChecks(runOptions, opts) {
24
+ const hub = new CheckHub();
25
+ let selectedKey;
26
+ let cancelled = false;
27
+ const instance = render(_jsx(CheckApp, { hub: hub, fix: Boolean(opts.fix), getActions: opts.getActions, onSelect: (key) => { selectedKey = key; }, onCancel: () => { cancelled = true; }, showAgentValidationHint: Boolean(opts.showAgentValidationHint) }), { exitOnCtrlC: false });
28
+ let engineError;
29
+ const outcomePromise = runChecks({
30
+ ...runOptions,
31
+ onPending: (slot) => hub.emit({ type: "pending", slot }),
32
+ onResult: (result) => hub.emit({ type: "result", result }),
33
+ })
34
+ .then((outcome) => {
35
+ hub.emit({ type: "done", outcome });
36
+ return outcome;
37
+ })
38
+ .catch((error) => {
39
+ engineError = error;
40
+ hub.emit({ type: "error", error });
41
+ return undefined;
42
+ });
43
+ await instance.waitUntilExit();
44
+ if (cancelled)
45
+ return { outcome: undefined, selectedKey: undefined };
46
+ const outcome = await outcomePromise;
47
+ if (engineError)
48
+ throw engineError;
49
+ return { outcome, selectedKey };
50
+ }
51
+ /**
52
+ * Render the agent validation as a live table: rows appear immediately with
53
+ * spinners and each cell fills in as its check resolves. Returns the finished
54
+ * rows so the caller can print the raw responses below.
55
+ */
56
+ export async function renderAgentValidation(orch, cfg, agentsFilter) {
57
+ const hub = new AgentTableHub();
58
+ const instance = render(_jsx(AgentTableApp, { agents: agentTypesFor(agentsFilter, cfg), hub: hub }), { exitOnCtrlC: false });
59
+ const rows = await validateAgents(orch, cfg, {
60
+ agents: agentsFilter,
61
+ onUpdate: (agent, update) => hub.emit({ type: "update", agent, update }),
62
+ });
63
+ hub.emit({ type: "done" });
64
+ await instance.waitUntilExit();
65
+ return rows;
66
+ }
67
+ /**
68
+ * Run `propr setup` interactively. The setup engine is UI-agnostic: it streams
69
+ * state through a reporter and collects decisions through prompt hooks. Here we
70
+ * bridge both to an Ink view ({@link SetupApp}) — the step list updates live as
71
+ * the engine emits state, and the engine's prompt hooks render confirm / input /
72
+ * single-choice / multi-choice prompts the user drives with the keyboard.
73
+ *
74
+ * Resolves with the final {@link SetupRunResult} once the engine finishes (and
75
+ * the view has painted its last frame). On Ctrl-C the view cancels any in-flight
76
+ * prompt — which unwinds the engine so `runSetup` still resolves — and exits the
77
+ * Ink session, so nothing is left running. Callers should use
78
+ * `result.completed` to decide what to print afterwards.
79
+ */
80
+ export async function renderSetupWizard(options = {}) {
81
+ const bridge = new SetupBridge();
82
+ const instance = render(_jsx(SetupApp, { bridge: bridge }), { exitOnCtrlC: false });
83
+ const reporter = {
84
+ onState: (state) => bridge.emitState(state),
85
+ onLog: (line) => bridge.emitLog(line),
86
+ };
87
+ // runSetup never throws for cancellation: a cancelled prompt rejects, the
88
+ // engine catches it, settles the step, and returns its (incomplete) state.
89
+ const result = await runSetup({ ...options, prompts: buildSetupPrompts(bridge), reporter });
90
+ bridge.finish(result.state);
91
+ await instance.waitUntilExit();
92
+ return result;
93
+ }
@@ -7,6 +7,7 @@
7
7
  */
8
8
  import { getHostConfig } from "../orchestrator/index.js";
9
9
  import { renderStatusTable } from "../orchestrator/format.js";
10
+ import { ensureVibePromptCacheDir } from "../commands/initStack.js";
10
11
  import { createInterface } from "node:readline/promises";
11
12
  async function confirmRestart() {
12
13
  if (!process.stdin.isTTY || !process.stdout.isTTY)
@@ -26,6 +27,16 @@ export async function runStart(configManager, options) {
26
27
  console.error("Error: cannot reach the Docker daemon. Run 'propr check' for diagnostics.");
27
28
  process.exit(1);
28
29
  }
30
+ // Pre-create the host Vibe prompt-cache dir owned by this user before Docker
31
+ // can auto-create it as root on first bind-mount. Without this, a stack that
32
+ // has run once leaves a root-owned cache dir that trips the writability check
33
+ // below and blocks every subsequent `propr start`.
34
+ try {
35
+ ensureVibePromptCacheDir(cfg.hostVibePromptCacheDir);
36
+ }
37
+ catch {
38
+ /* best-effort: validateEnv will surface an actionable error if needed */
39
+ }
29
40
  const validation = orch.validateEnv(cfg);
30
41
  for (const w of validation.warnings)
31
42
  console.warn(`warning: ${w}`);
@@ -63,3 +63,48 @@ export function upsertEnvVars(envPath, vars) {
63
63
  console.warn(`Note: tightened ${envPath} permissions from ${tightenedFrom.toString(8)} to 600 (secrets file).`);
64
64
  }
65
65
  }
66
+ /**
67
+ * Remove the given keys from a .env file entirely.
68
+ *
69
+ * Deletes every uncommented `KEY=` assignment for each key — so a key that was
70
+ * accidentally assigned more than once is fully cleared, not just thinned to its
71
+ * last duplicate; every other line — comments, blanks, and unrelated keys — is
72
+ * preserved verbatim. A missing file, an empty key list, and keys that aren't
73
+ * present are all no-ops.
74
+ *
75
+ * This exists because {@link upsertEnvVars} can only *set* a value: writing a
76
+ * blank (e.g. `GITHUB_USER_WHITELIST=`) still leaves the key in the file, where
77
+ * it reads back as an empty value rather than as "unset". Setup flows that must
78
+ * genuinely clear a stale key (clearing the user whitelist, dropping a key when
79
+ * switching auth/intake modes) use this so the value does not silently return on
80
+ * the next read or restart.
81
+ */
82
+ export function clearEnvKeys(envPath, keys) {
83
+ if (keys.length === 0 || !existsSync(envPath))
84
+ return;
85
+ const lines = readFileSync(envPath, "utf-8").split(/\r?\n/);
86
+ const patterns = keys.map((key) => new RegExp(`^\\s*(export\\s+)?${escapeRegExp(key)}\\s*=`));
87
+ const kept = lines.filter((line) => !patterns.some((pattern) => pattern.test(line)));
88
+ // Nothing matched → leave the file (and its mode) untouched.
89
+ if (kept.length === lines.length)
90
+ return;
91
+ // Tighten permissions like upsertEnvVars does — this is still the secrets file.
92
+ let tightenedFrom = null;
93
+ try {
94
+ const before = statSync(envPath).mode & 0o777;
95
+ if (before !== 0o600) {
96
+ chmodSync(envPath, 0o600);
97
+ tightenedFrom = before;
98
+ }
99
+ }
100
+ catch {
101
+ // Best-effort — may fail on Windows or non-owned files.
102
+ }
103
+ // Drop trailing blank lines, then re-add exactly one terminating newline.
104
+ while (kept.length > 0 && kept[kept.length - 1] === "")
105
+ kept.pop();
106
+ writeFileSync(envPath, `${kept.join("\n")}\n`, "utf-8");
107
+ if (tightenedFrom !== null) {
108
+ console.warn(`Note: tightened ${envPath} permissions from ${tightenedFrom.toString(8)} to 600 (secrets file).`);
109
+ }
110
+ }
@@ -0,0 +1,41 @@
1
+ /**
2
+ * GitHub event intake mode resolution, shared so the backend boot path and
3
+ * any tooling agree on how GitHub events are delivered.
4
+ *
5
+ * Auth mode (see githubAuthMode) and event delivery mode are now independent:
6
+ * how ProPR authenticates to GitHub no longer dictates how it receives events.
7
+ *
8
+ * Event intake is configured one of three ways:
9
+ * routing_websocket — events arrive over a routing WebSocket (the default)
10
+ * polling — events are pulled by polling the GitHub API
11
+ * direct_webhook — events are delivered to a local webhook endpoint
12
+ * An explicit GITHUB_EVENT_INTAKE_MODE selects the mode; unset defaults to
13
+ * routing_websocket. The legacy boolean ENABLE_GITHUB_WEBHOOKS is deprecated
14
+ * and no longer selects the mode.
15
+ */
16
+ export const GITHUB_EVENT_INTAKE_MODES = [
17
+ 'routing_websocket',
18
+ 'polling',
19
+ 'direct_webhook',
20
+ ];
21
+ export const DEFAULT_GITHUB_EVENT_INTAKE_MODE = 'routing_websocket';
22
+ function isGithubEventIntakeMode(value) {
23
+ return GITHUB_EVENT_INTAKE_MODES.includes(value);
24
+ }
25
+ export function resolveGithubEventIntakeMode(env) {
26
+ const warnings = [];
27
+ // The legacy boolean no longer selects the mode — surface a deprecation
28
+ // notice whenever it is present so operators can migrate off it.
29
+ if (env.enableGithubWebhooks !== undefined) {
30
+ warnings.push('ENABLE_GITHUB_WEBHOOKS is deprecated and no longer selects the GitHub event intake mode. Set GITHUB_EVENT_INTAKE_MODE to "routing_websocket", "polling", or "direct_webhook" instead.');
31
+ }
32
+ const explicit = (env.eventIntakeMode || '').trim().toLowerCase();
33
+ if (!explicit) {
34
+ return { mode: DEFAULT_GITHUB_EVENT_INTAKE_MODE, warnings };
35
+ }
36
+ if (isGithubEventIntakeMode(explicit)) {
37
+ return { mode: explicit, warnings };
38
+ }
39
+ throw new Error(`GITHUB_EVENT_INTAKE_MODE="${env.eventIntakeMode}" is not a recognized value (expected ${GITHUB_EVENT_INTAKE_MODES.map((m) => `"${m}"`).join(', ')}).`);
40
+ }
41
+ //# sourceMappingURL=githubEventIntakeMode.js.map
@@ -7,8 +7,24 @@ export { DEMO_MODE_READ_ONLY_CODE, parseTruthyEnvValue } from './demoMode.js';
7
7
  export { getGithubUserWhitelist, isGithubUserWhitelisted, } from './userWhitelist.js';
8
8
  // Export relay URL validation
9
9
  export { validateRelayUrl } from './validateRelayUrl.js';
10
+ // Export the hosted propr-routing service default URLs (one source of truth for
11
+ // the webhook.propr.dev host shared by the CLI, the daemon dialer, and the
12
+ // boot/check prerequisite validators)
13
+ export { DEFAULT_PROPR_ROUTING_URL, DEFAULT_PROPR_GH_RELAY_URL, } from './proprServiceUrls.js';
14
+ // Export routing URL validation (shared by intake prerequisites and the daemon
15
+ // routing service so the boot/CLI checks and the dialer agree on one policy)
16
+ export { validateRoutingUrl } from './validateRoutingUrl.js';
10
17
  // Export GitHub auth mode inference (shared by backend boot and `propr check`)
11
18
  export { resolveGithubAuthMode, } from './githubAuthMode.js';
19
+ // Export GitHub event intake mode resolution (auth mode and event delivery
20
+ // mode evolve independently; replaces the legacy ENABLE_GITHUB_WEBHOOKS boolean)
21
+ export { GITHUB_EVENT_INTAKE_MODES, DEFAULT_GITHUB_EVENT_INTAKE_MODE, resolveGithubEventIntakeMode, } from './githubEventIntakeMode.js';
22
+ // Export mode-specific GitHub intake prerequisite validation (shared by backend
23
+ // boot and `propr check` so the two agree on what each intake mode requires)
24
+ export { validateIntakeModePrerequisites, } from './intakeModePrerequisites.js';
25
+ // Export shared Redis status keys (one source of truth for cross-process status
26
+ // keys so the daemon publisher, API status route, and CLI cannot drift)
27
+ export { ROUTING_STATUS_REDIS_KEY } from './statusKeys.js';
12
28
  export { shortHash, buildDynamicLlmLabel, MAX_GITHUB_LABEL_LENGTH } from './labelUtils.js';
13
29
  // Export the default review guidance (the overridable part of the /review prompt)
14
30
  export { DEFAULT_REVIEW_GUIDANCE } from './reviewPrompt.js';
@@ -0,0 +1,76 @@
1
+ /**
2
+ * Mode-specific GitHub intake prerequisite validation, shared so the backend
3
+ * boot path and the CLI (`propr check`) agree on what each intake mode needs
4
+ * before the daemon or API starts partially configured.
5
+ *
6
+ * Mode resolution (see githubEventIntakeMode) decides which intake path runs;
7
+ * this helper validates the environment that path requires:
8
+ * routing_websocket — needs relay auth plus relay/routing credentials
9
+ * polling — needs usable GitHub auth (relay or app)
10
+ * direct_webhook — needs an own GitHub App plus a webhook secret
11
+ *
12
+ * Validation is intentionally side-effect free (no logging, no process exit)
13
+ * so CLI checks and the boot path can both reuse it.
14
+ */
15
+ import { validateRoutingUrl } from './validateRoutingUrl.js';
16
+ import { DEFAULT_PROPR_ROUTING_URL } from './proprServiceUrls.js';
17
+ function isPresent(value) {
18
+ return typeof value === 'string' && value.trim() !== '';
19
+ }
20
+ /**
21
+ * Validate the environment prerequisites for the resolved intake mode.
22
+ * Returns structured errors and warnings; never throws and never has side effects.
23
+ */
24
+ export function validateIntakeModePrerequisites(env) {
25
+ const errors = [];
26
+ const warnings = [];
27
+ // Demo mode disables GitHub entirely, so no intake prerequisites apply.
28
+ if (env.authMode === 'demo') {
29
+ return { valid: true, errors, warnings };
30
+ }
31
+ switch (env.intakeMode) {
32
+ case 'routing_websocket': {
33
+ if (env.authMode !== 'relay') {
34
+ errors.push('routing_websocket intake requires relay auth mode. Set GH_AUTH_MODE=relay (or configure PROPR_GH_RELAY_URL + PROPR_GH_RELAY_TOKEN so relay mode is inferred).');
35
+ }
36
+ // PROPR_ROUTING_URL / PROPR_GH_RELAY_URL default to the hosted relay
37
+ // (webhook.propr.dev) — the same fallbacks the daemon dialer and
38
+ // `propr relay enroll` apply — so an unset value is not an error here; it
39
+ // resolves to the default. Only the relay TOKEN (a secret, minted by
40
+ // enrollment) cannot be defaulted.
41
+ const routingUrl = isPresent(env.routingUrl) ? env.routingUrl : DEFAULT_PROPR_ROUTING_URL;
42
+ // validateRoutingUrl is the single source of truth for the routing-URL
43
+ // policy — the same function the daemon service uses before it dials —
44
+ // so the boot/CLI check and the dialer can never disagree. It accepts
45
+ // the default wss:// origin (and https://), rejecting insecure non-local
46
+ // schemes and path-bearing origins.
47
+ const routingUrlError = validateRoutingUrl(routingUrl);
48
+ if (routingUrlError) {
49
+ errors.push(`PROPR_ROUTING_URL is invalid: ${routingUrlError}`);
50
+ }
51
+ if (!isPresent(env.relayToken)) {
52
+ errors.push('PROPR_GH_RELAY_TOKEN must be set for routing_websocket intake.');
53
+ }
54
+ break;
55
+ }
56
+ case 'polling': {
57
+ // Polling pulls events from the GitHub API, so any usable GitHub auth
58
+ // works — both the relay (shared-app) and app (own-app) paths qualify.
59
+ if (env.authMode !== 'relay' && env.authMode !== 'app') {
60
+ errors.push('polling intake requires usable GitHub auth. Configure relay mode (PROPR_GH_RELAY_URL + PROPR_GH_RELAY_TOKEN) or app mode (GH_APP_ID + GH_PRIVATE_KEY_PATH + GH_INSTALLATION_ID).');
61
+ }
62
+ break;
63
+ }
64
+ case 'direct_webhook': {
65
+ if (env.authMode !== 'app') {
66
+ errors.push('direct_webhook intake requires app auth mode (an own GitHub App). Set GH_AUTH_MODE=app and configure GH_APP_ID + GH_PRIVATE_KEY_PATH + GH_INSTALLATION_ID.');
67
+ }
68
+ if (!isPresent(env.webhookSecret)) {
69
+ errors.push('GH_WEBHOOK_SECRET must be set for direct_webhook intake.');
70
+ }
71
+ break;
72
+ }
73
+ }
74
+ return { valid: errors.length === 0, errors, warnings };
75
+ }
76
+ //# sourceMappingURL=intakeModePrerequisites.js.map
@@ -100,7 +100,7 @@ export const AGENT_DEFAULTS = {
100
100
  defaultModels: CLAUDE_MODELS.map(m => m.id),
101
101
  defaultAlias: 'claude',
102
102
  npmPackage: '@anthropic-ai/claude-code',
103
- defaultCliVersion: '2.1.170'
103
+ defaultCliVersion: '2.1.191'
104
104
  },
105
105
  codex: {
106
106
  dockerImage: 'propr/agent-codex:latest',
@@ -108,7 +108,7 @@ export const AGENT_DEFAULTS = {
108
108
  defaultModels: CODEX_MODELS.map(m => m.id),
109
109
  defaultAlias: 'codex',
110
110
  npmPackage: '@openai/codex',
111
- defaultCliVersion: '0.137.0'
111
+ defaultCliVersion: '0.142.1'
112
112
  },
113
113
  antigravity: {
114
114
  dockerImage: 'propr/agent-antigravity:latest',
@@ -124,7 +124,7 @@ export const AGENT_DEFAULTS = {
124
124
  defaultModels: OPENCODE_MODELS.map(m => m.id),
125
125
  defaultAlias: 'opencode',
126
126
  npmPackage: 'opencode-ai',
127
- defaultCliVersion: '1.16.2'
127
+ defaultCliVersion: '1.17.10'
128
128
  },
129
129
  vibe: {
130
130
  dockerImage: 'propr/agent-vibe:latest',
@@ -132,7 +132,7 @@ export const AGENT_DEFAULTS = {
132
132
  defaultModels: VIBE_MODELS.map(m => m.id),
133
133
  defaultAlias: 'vibe',
134
134
  npmPackage: 'mistral-vibe',
135
- defaultCliVersion: '2.12.1'
135
+ defaultCliVersion: '2.17.1'
136
136
  }
137
137
  };
138
138
  // Badge colors for each agent type (for UI)
@@ -0,0 +1,27 @@
1
+ /**
2
+ * Default URLs for the vendor-run propr-routing service.
3
+ *
4
+ * The routing relay and the GitHub token relay are not separate deployments —
5
+ * they are the same Cloudflare Worker (propr-routing), served from a single
6
+ * custom domain and exposing every endpoint under `/v1`:
7
+ * - `wss://webhook.propr.dev/v1/connect` (routing WebSocket intake)
8
+ * - `https://webhook.propr.dev/v1/relay-tokens`, `/v1/installation-token` …
9
+ *
10
+ * These constants are the single source of truth for that host so the CLI
11
+ * (`propr relay enroll`), the daemon dialer, and the boot/`propr check`
12
+ * prerequisite validators all agree on the hosted default without anyone having
13
+ * to set PROPR_ROUTING_URL / PROPR_GH_RELAY_URL by hand.
14
+ */
15
+ /**
16
+ * Default routing WebSocket origin (PROPR_ROUTING_URL). A bare origin without a
17
+ * path — the routing service owns the `/v1/...` paths it appends (connect +
18
+ * payload pull), so a path here would corrupt the derived URLs.
19
+ */
20
+ export const DEFAULT_PROPR_ROUTING_URL = 'wss://webhook.propr.dev';
21
+ /**
22
+ * Default GitHub token relay base URL (PROPR_GH_RELAY_URL). Includes the `/v1`
23
+ * version prefix because the relay client appends paths like `/relay-tokens`
24
+ * directly to this value.
25
+ */
26
+ export const DEFAULT_PROPR_GH_RELAY_URL = 'https://webhook.propr.dev/v1';
27
+ //# sourceMappingURL=proprServiceUrls.js.map
@@ -0,0 +1,14 @@
1
+ /**
2
+ * Redis keys carrying cross-process runtime status.
3
+ *
4
+ * Centralized here so the daemon (publisher), the API status route, the CLI, and
5
+ * their tests all reference one string instead of duplicating literals that could
6
+ * silently drift out of sync.
7
+ */
8
+ /**
9
+ * Routing WebSocket intake runtime state, published by the daemon and read by the
10
+ * API status route / `propr check` to report routing connectivity, last delivery
11
+ * id, and last ACK for the default routing_websocket intake path.
12
+ */
13
+ export const ROUTING_STATUS_REDIS_KEY = 'system:status:routing';
14
+ //# sourceMappingURL=statusKeys.js.map
@@ -0,0 +1,46 @@
1
+ // URL.hostname returns brackets for IPv6, e.g. '[::1]'.
2
+ const LOCALHOST_HOSTS = ['localhost', '127.0.0.1', '[::1]'];
3
+ /**
4
+ * Validate a PROPR_ROUTING_URL origin. This is the single source of truth for
5
+ * the routing-URL policy, shared by the boot/CLI prerequisite checks
6
+ * (intakeModePrerequisites) and the daemon service that actually dials it
7
+ * (RoutingWebSocketIntakeService), so the two can never disagree.
8
+ *
9
+ * Policy:
10
+ * - must be a parseable URL
11
+ * - must use a secure scheme: `wss://` or `https://`
12
+ * (`ws://`/`http://` is allowed ONLY for localhost development)
13
+ * - must be a bare ORIGIN — no path/query/fragment — because the service owns
14
+ * the `/v1/...` paths it appends (connect + payload pull); a configured path
15
+ * like `wss://routing/v1` would corrupt the derived URLs (`/v1/v1/connect`).
16
+ *
17
+ * Note the `wss://`/`ws://` (and `https://`/`http://`) schemes are all accepted
18
+ * because the service derives both the WebSocket connect URL and the HTTP
19
+ * payload-pull URL from this single value. The default `wss://webhook.propr.dev`
20
+ * is valid under this policy.
21
+ *
22
+ * Returns an error message string, or `null` when valid.
23
+ */
24
+ export function validateRoutingUrl(url) {
25
+ let parsed;
26
+ try {
27
+ parsed = new URL(url);
28
+ }
29
+ catch {
30
+ return `Routing URL ("${url}") is not a valid URL. Set PROPR_ROUTING_URL to a wss:// origin, e.g. wss://webhook.propr.dev.`;
31
+ }
32
+ const scheme = parsed.protocol;
33
+ if (scheme !== 'ws:' && scheme !== 'wss:' && scheme !== 'http:' && scheme !== 'https:') {
34
+ return `Routing URL must use wss:// or https:// (ws://, http:// only for localhost); got "${scheme}//".`;
35
+ }
36
+ const isSecure = scheme === 'wss:' || scheme === 'https:';
37
+ const isLocalhost = LOCALHOST_HOSTS.includes(parsed.hostname);
38
+ if (!isSecure && !isLocalhost) {
39
+ return 'Routing URL must use wss:// or https:// (ws://, http:// is only allowed for localhost).';
40
+ }
41
+ if (parsed.pathname.replace(/\/+$/, '') !== '' || parsed.search || parsed.hash) {
42
+ return `Routing URL must be an origin without a path (e.g. wss://webhook.propr.dev), got "${url}".`;
43
+ }
44
+ return null;
45
+ }
46
+ //# sourceMappingURL=validateRoutingUrl.js.map
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "propr-cli",
3
- "version": "0.8.3",
3
+ "version": "0.8.4",
4
4
  "description": "CLI for interacting with the ProPR backend",
5
5
  "type": "module",
6
6
  "bin": {
@@ -17,7 +17,7 @@
17
17
  "commander": "^13.1.0",
18
18
  "dotenv": "^16.5.0",
19
19
  "ink": "^7.0.5",
20
- "react": "^19.2.7"
20
+ "react": "19.2.7"
21
21
  },
22
22
  "keywords": [
23
23
  "propr",