propr-cli 0.8.3 → 0.8.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (38) hide show
  1. package/README.md +4 -4
  2. package/dist/api/relay.js +10 -0
  3. package/dist/assets/env.example.txt +93 -57
  4. package/dist/auth/githubLogin.js +66 -0
  5. package/dist/commands/agentCommands.js +74 -0
  6. package/dist/commands/agentValidation.js +548 -0
  7. package/dist/commands/checkCommands.js +981 -76
  8. package/dist/commands/imageCommands.js +60 -0
  9. package/dist/commands/index.js +2 -0
  10. package/dist/commands/initStack.js +50 -1
  11. package/dist/commands/relayCommands.js +45 -12
  12. package/dist/commands/setup/agents.js +185 -0
  13. package/dist/commands/setup/engine.js +956 -0
  14. package/dist/commands/setup/github.js +181 -0
  15. package/dist/commands/setup/sequential.js +501 -0
  16. package/dist/commands/setup/state.js +242 -0
  17. package/dist/commands/setup/types.js +85 -0
  18. package/dist/commands/setupCommand.js +85 -0
  19. package/dist/commands/systemCommands.js +49 -2
  20. package/dist/index.js +13 -45
  21. package/dist/orchestrator/manifest.json +10 -10
  22. package/dist/orchestrator/orchestrator.mjs +513 -61
  23. package/dist/tui/AgentTableApp.js +86 -0
  24. package/dist/tui/CheckApp.js +202 -0
  25. package/dist/tui/SetupApp.js +586 -0
  26. package/dist/tui/SetupApp.test.js +172 -0
  27. package/dist/tui/app.js +84 -0
  28. package/dist/tui/render.js +11 -0
  29. package/dist/utils/envFile.js +45 -0
  30. package/dist/vendor/shared/githubEventIntakeMode.js +41 -0
  31. package/dist/vendor/shared/index.js +16 -0
  32. package/dist/vendor/shared/intakeModePrerequisites.js +76 -0
  33. package/dist/vendor/shared/modelDefinitions.js +4 -4
  34. package/dist/vendor/shared/proprServiceUrls.js +27 -0
  35. package/dist/vendor/shared/statusKeys.js +14 -0
  36. package/dist/vendor/shared/validateRoutingUrl.js +46 -0
  37. package/package.json +2 -2
  38. package/dist/assets/.env.example +0 -183
@@ -0,0 +1,60 @@
1
+ /**
2
+ * Docker image maintenance commands for the local ProPR stack.
3
+ */
4
+ import { Command } from "commander";
5
+ import { createConfigManager } from "../config/index.js";
6
+ import { getHostConfig } from "../orchestrator/index.js";
7
+ async function pullImages(options) {
8
+ const configManager = await createConfigManager();
9
+ const { orch, cfg, rootDir } = await getHostConfig({ configManager, root: options.root });
10
+ if (!orch.dockerAvailable()) {
11
+ console.error("Error: cannot reach the Docker daemon. Run 'propr check' for diagnostics.");
12
+ process.exit(1);
13
+ }
14
+ console.log(`Pulling ProPR images (root: ${rootDir})`);
15
+ const env = options.skipRemoteImageCheck
16
+ ? { ...process.env, PROPR_SKIP_REMOTE_IMAGE_CHECK: "1" }
17
+ : process.env;
18
+ const { failedAgentImages, strictAgentPull } = orch.pullImages(cfg, {
19
+ env,
20
+ onLog: (line) => console.log(line),
21
+ });
22
+ if (failedAgentImages.length > 0) {
23
+ console.warn(`\nwarning: ${failedAgentImages.length} agent image(s) could not be pulled:`);
24
+ for (const tag of failedAgentImages)
25
+ console.warn(` - ${tag}`);
26
+ console.warn(" Jobs using those agents will fail until the images are available.");
27
+ if (strictAgentPull)
28
+ process.exit(1);
29
+ }
30
+ }
31
+ export function createImagesCommand() {
32
+ const images = new Command("images")
33
+ .description("Manage local ProPR Docker images");
34
+ images
35
+ .command("pull")
36
+ .description("Pull missing or stale ProPR Docker images without starting the stack")
37
+ .option("--root <dir>", "Stack root directory (where .env/data/logs/repos live)")
38
+ .option("--skip-remote-image-check", "Skip registry freshness checks before deciding what to pull")
39
+ .addHelpText("after", `
40
+ Examples:
41
+ $ propr images pull
42
+ $ propr images pull --skip-remote-image-check
43
+ $ propr images pull --root ~/propr
44
+
45
+ Notes:
46
+ --skip-remote-image-check is for offline runs. It still pulls missing images,
47
+ but local images are treated as acceptable because stale tags cannot be
48
+ detected without the registry freshness check.
49
+ `)
50
+ .action(async (options) => {
51
+ try {
52
+ await pullImages(options);
53
+ }
54
+ catch (error) {
55
+ console.error(`Error pulling images: ${error.message}`);
56
+ process.exit(1);
57
+ }
58
+ });
59
+ return images;
60
+ }
@@ -13,8 +13,10 @@ export { createLogCommand } from "./logCommands.js";
13
13
  export { createTodoCommand } from "./todoCommands.js";
