propr-cli 0.8.3 → 0.8.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (44) hide show
  1. package/README.md +4 -4
  2. package/dist/api/relay.js +10 -0
  3. package/dist/assets/env.example.txt +182 -59
  4. package/dist/auth/githubLogin.js +66 -0
  5. package/dist/commands/agentCommands.js +74 -0
  6. package/dist/commands/agentValidation.js +548 -0
  7. package/dist/commands/checkCommands.js +981 -76
  8. package/dist/commands/imageCommands.js +60 -0
  9. package/dist/commands/index.js +3 -0
  10. package/dist/commands/initStack.js +50 -1
  11. package/dist/commands/relayCommands.js +45 -12
  12. package/dist/commands/setup/agents.js +185 -0
  13. package/dist/commands/setup/engine.js +956 -0
  14. package/dist/commands/setup/github.js +181 -0
  15. package/dist/commands/setup/sequential.js +501 -0
  16. package/dist/commands/setup/state.js +242 -0
  17. package/dist/commands/setup/types.js +85 -0
  18. package/dist/commands/setupCommand.js +85 -0
  19. package/dist/commands/stackCommands.js +14 -2
  20. package/dist/commands/systemCommands.js +49 -2
  21. package/dist/commands/tunnelCommand.js +562 -0
  22. package/dist/config/ConfigManager.js +22 -0
  23. package/dist/config/types.js +1 -0
  24. package/dist/index.js +14 -45
  25. package/dist/orchestrator/format.js +46 -0
  26. package/dist/orchestrator/index.js +7 -2
  27. package/dist/orchestrator/manifest.json +12 -11
  28. package/dist/orchestrator/orchestrator.mjs +872 -73
  29. package/dist/tui/AgentTableApp.js +86 -0
  30. package/dist/tui/CheckApp.js +202 -0
  31. package/dist/tui/SetupApp.js +586 -0
  32. package/dist/tui/SetupApp.test.js +172 -0
  33. package/dist/tui/app.js +84 -0
  34. package/dist/tui/render.js +28 -2
  35. package/dist/utils/envFile.js +45 -0
  36. package/dist/vendor/shared/githubEventIntakeMode.js +41 -0
  37. package/dist/vendor/shared/index.js +17 -0
  38. package/dist/vendor/shared/intakeModePrerequisites.js +76 -0
  39. package/dist/vendor/shared/modelDefinitions.js +4 -4
  40. package/dist/vendor/shared/proprCompatibility.js +70 -0
  41. package/dist/vendor/shared/proprServiceUrls.js +124 -0
  42. package/dist/vendor/shared/statusKeys.js +14 -0
  43. package/dist/vendor/shared/validateRoutingUrl.js +46 -0
  44. package/package.json +3 -3
@@ -0,0 +1,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
+ }
@@ -6,7 +6,8 @@
6
6
  * In all cases the containers run detached, so the stack outlives this process.
7
7
  */
8
8
  import { getHostConfig } from "../orchestrator/index.js";
