propr-cli 0.8.3 → 0.8.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +4 -4
- package/dist/api/relay.js +10 -0
- package/dist/assets/env.example.txt +93 -57
- package/dist/auth/githubLogin.js +66 -0
- package/dist/commands/agentCommands.js +74 -0
- package/dist/commands/agentValidation.js +548 -0
- package/dist/commands/checkCommands.js +981 -76
- package/dist/commands/imageCommands.js +60 -0
- package/dist/commands/index.js +2 -0
- package/dist/commands/initStack.js +50 -1
- package/dist/commands/relayCommands.js +45 -12
- package/dist/commands/setup/agents.js +185 -0
- package/dist/commands/setup/engine.js +956 -0
- package/dist/commands/setup/github.js +181 -0
- package/dist/commands/setup/sequential.js +501 -0
- package/dist/commands/setup/state.js +242 -0
- package/dist/commands/setup/types.js +85 -0
- package/dist/commands/setupCommand.js +85 -0
- package/dist/commands/systemCommands.js +49 -2
- package/dist/index.js +13 -45
- package/dist/orchestrator/manifest.json +10 -10
- package/dist/orchestrator/orchestrator.mjs +513 -61
- package/dist/tui/AgentTableApp.js +86 -0
- package/dist/tui/CheckApp.js +202 -0
- package/dist/tui/SetupApp.js +586 -0
- package/dist/tui/SetupApp.test.js +172 -0
- package/dist/tui/app.js +84 -0
- package/dist/tui/render.js +11 -0
- package/dist/utils/envFile.js +45 -0
- package/dist/vendor/shared/githubEventIntakeMode.js +41 -0
- package/dist/vendor/shared/index.js +16 -0
- package/dist/vendor/shared/intakeModePrerequisites.js +76 -0
- package/dist/vendor/shared/modelDefinitions.js +4 -4
- package/dist/vendor/shared/proprServiceUrls.js +27 -0
- package/dist/vendor/shared/statusKeys.js +14 -0
- package/dist/vendor/shared/validateRoutingUrl.js +46 -0
- package/package.json +2 -2
- package/dist/assets/.env.example +0 -183
|
@@ -0,0 +1,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
|
@@ -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}`);
|
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,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.
|
|
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,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
|
+
"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": "
|
|
20
|
+
"react": "19.2.7"
|
|
21
21
|
},
|
|
22
22
|
"keywords": [
|
|
23
23
|
"propr",
|