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.
- package/README.md +4 -4
- package/dist/api/relay.js +10 -0
- package/dist/assets/env.example.txt +182 -59
- 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 +3 -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/stackCommands.js +14 -2
- package/dist/commands/systemCommands.js +49 -2
- package/dist/commands/tunnelCommand.js +562 -0
- package/dist/config/ConfigManager.js +22 -0
- package/dist/config/types.js +1 -0
- package/dist/index.js +14 -45
- package/dist/orchestrator/format.js +46 -0
- package/dist/orchestrator/index.js +7 -2
- package/dist/orchestrator/manifest.json +12 -11
- package/dist/orchestrator/orchestrator.mjs +872 -73
- 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 +28 -2
- package/dist/utils/envFile.js +45 -0
- package/dist/vendor/shared/githubEventIntakeMode.js +41 -0
- package/dist/vendor/shared/index.js +17 -0
- package/dist/vendor/shared/intakeModePrerequisites.js +76 -0
- package/dist/vendor/shared/modelDefinitions.js +4 -4
- package/dist/vendor/shared/proprCompatibility.js +70 -0
- package/dist/vendor/shared/proprServiceUrls.js +124 -0
- package/dist/vendor/shared/statusKeys.js +14 -0
- package/dist/vendor/shared/validateRoutingUrl.js +46 -0
- 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
|
+
}
|
package/dist/tui/render.js
CHANGED
|
@@ -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("");
|
package/dist/utils/envFile.js
CHANGED
|
@@ -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.
|
|
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.
|
|
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.
|
|
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.
|
|
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
|