14
14
  export { createRemoteStatusCommand, createQueueCommand } from "./systemCommands.js";
15
15
  export { createInitCommand } from "./initCommands.js";
16
+ export { createSetupCommand } from "./setupCommand.js";
16
17
  // Control-plane commands (local Docker stack)
17
18
  export { createCheckCommand, runChecks, printChecks, STACK_CONFIG_CHECK_NAME } from "./checkCommands.js";
19
+ export { createImagesCommand } from "./imageCommands.js";
18
20
  export { createStartCommand } from "./startCommand.js";
19
21
  export { createStackStatusCommand, createStopCommand } from "./stackCommands.js";
20
22
  export { createUiCommand, createDocsCommand } from "./uiDocsCommands.js";
@@ -9,9 +9,42 @@
9
9
  import { Command } from "commander";
10
10
  import { existsSync, copyFileSync, chmodSync, mkdirSync, readFileSync, appendFileSync } from "node:fs";
11
11
  import { fileURLToPath } from "node:url";
12
- import { dirname, join, resolve } from "node:path";
12
+ import { dirname, isAbsolute, join, resolve } from "node:path";
13
13
  import { homedir } from "node:os";
14
14
  import { createConfigManager } from "../config/index.js";
15
+ // Mirrors the launcher's HOST_VIBE_PROMPT_CACHE_DIR default in
16
+ // docker/launcher/orchestrator.mjs. Keep it per-user and private because prompt
17
+ // files can contain task/repository context.
18
+ function defaultVibePromptCacheDir() {
19
+ return `/tmp/propr-vibe-prompts-${process.getuid?.() ?? "user"}`;
20
+ }
21
+ /**
22
+ * Pre-create the host Vibe prompt-cache directory owned by the invoking user.
23
+ *
24
+ * Spawned Vibe agent containers bind-mount this path. If it does not exist when
25
+ * Docker first mounts it, the daemon auto-creates it as root, leaving a
26
+ * root-owned directory the user can no longer write to — which then trips
27
+ * `validateEnv`'s writability check and blocks `propr start` on every
28
+ * subsequent run. Creating it here (owned by the user, 0700) avoids that.
29
+ *
30
+ * Returns the directory if it was created, otherwise `undefined`. Safe to call
31
+ * repeatedly: an existing directory (regardless of owner) is left untouched.
32
+ */
33
+ export function ensureVibePromptCacheDir(cacheDir) {
34
+ if (!cacheDir)
35
+ return undefined;
36
+ // Skip container-only / Docker-special paths (a ':' would be a volume spec).
37
+ if (!isAbsolute(cacheDir) || cacheDir.includes(":"))
38
+ return undefined;
39
+ if (existsSync(cacheDir))
40
+ return undefined;
41
+ mkdirSync(cacheDir, { recursive: true, mode: 0o700 });
42
+ try {
43
+ chmodSync(cacheDir, 0o700);
44
+ }
45
+ catch { /* best-effort: keep prompt material private */ }
46
+ return cacheDir;
47
+ }
15
48
  /** Resolve the bundled .env.example, falling back to a repo checkout. */