9
- import { renderStatusTable } from "../orchestrator/format.js";
9
+ import { renderStatusTable, renderTunnelEndpointSummary } 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}`);
@@ -60,8 +71,23 @@ export async function runStart(configManager, options) {
60
71
  }
61
72
  const ui = configManager.getUiEnabled();
62
73
  const docs = cfg.docsEnabled;
74
+ // cfg.uiTunnelEnabled already reflects a persisted `propr tunnel on|off`
75
+ // toggle (forwarded as an override in getHostConfig), falling back to the
76
+ // env-derived default when the toggle has never been set.
77
+ const tunnel = cfg.uiTunnelEnabled;
63
78
  orch.ensureNetwork(cfg, (l) => console.log(l));
64
- const status = orch.startStack(cfg, { ui, docs, onLog: (l) => console.log(l) });
79
+ const status = orch.startStack(cfg, { ui, docs, tunnel, onLog: (l) => console.log(l) });
80
+ // When the tunnel is on, surface the concrete routed endpoints (not the base
81
+ // URL as a health target) so the operator can see where the hosted UI reaches
82
+ // this stack.
83
+ if (tunnel) {
84
+ const summary = renderTunnelEndpointSummary(cfg.uiPublicApiUrl);
85
+ if (summary.length > 0) {
86
+ console.log("");
87
+ for (const line of summary)
88
+ console.log(line);
89
+ }
90
+ }
65
91
  const interactive = options.tui !== false && Boolean(process.stdout.isTTY);
66
92
  if (!interactive) {
67
93
  console.log("");
@@ -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,25 @@ 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, DEFAULT_PROPR_UI_ORIGIN, PROPR_UI_PROXY_SUFFIX, DEFAULT_CLOUDFLARED_IMAGE, proprInstanceProxyUrl, isValidProprInstanceId, isProprProxyUrl, proprTunnelEndpoints, } 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';
28
+ export { PROPR_VERSION, PROPR_API_COMPATIBILITY, PROPR_UI_COMPATIBILITY, PROPR_UI_SUPPORTED_API_COMPATIBILITY, getProprCompatibilityMetadata, evaluateProprApiCompatibility, } from './proprCompatibility.js';
12
29
  export { shortHash, buildDynamicLlmLabel, MAX_GITHUB_LABEL_LENGTH } from './labelUtils.js';
13
30
  // Export the default review guidance (the overridable part of the /review prompt)
14
31
  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,70 @@
1
+ // Public ProPR version surfaced to the hosted UI via `/api/compatibility`. This
2
+ // must track the release version. The shared package is bundled for the browser
3
+ // (no fs/JSON-import of package.json available within rootDir), so it is kept as
4
+ // a constant rather than read from package.json at runtime. A release bump that
5
+ // updates packages/shared/package.json or docker/launcher/manifest.json but
6
+ // forgets this constant is caught by the drift test in
7
+ // test/orchestratorProprUrlsDrift.test.ts, which asserts all three agree.
8
+ export const PROPR_VERSION = '0.8.5';
9
+ // Bump this only when the API/UI contract changes in a way the hosted UI must
10
+ // account for. Patch releases that do not change the browser-facing contract can
11
+ // keep the same compatibility version.
12
+ export const PROPR_API_COMPATIBILITY = '2026-06-27';
13
+ export const PROPR_UI_COMPATIBILITY = PROPR_API_COMPATIBILITY;
14
+ export const PROPR_UI_SUPPORTED_API_COMPATIBILITY = [PROPR_API_COMPATIBILITY];
15
+ export function getProprCompatibilityMetadata() {
16
+ return {
17
+ version: PROPR_VERSION,
18
+ apiCompatibility: PROPR_API_COMPATIBILITY,
19
+ uiCompatibility: PROPR_UI_COMPATIBILITY,
20
+ };
21
+ }
22
+ export function evaluateProprApiCompatibility(input) {
23
+ const apiCompatibility = input.apiCompatibility?.trim() || null;
24
+ const apiVersion = input.version?.trim() || null;
25
+ if (!apiCompatibility) {
26
+ return {
27
+ compatible: false,
28
+ apiCompatibility,
29
+ apiVersion,
30
+ reason: 'missing',
31
+ message: 'This ProPR instance does not publish API compatibility metadata. Update the local ProPR stack before using the hosted UI.',
32
+ };
33
+ }
34
+ if (PROPR_UI_SUPPORTED_API_COMPATIBILITY.includes(apiCompatibility)) {
35
+ return { compatible: true, apiCompatibility, apiVersion };
36
+ }
37
+ // PROPR_UI_SUPPORTED_API_COMPATIBILITY currently holds a single value, so
38
+ // oldest === newest and only the too_old / too_new branches below can fire (the
39
+ // final `unsupported` branch is unreachable today). These are forward-looking:
40
+ // once the UI supports a range with gaps, an in-range-but-unsupported value can
41
+ // occur and the `unsupported` branch covers it.
42
+ const oldestSupported = PROPR_UI_SUPPORTED_API_COMPATIBILITY[0];
43
+ const newestSupported = PROPR_UI_SUPPORTED_API_COMPATIBILITY[PROPR_UI_SUPPORTED_API_COMPATIBILITY.length - 1];
44
+ if (apiCompatibility < oldestSupported) {
45
+ return {
46
+ compatible: false,
47
+ apiCompatibility,
48
+ apiVersion,
49
+ reason: 'too_old',
50
+ message: `This ProPR instance is too old for the hosted UI. Update the local ProPR stack to API compatibility ${oldestSupported} or newer.`,
51
+ };
52
+ }
53
+ if (apiCompatibility > newestSupported) {
54
+ return {
55
+ compatible: false,
56
+ apiCompatibility,
57
+ apiVersion,
58
+ reason: 'too_new',
59
+ message: `This ProPR instance is newer than the hosted UI supports. Update the hosted UI or use the matching local UI for API compatibility ${apiCompatibility}.`,
60
+ };
61
+ }
62
+ return {
63
+ compatible: false,
64
+ apiCompatibility,
65
+ apiVersion,
66
+ reason: 'unsupported',
67
+ message: `This hosted UI supports API compatibility ${PROPR_UI_SUPPORTED_API_COMPATIBILITY.join(', ')}, but the local ProPR instance reports ${apiCompatibility}.`,
68
+ };
69
+ }
70
+ //# sourceMappingURL=proprCompatibility.js.map