16
49
  function resolveEnvExample() {
17
50
  const here = dirname(fileURLToPath(import.meta.url));
@@ -119,6 +152,22 @@ export async function scaffoldStack(options = {}) {
119
152
  }
120
153
  result.detected = detected;
121
154
  result.pendingCredentials = toAppend;
155
+ // 3b. When Vibe is in play, pre-create its prompt-cache dir so spawned Vibe
156
+ // agent containers can bind-mount a writable host directory. Creating it
157
+ // here (owned by the invoking user) avoids Docker auto-creating it as
158
+ // root. Defaults to a per-user /tmp/propr-vibe-prompts-$uid path
159
+ // (matching the launcher default); an explicit
160
+ // HOST_VIBE_PROMPT_CACHE_DIR is honored.
161
+ const vibeConfigured = detected.some((c) => c.envKey === "HOST_VIBE_DIR") ||
162
+ /^\s*(?:export\s+)?(?:HOST_VIBE_DIR|MISTRAL_API_KEY)\s*=\s*\S/m.test(envContent);
163
+ if (vibeConfigured) {
164
+ const match = envContent.match(/^\s*(?:export\s+)?HOST_VIBE_PROMPT_CACHE_DIR\s*=\s*(.+)\s*$/m);
165
+ const configured = match?.[1]?.trim().replace(/^["']|["']$/g, "");
166
+ const cacheDir = configured || defaultVibePromptCacheDir();
167
+ const created = ensureVibePromptCacheDir(cacheDir);
168
+ if (created)
169
+ result.dirsCreated.push(created);
170
+ }
122
171
  // 4. Persist the stack root so other commands can find it.
123
172
  const configManager = await createConfigManager();
124
173
  await configManager.setStackRoot(rootDir);
@@ -10,41 +10,67 @@
10
10
  import { Command } from "commander";
11
11
  import { hostname } from "node:os";
12
12
  import { join } from "node:path";
13
- import { validateRelayUrl } from "../vendor/shared/index.js";
13
+ import { validateRelayUrl, DEFAULT_PROPR_GH_RELAY_URL } from "../vendor/shared/index.js";
14
14
  import { createConfigManager } from "../config/index.js";
15
15
  import { loadOrchestrator, resolveStackRoot } from "../orchestrator/index.js";
16
16
  import { upsertEnvVars } from "../utils/envFile.js";
17
- import { enrollRelayToken, listRelayTokens, revokeRelayToken, } from "../api/relay.js";
17
+ import { enrollRelayToken, fetchAuthenticatedUser, listRelayTokens, revokeRelayToken, } from "../api/relay.js";
18
18
  async function resolveContext(options) {
19
19
  const configManager = await createConfigManager();
20
20
  const rootDir = resolveStackRoot(configManager, options.root);
21
21
  const envPath = join(rootDir, ".env");
22
22
  const orch = await loadOrchestrator();
23
23
  const fileEnv = orch.readEnvFile(envPath);
24
- const relayBaseUrl = options.url ?? process.env.PROPR_GH_RELAY_URL ?? fileEnv.PROPR_GH_RELAY_URL;
25
- if (!relayBaseUrl) {
26
- throw new Error("No relay URL. Pass --url <https://relay/v1> or set PROPR_GH_RELAY_URL in .env (run `propr init stack` first).");
27
- }
24
+ // Falls back to the hosted relay (webhook.propr.dev) so `propr relay enroll`
25
+ // works out of the box; an explicit --url or PROPR_GH_RELAY_URL overrides it
26
+ // for self-hosted relays.
27
+ const relayBaseUrl = options.url ??
28
+ process.env.PROPR_GH_RELAY_URL ??
29
+ fileEnv.PROPR_GH_RELAY_URL ??
30
+ DEFAULT_PROPR_GH_RELAY_URL;
28
31
  const urlError = validateRelayUrl(relayBaseUrl);
29
32
  if (urlError) {
30
33
  throw new Error(urlError);
31
34
  }
32
- const installationId = options.installation ?? process.env.GH_INSTALLATION_ID ?? fileEnv.GH_INSTALLATION_ID;
33
- if (!installationId) {
34
- throw new Error("No installation id. Pass --installation <id> or set GH_INSTALLATION_ID in .env.");
35
- }
36
35
  const githubToken = configManager.getGithubToken();
37
36
  if (!githubToken) {
38
37
  throw new Error("Not logged in to GitHub. Run `propr login` first.");
39
38
  }
39
+ const client = { baseUrl: relayBaseUrl, githubToken };
40
+ // Explicit flag / env / .env win; otherwise ask the relay which installations
41
+ // this GitHub identity can access and auto-select when there's exactly one.
42
+ const installationId = options.installation ??
43
+ process.env.GH_INSTALLATION_ID ??
44
+ fileEnv.GH_INSTALLATION_ID ??
45
+ (await discoverInstallationId(client));
40
46
  return {
41
47
  rootDir,
42
48
  envPath,
43
49
  relayBaseUrl,
44
50
  installationId,
45
- client: { baseUrl: relayBaseUrl, githubToken },
51
+ client,
46
52
  };
47
53
  }
54
+ // Discovery fallback when no installation id was supplied: query the relay for
55
+ // the installations this GitHub identity can access. Auto-select the only one;
56
+ // otherwise fail with an actionable message (zero installs vs. ambiguous choice).
57
+ export async function discoverInstallationId(client) {
58
+ const { installations } = await fetchAuthenticatedUser(client);
59
+ if (installations.length === 1) {
60
+ const only = installations[0];
61
+ // To stderr, not stdout: `relay list --json` must emit only the JSON body,
62
+ // so this informational notice must stay out of the data stream.
63
+ console.error(`Using installation ${only.installation_id} (${only.account_login}) — the only one available to you.`);
64
+ return String(only.installation_id);
65
+ }
66
+ if (installations.length === 0) {
67
+ throw new Error("No GitHub App installation is available for your account. Install the shared ProPR GitHub App, then retry — or pass --installation <id>.");
68
+ }
69
+ const options = installations
70
+ .map((i) => ` ${i.installation_id} ${i.account_login} (${i.account_type})`)
71
+ .join("\n");
72
+ throw new Error(`Multiple installations are available; pass --installation <id> to choose one:\n${options}`);
73
+ }
48
74
  export function createRelayCommand() {
49
75
  const relay = new Command("relay")
50
76
  .description("Manage GitHub token relay enrollment (shared-app auth path)")
@@ -52,8 +78,15 @@ export function createRelayCommand() {
52
78
  The relay lets a shared-app stack obtain GitHub installation tokens without
53
79
  holding the App's private key. Enroll once; the token is saved to your .env.
54
80
 
81
+ The relay URL defaults to the hosted service (${DEFAULT_PROPR_GH_RELAY_URL});
82
+ pass --url only when running a self-hosted relay.
83
+
84
+ The installation id is discovered automatically when you have exactly one;
85
+ pass --installation <id> to disambiguate or override it.
86
+
55
87
  Examples:
56
- $ propr relay enroll --url https://relay.propr.dev/v1
88
+ $ propr relay enroll
89
+ $ propr relay enroll --url https://relay.example.com/v1
57
90
  $ propr relay list
58
91
  $ propr relay revoke <token-id>
59
92
  `);
@@ -0,0 +1,185 @@
1
+ /**
2
+ * Agent enablement + image-based authentication for `propr setup`.
3
+ *
4
+ * This runs as a setup step *after the stack is up* (the backend must be
5
+ * reachable to read and write agent configuration). It does three things, each
6
+ * non-destructively:
7
+ *
8
+ * 1. Reads the agents already configured in the running backend.
9
+ * 2. Adds any *selected* agent whose type is not yet configured, seeding it
10
+ * from the shared {@link AGENT_DEFAULTS} metadata (alias + supported
11
+ * models). Existing agents are never disabled, deleted, or re-aliased — a
12
+ * re-run only fills in what is missing.
13
+ * 3. For selected agents that support an interactive image login (see
14
+ * {@link planAgentLogin}), offers to authenticate through the agent's
15
+ * Docker image and runs the login only for the ones the user confirms.
16
+ *
17
+ * Like the engine, this module is UI-agnostic: the side effects live behind the
18
+ * injectable {@link AgentSetupActions} seam (tests pass mocks so the flow runs
19
+ * without Docker, the network, or a TTY) and the single user decision is
20
+ * collected through the optional {@link AgentSetupParams.confirmLogin} callback
21
+ * (a missing callback means "authenticate nothing", the safe default).
22
+ */
23
+ import { AGENT_DEFAULTS } from "../../vendor/shared/index.js";
24
+ /**
25
+ * Enable the selected agents in the running backend and, on confirmation,
26
+ * authenticate the ones that support an image login. Never throws for expected
27
+ * conditions — every failure is captured in {@link AgentSetupOutcome.errors} so
28
+ * the caller can settle the step as a warning rather than aborting setup.
29
+ */
30
+ export async function runAgentSetup(params) {
31
+ const { rootDir, selectedAgents, actions, confirmLogin, onLog } = params;
32
+ const outcome = {
33
+ added: [],
34
+ alreadyConfigured: [],
35
+ authenticated: [],
36
+ authFailed: [],
37
+ errors: [],
38
+ };
39
+ if (selectedAgents.length === 0)
40
+ return outcome;
41
+ // 1. Read the current backend configuration. Without it we cannot safely tell
42
+ // which agents are new, so a read failure stops here (nothing was changed).
43
+ let existing;
44
+ try {
45
+ existing = await actions.listAgents(rootDir);
46
+ }
47
+ catch (error) {
48
+ outcome.errors.push(`could not read backend agents: ${error.message}`);
49
+ return outcome;
50
+ }
51
+ // 2. Add the selected agents that are not yet configured. Match by type so we
52
+ // never add a second agent for a type the user already runs — existing
53
+ // agents (enabled or not) are left exactly as they are.
54
+ const configuredTypes = new Set(existing.map((agent) => agent.type));
55
+ for (const type of selectedAgents) {
56
+ if (configuredTypes.has(type)) {
57
+ outcome.alreadyConfigured.push(type);
58
+ continue;
59
+ }
60
+ const defaults = AGENT_DEFAULTS[type];
61
+ if (!defaults)
62
+ continue; // unknown type — guarded, but never trust the input
63
+ try {
64
+ onLog?.(`enabling agent ${type}…`);
65
+ // Seed from shared metadata: alias + the full supported-model set. The
66
+ // backend resolves the default docker image and host config path, so we
67
+ // don't pass them (a literal "~" path would otherwise reach the backend).
68
+ await actions.addAgent(rootDir, {
69
+ alias: defaults.defaultAlias,
70
+ type: type,
71
+ models: defaults.defaultModels,
72
+ enabled: true,
73
+ });
74
+ outcome.added.push(type);
75
+ configuredTypes.add(type);
76
+ }
77
+ catch (error) {
78
+ outcome.errors.push(`could not enable ${type}: ${error.message}`);
79
+ }
80
+ }
81
+ // 3. Image-based authentication — only for selected agents that actually have
82
+ // a login plan, and only for the ones the user confirms.
83
+ let loginable;
84
+ try {
85
+ loginable = new Set(await actions.loginableAgents());
86
+ }
87
+ catch (error) {
88
+ outcome.errors.push(`could not determine which agents support image login: ${error.message}`);
89
+ return outcome;
90
+ }
91
+ const candidates = selectedAgents.filter((type) => loginable.has(type));
92
+ if (candidates.length === 0 || !confirmLogin)
93
+ return outcome;
94
+ let chosen;
95
+ try {
96
+ chosen = await confirmLogin({ candidates, rootDir });
97
+ }
98
+ catch (error) {
99
+ // A failed/cancelled prompt must not abort the whole run — just skip login.
100
+ outcome.errors.push(`agent login prompt failed: ${error.message}`);
101
+ return outcome;
102
+ }
103
+ const chosenSet = new Set(chosen.filter((type) => loginable.has(type)));
104
+ // Iterate the candidate order (not the user's), so logins run in a stable order.
105
+ for (const type of candidates) {
106
+ if (!chosenSet.has(type))
107
+ continue;
108
+ try {
109
+ onLog?.(`authenticating ${type} through its image…`);
110
+ const result = await actions.loginAgent(rootDir, type);
111
+ if (result.detail)
112
+ onLog?.(result.detail);
113
+ if (result.available && result.success)
114
+ outcome.authenticated.push(type);
115
+ else
116
+ outcome.authFailed.push(type);
117
+ }
118
+ catch (error) {
119
+ outcome.authFailed.push(type);
120
+ outcome.errors.push(`login for ${type} failed: ${error.message}`);
121
+ }
122
+ }
123
+ return outcome;
124
+ }
125
+ /**
126
+ * Build the production {@link AgentSetupActions}, lazily importing the heavy
127
+ * orchestrator/API/validation modules only when an action runs — keeping the
128
+ * engine import cheap and Docker-free for tests, which replace these anyway.
129
+ */
130
+ export function createDefaultAgentSetupActions(configManager) {
131
+ /** A client pointed at the local stack's API port (not the saved remote URL). */
132
+ const localApiClient = async (rootDir) => {
133
+ const { getHostConfig } = await import("../../orchestrator/index.js");
134
+ const { cfg } = await getHostConfig({ configManager, root: rootDir });
135
+ const { createApiClient } = await import("../../api/client.js");
136
+ return createApiClient({ baseUrl: `http://localhost:${cfg.apiPort}` });
137
+ };
138
+ return {
139
+ async listAgents(rootDir) {
140
+ const { listAgents } = await import("../../api/agents.js");
141
+ const client = await localApiClient(rootDir);
142
+ const response = await listAgents(client);
143
+ return response.agents;
144
+ },
145
+ async addAgent(rootDir, options) {
146
+ const { addAgent } = await import("../../api/agents.js");
147
+ const client = await localApiClient(rootDir);
148
+ await addAgent(options, client);
149
+ },
150
+ async loginableAgents() {
151
+ const { loginableAgents } = await import("../agentValidation.js");
152
+ return loginableAgents();
153
+ },
154
+ async loginAgent(rootDir, type) {
155
+ const { mkdirSync, mkdtempSync, rmSync } = await import("node:fs");
156
+ const { tmpdir } = await import("node:os");
157
+ const { join } = await import("node:path");
158
+ const { spawnSync } = await import("node:child_process");
159
+ const { getHostConfig } = await import("../../orchestrator/index.js");
160
+ const { planAgentLogin } = await import("../agentValidation.js");
161
+ const { orch, cfg } = await getHostConfig({ configManager, root: rootDir });
162
+ const tmp = mkdtempSync(join(tmpdir(), "propr-setup-login-"));
163
+ const workspaceDir = join(tmp, "workspace");
164
+ mkdirSync(workspaceDir, { recursive: true });
165
+ try {
166
+ const { plan, error } = planAgentLogin(type, cfg, workspaceDir, orch.validateDockerBindPath);
167
+ if (error || !plan)
168
+ return { available: false, success: false, detail: error };
169
+ // The image must be present locally; setup pulls selected agent images
170
+ // earlier, but a failed pull would leave it absent.
171
+ if (orch.docker(["images", "-q", plan.image], { capture: true }).stdout.trim().length === 0) {
172
+ return { available: true, success: false, detail: `image ${plan.image} not present locally — run \`propr images pull\`` };
173
+ }
174
+ mkdirSync(plan.hostDir, { recursive: true, mode: 0o700 });
175
+ const res = spawnSync("docker", plan.dockerArgs, { stdio: "inherit" });
176
+ return res.status === 0
177
+ ? { available: true, success: true, detail: `${type} login finished — credentials written to ${plan.hostDir}` }
178
+ : { available: true, success: false, detail: `${type} login exited with code ${res.status ?? "?"}` };
179
+ }
180
+ finally {
181
+ rmSync(tmp, { recursive: true, force: true });
182
+ }
183
+ },
184
+ };
185
+ }