propr-cli 0.8.3 → 0.8.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (44) hide show
  1. package/README.md +4 -4
  2. package/dist/api/relay.js +10 -0
  3. package/dist/assets/env.example.txt +182 -59
  4. package/dist/auth/githubLogin.js +66 -0
  5. package/dist/commands/agentCommands.js +74 -0
  6. package/dist/commands/agentValidation.js +548 -0
  7. package/dist/commands/checkCommands.js +981 -76
  8. package/dist/commands/imageCommands.js +60 -0
  9. package/dist/commands/index.js +3 -0
  10. package/dist/commands/initStack.js +50 -1
  11. package/dist/commands/relayCommands.js +45 -12
  12. package/dist/commands/setup/agents.js +185 -0
  13. package/dist/commands/setup/engine.js +956 -0
  14. package/dist/commands/setup/github.js +181 -0
  15. package/dist/commands/setup/sequential.js +501 -0
  16. package/dist/commands/setup/state.js +242 -0
  17. package/dist/commands/setup/types.js +85 -0
  18. package/dist/commands/setupCommand.js +85 -0
  19. package/dist/commands/stackCommands.js +14 -2
  20. package/dist/commands/systemCommands.js +49 -2
  21. package/dist/commands/tunnelCommand.js +562 -0
  22. package/dist/config/ConfigManager.js +22 -0
  23. package/dist/config/types.js +1 -0
  24. package/dist/index.js +14 -45
  25. package/dist/orchestrator/format.js +46 -0
  26. package/dist/orchestrator/index.js +7 -2
  27. package/dist/orchestrator/manifest.json +12 -11
  28. package/dist/orchestrator/orchestrator.mjs +872 -73
  29. package/dist/tui/AgentTableApp.js +86 -0
  30. package/dist/tui/CheckApp.js +202 -0
  31. package/dist/tui/SetupApp.js +586 -0
  32. package/dist/tui/SetupApp.test.js +172 -0
  33. package/dist/tui/app.js +84 -0
  34. package/dist/tui/render.js +28 -2
  35. package/dist/utils/envFile.js +45 -0
  36. package/dist/vendor/shared/githubEventIntakeMode.js +41 -0
  37. package/dist/vendor/shared/index.js +17 -0
  38. package/dist/vendor/shared/intakeModePrerequisites.js +76 -0
  39. package/dist/vendor/shared/modelDefinitions.js +4 -4
  40. package/dist/vendor/shared/proprCompatibility.js +70 -0
  41. package/dist/vendor/shared/proprServiceUrls.js +124 -0
  42. package/dist/vendor/shared/statusKeys.js +14 -0
  43. package/dist/vendor/shared/validateRoutingUrl.js +46 -0
  44. package/package.json +3 -3
@@ -9,52 +9,126 @@ import { Command } from "commander";
9
9
  import { spawnSync } from "node:child_process";
10
10
  import { existsSync, accessSync, readFileSync, constants as fsConstants } from "node:fs";
11
11
  import { homedir } from "node:os";
12
- import { join } from "node:path";
13
- import { resolveGithubAuthMode, validateRelayUrl } from "../vendor/shared/index.js";
12
+ import { dirname, join, resolve } from "node:path";
13
+ import { fileURLToPath } from "node:url";
14
+ import { createInterface } from "node:readline/promises";
15
+ import { DEFAULT_PROPR_GH_RELAY_URL, resolveGithubAuthMode, resolveGithubEventIntakeMode, validateIntakeModePrerequisites, validateRelayUrl, } from "../vendor/shared/index.js";
14
16
  import { createConfigManager } from "../config/index.js";
15
- import { getHostConfig } from "../orchestrator/index.js";
17
+ import { createApiClient, getSystemStatus } from "../api/index.js";
18
+ import { getHostConfig, loadOrchestrator } from "../orchestrator/index.js";
19
+ import { upsertEnvVars } from "../utils/envFile.js";
16
20
  import { printOutput } from "../utils/index.js";
21
+ import { validateAgents, validateAgentFilter, validAgentTypes, agentRowsToChecks, getAgentTankUsage } from "./agentValidation.js";
17
22
  function agentDescriptors() {
18
23
  const home = homedir();
19
24
  return [
20
25
  { type: "claude", hostDirKey: "hostClaudeDir", envKey: "HOST_CLAUDE_DIR", defaultDir: join(home, ".claude"), imageKey: "agent-claude", bin: "claude" },
21
26
  { type: "codex", hostDirKey: "hostCodexDir", envKey: "HOST_CODEX_DIR", defaultDir: join(home, ".codex"), imageKey: "agent-codex", bin: "codex" },
22
- { type: "antigravity", hostDirKey: "hostAntigravityDir", envKey: "HOST_ANTIGRAVITY_DIR", defaultDir: join(home, ".gemini"), imageKey: "agent-antigravity", bin: "gemini" },
27
+ { type: "antigravity", hostDirKey: "hostAntigravityDir", envKey: "HOST_ANTIGRAVITY_DIR", defaultDir: join(home, ".gemini"), imageKey: "agent-antigravity", bin: "agy" },
23
28
  { type: "opencode", hostDirKey: "hostOpencodeXdgDir", envKey: "HOST_OPENCODE_XDG_DIR", defaultDir: join(home, ".config", "opencode"), imageKey: "agent-opencode", bin: "opencode" },
24
- { type: "opencode-legacy", hostDirKey: "hostOpencodeLegacyDir", envKey: "HOST_OPENCODE_LEGACY_DIR", defaultDir: join(home, ".opencode"), imageKey: "agent-opencode", bin: "opencode" },
25
29
  { type: "opencode-data", hostDirKey: "hostOpencodeDataDir", envKey: "HOST_OPENCODE_DATA_DIR", defaultDir: join(home, ".local", "share", "opencode"), imageKey: "agent-opencode", bin: "opencode" },
26
30
  { type: "vibe", hostDirKey: "hostVibeDir", envKey: "HOST_VIBE_DIR", defaultDir: join(home, ".vibe"), imageKey: "agent-vibe", bin: "vibe" },
27
31
  ];
28
32
  }
29
33
  export const STACK_CONFIG_CHECK_NAME = "Stack config (.env)";
30
34
  const STATUS_GLYPH = { ok: "✓", warn: "!", fail: "✗" };
35
+ const STATUS_LABEL = { ok: "OK", warn: "WARN", fail: "FAIL" };
36
+ export const CHECK_GROUPS = ["CLI", "Docker", "Stack", "Images", "Agents", "GitHub", "Configuration"];
37
+ // Display titles for section headers — more descriptive than the internal
38
+ // single-word CheckGroup keys (which stay stable for filtering/data).
39
+ export const GROUP_TITLES = {
40
+ CLI: "ProPR CLI",
41
+ Docker: "Docker Engine",
42
+ Stack: "Stack Configuration",
43
+ Images: "Container Images",
44
+ Agents: "Agent Credentials",
45
+ GitHub: "GitHub Authentication",
46
+ Configuration: "Environment Configuration",
47
+ };
48
+ // One-line, new-user-friendly explanation of what each section verifies.
49
+ export const GROUP_DESCRIPTIONS = {
50
+ CLI: "Local CLI version",
51
+ Docker: "Container engine that runs the stack and agents",
52
+ Stack: "Local stack root and .env configuration",
53
+ Images: "ProPR service and agent container images",
54
+ Agents: "Host credential directories mounted into agent containers",
55
+ GitHub: "Credentials the backend needs to access GitHub",
56
+ Configuration: "Environment variable validation",
57
+ };
58
+ const ANSI = {
59
+ reset: "\u001b[0m",
60
+ bold: "\u001b[1m",
61
+ dim: "\u001b[2m",
62
+ red: "\u001b[31m",
63
+ green: "\u001b[32m",
64
+ yellow: "\u001b[33m",
65
+ cyan: "\u001b[36m",
66
+ };
67
+ /** Read this CLI's version from package.json across TS source, workspace dist, and published dist layouts. */
68
+ function readCliVersion() {
69
+ const here = dirname(fileURLToPath(import.meta.url));
70
+ const candidates = [
71
+ join(here, "..", "..", "package.json"),
72
+ join(here, "..", "..", "..", "package.json"),
73
+ resolve(process.cwd(), "package.json"),
74
+ ];
75
+ try {
76
+ for (const pkgPath of candidates) {
77
+ if (!existsSync(pkgPath))
78
+ continue;
79
+ const pkg = JSON.parse(readFileSync(pkgPath, "utf8"));
80
+ if ((pkg.name === "@propr/cli" || pkg.name === "propr-cli" || pkg.name === "propr") && pkg.version) {
81
+ return pkg.version;
82
+ }
83
+ }
84
+ }
85
+ catch {
86
+ /* fall through */
87
+ }
88
+ return "0.0.0";
89
+ }
90
+ function runCliChecks(emit) {
91
+ emit({ name: "CLI version", status: "ok", detail: readCliVersion(), group: "CLI" });
92
+ }
31
93
  /** Run all checks and return the structured outcome (no printing). */
32
94
  export async function runChecks(options = {}) {
33
95
  const results = [];
96
+ // Record a finalized result and notify any live presenter immediately.
97
+ const emit = (result, opts = {}) => {
98
+ if (opts.record !== false)
99
+ results.push(result);
100
+ options.onResult?.(result);
101
+ };
34
102
  const configManager = await createConfigManager();
103
+ const skipRemoteImageCheck = Boolean(options.skipRemoteImageCheck || envSkipsRemoteImageCheck());
104
+ // 0. CLI version (local-only; `propr check` should not phone home by default).
105
+ runCliChecks(emit);
35
106
  // 1. Docker installed
36
107
  const dockerVersion = spawnSync("docker", ["--version"], { encoding: "utf-8" });
37
108
  if (dockerVersion.status === 0) {
38
- results.push({ name: "Docker installed", status: "ok", detail: dockerVersion.stdout.trim() });
109
+ emit({ name: "Docker installed", status: "ok", detail: dockerVersion.stdout.trim(), group: "Docker" });
39
110
  }
40
111
  else {
41
- results.push({
112
+ emit({
42
113
  name: "Docker installed",
43
114
  status: "fail",
44
115
  detail: "`docker` command not found",
116
+ group: "Docker",
45
117
  fix: "Install Docker: https://docs.docker.com/get-docker/",
46
118
  });
47
119
  }
48
120
  const { orch, cfg, rootDir } = await getHostConfig({ configManager, root: options.root });
49
121
  // 2. Docker daemon running
50
122
  const daemonUp = orch.dockerAvailable();
51
- results.push(daemonUp
52
- ? { name: "Docker daemon", status: "ok", detail: "daemon is reachable" }
123
+ emit(daemonUp
124
+ ? { name: "Docker daemon", status: "ok", detail: "daemon is reachable", group: "Docker" }
53
125
  : {
54
126
  name: "Docker daemon",
55
127
  status: "fail",
56
128
  detail: "cannot reach the Docker daemon (`docker info` failed)",
129
+ group: "Docker",
57
130
  fix: "Start Docker (e.g. `sudo systemctl start docker`) and ensure your user can access it.",
131
+ remediation: { kind: "start-docker" },
58
132
  });
59
133
  // 3. Docker socket (informational — only relevant for the default socket setup)
60
134
  const socketPath = "/var/run/docker.sock";
@@ -66,90 +140,184 @@ export async function runChecks(options = {}) {
66
140
  catch {
67
141
  accessible = false;
68
142
  }
69
- results.push(accessible
70
- ? { name: "Docker socket", status: "ok", detail: socketPath }
143
+ emit(accessible
144
+ ? { name: "Docker socket", status: "ok", detail: socketPath, group: "Docker" }
71
145
  : {
72
146
  name: "Docker socket",
73
147
  status: "warn",
74
148
  detail: `${socketPath} is not read/write for the current user`,
149
+ group: "Docker",
75
150
  fix: "Add your user to the `docker` group, or run with sufficient privileges.",
76
151
  });
77
152
  }
78
153
  // 4. Stack root + .env
79
154
  const envPath = join(rootDir, ".env");
80
155
  if (existsSync(envPath)) {
81
- results.push({ name: STACK_CONFIG_CHECK_NAME, status: "ok", detail: envPath });
156
+ emit({ name: STACK_CONFIG_CHECK_NAME, status: "ok", detail: envPath, group: "Stack" });
82
157
  }
83
158
  else {
84
- results.push({
159
+ emit({
85
160
  name: STACK_CONFIG_CHECK_NAME,
86
161
  status: "warn",
87
162
  detail: `no .env found at ${rootDir}`,
163
+ group: "Stack",
88
164
  fix: "Run `propr init stack` to scaffold .env, data/, logs/ and repos/.",
165
+ remediation: { kind: "init-stack", rootDir },
89
166
  });
90
167
  }
91
- // 5. Stack images present locally
168
+ // 5. Stack images present locally. The remote freshness probe is the slowest
169
+ // check, so it runs for every image concurrently (off the event loop) instead
170
+ // of serially — results are emitted live as each settles, then appended to the
171
+ // outcome in manifest order so non-streaming consumers stay deterministic.
92
172
  if (daemonUp) {
93
- for (const [key, tag] of Object.entries(cfg.images)) {
94
- if (key === "docs" && !cfg.docsEnabled)
95
- continue;
96
- const present = imagePresent(orch, tag);
97
- if (present) {
98
- results.push({ name: `Image ${key}`, status: "ok", detail: tag });
173
+ const missingImageResult = (key, tag) => ({
174
+ name: `Image ${key}`,
175
+ status: "warn",
176
+ detail: `${tag} not present locally`,
177
+ group: "Images",
178
+ fix: key.startsWith("agent-")
179
+ ? "Jobs using this agent fail until the image is pulled. Run `propr images pull`, `propr start`, or build with scripts/build-images.sh."
180
+ : "Run `propr images pull`, or let `propr start` pull it automatically.",
181
+ remediation: { kind: "pull-image", imageKey: key, tag },
182
+ });
183
+ // ProPR only publishes images in its own registry namespace (propr/*).
184
+ // Third-party images (e.g. redis:7-alpine) are pinned by tag and not part of
185
+ // ProPR's update story, so their registry "freshness" is not actionable here
186
+ // — and the remote digest probe for them is the main source of slow timeouts.
187
+ const registry = typeof cfg.manifest?.registry === "string" ? cfg.manifest.registry : "propr";
188
+ const isProprPublished = (tag) => tag.startsWith(`${registry}/`);
189
+ const freshnessByTag = new Map();
190
+ const computeImageResult = async (key, tag) => {
191
+ // Presence-only for third-party images and when remote checks are skipped.
192
+ if (skipRemoteImageCheck || !isProprPublished(tag)) {
193
+ if (!imagePresent(orch, tag))
194
+ return missingImageResult(key, tag);
195
+ const detail = skipRemoteImageCheck ? `${tag} (local; remote check skipped)` : `${tag} (present)`;
196
+ return { name: `Image ${key}`, status: "ok", detail, group: "Images" };
99
197
  }
100
- else {
101
- const isAgent = key.startsWith("agent-");
102
- results.push({
198
+ let freshnessPromise = freshnessByTag.get(tag);
199
+ if (!freshnessPromise) {
200
+ freshnessPromise = orch.inspectImageFreshnessAsync(tag);
201
+ freshnessByTag.set(tag, freshnessPromise);
202
+ }
203
+ const freshness = await freshnessPromise;
204
+ if (freshness.status === "missing")
205
+ return missingImageResult(key, tag);
206
+ if (freshness.status === "current") {
207
+ return { name: `Image ${key}`, status: "ok", detail: `${tag} (current)`, group: "Images" };
208
+ }
209
+ if (freshness.status === "stale") {
210
+ return {
103
211
  name: `Image ${key}`,
104
212
  status: "warn",
105
- detail: `${tag} not present locally`,
106
- fix: isAgent
107
- ? "Jobs using this agent fail until the image is pulled. `propr start` pulls it, or build with scripts/build-images.sh."
108
- : "Will be pulled automatically on `propr start`.",
109
- });
213
+ detail: `${tag} is stale; remote digest ${freshness.remoteDigest}`,
214
+ group: "Images",
215
+ fix: "Pull the updated image: `propr images pull`.",
216
+ remediation: { kind: "pull-image", imageKey: key, tag },
217
+ };
218
+ }
219
+ if (freshness.localOnly) {
220
+ return {
221
+ name: `Image ${key}`,
222
+ status: "warn",
223
+ detail: `${tag} is local-only; registry freshness not verified`,
224
+ group: "Images",
225
+ fix: "Replace the unverifiable local tag with the registry image: `propr images pull`.",
226
+ remediation: { kind: "pull-image", imageKey: key, tag },
227
+ };
110
228
  }
229
+ return {
230
+ name: `Image ${key}`,
231
+ status: "warn",
232
+ detail: `${tag} is present, but freshness could not be verified: ${freshness.error}`,
233
+ group: "Images",
234
+ fix: "Check registry access or rerun with --skip-remote-image-check for offline environments.",
235
+ };
236
+ };
237
+ const imageEntries = Object.entries(cfg.images).filter(([key]) => !(key === "docs" && !cfg.docsEnabled));
238
+ for (const [key] of imageEntries)
239
+ options.onPending?.({ name: `Image ${key}`, group: "Images" });
240
+ const computed = new Map();
241
+ await Promise.all(imageEntries.map(async ([key, tag]) => {
242
+ const result = await computeImageResult(key, tag);
243
+ computed.set(key, result);
244
+ emit(result, { record: false }); // live update in completion order
245
+ }));
246
+ // Append in manifest order so outcome.results is stable across runs.
247
+ for (const [key] of imageEntries) {
248
+ const result = computed.get(key);
249
+ if (result)
250
+ results.push(result);
111
251
  }
112
252
  }
113
253
  // 6. Agent credential dirs
114
254
  for (const agent of agentDescriptors()) {
115
255
  const configured = cfg[agent.hostDirKey];
116
256
  const dir = configured || agent.defaultDir;
117
- if (existsSync(dir)) {
118
- results.push({ name: `Agent creds: ${agent.type}`, status: "ok", detail: dir });
257
+ if (configured && existsSync(dir)) {
258
+ emit({ name: `Agent creds: ${agent.type}`, status: "ok", detail: dir, group: "Agents" });
259
+ }
260
+ else if (!configured && existsSync(agent.defaultDir)) {
261
+ emit({
262
+ name: `Agent creds: ${agent.type}`,
263
+ status: "warn",
264
+ detail: `${agent.defaultDir} detected but ${agent.envKey} is not set in .env`,
265
+ group: "Agents",
266
+ fix: `Add ${agent.envKey}=${agent.defaultDir} to .env so containers can mount these credentials.`,
267
+ remediation: { kind: "add-agent-credential", envKey: agent.envKey, path: agent.defaultDir, agentType: agent.type },
268
+ });
119
269
  }
120
270
  else {
121
- results.push({
271
+ emit({
122
272
  name: `Agent creds: ${agent.type}`,
123
273
  status: "warn",
124
274
  detail: `${dir} not found — ${agent.type} will not authenticate`,
275
+ group: "Agents",
125
276
  fix: `Log in with the ${agent.type} CLI on this host, or set ${agent.envKey} in .env.`,
126
277
  });
127
278
  }
128
279
  }
280
+ // 6b. Agent Tank (optional subscription-usage monitor). Presence only — the
281
+ // actual usage refresh (slow PTY /usage calls) runs in `propr check agents`.
282
+ if (spawnSync("which", ["agent-tank"], { encoding: "utf-8" }).status === 0) {
283
+ const ver = spawnSync("agent-tank", ["--version"], { encoding: "utf-8", timeout: 10000 });
284
+ const version = `${ver.stdout ?? ""}${ver.stderr ?? ""}`.match(/\d+\.\d+\.\d+/)?.[0];
285
+ emit({ name: "Agent Tank", status: "ok", detail: version ? `agent-tank ${version} installed` : "installed", group: "Agents" });
286
+ }
129
287
  // 7. GitHub credentials (the backend hard-exits without a valid auth mode)
130
288
  const fileEnv = existsSync(envPath) ? orch.readEnvFile(envPath) : {};
131
289
  for (const r of checkGithubAuth(fileEnv, cfg))
132
- results.push(r);
290
+ emit(r);
291
+ // 7b. Mode-specific GitHub intake prerequisites (the resolved intake mode
292
+ // needs the right credentials before the daemon/API can serve it).
293
+ for (const r of checkGithubIntakeMode(fileEnv))
294
+ emit(r);
295
+ // 7c. Routing intake diagnostics: routing URL plus live WebSocket state, last
296
+ // delivery id, and last ACK (when the backend is reachable) for the default
297
+ // routing_websocket path.
298
+ for (const r of await checkRoutingDiagnostics(fileEnv))
299
+ emit(r);
133
300
  // 8. User whitelist — warn when no whitelist is configured for non-demo stacks
134
301
  const whitelistRaw = process.env.GITHUB_USER_WHITELIST ?? fileEnv.GITHUB_USER_WHITELIST;
135
302
  const whitelistEntries = (whitelistRaw ?? "").split(",").map((s) => s.trim()).filter(Boolean);
136
303
  const authMode = (process.env.GH_AUTH_MODE ?? fileEnv.GH_AUTH_MODE ?? "").trim().toLowerCase();
137
304
  const isDemo = isTruthy(process.env.PROPR_DEMO_MODE ?? fileEnv.PROPR_DEMO_MODE) || authMode === "demo";
138
305
  if (whitelistEntries.length === 0 && !isDemo) {
139
- results.push({
306
+ emit({
140
307
  name: "User whitelist",
141
308
  status: "warn",
142
309
  detail: "GITHUB_USER_WHITELIST is not set — any GitHub user who can authenticate to this instance may trigger processing and use the API (within the App's repository access)",
310
+ group: "GitHub",
143
311
  fix: "Set GITHUB_USER_WHITELIST to a comma-separated list of allowed GitHub usernames in .env.",
144
312
  });
145
313
  }
146
314
  else if (whitelistEntries.length > 0) {
147
- results.push({ name: "User whitelist", status: "ok", detail: `${whitelistEntries.length} user(s) allowed` });
315
+ emit({ name: "User whitelist", status: "ok", detail: `${whitelistEntries.length} user(s) allowed`, group: "GitHub" });
148
316
  }
149
317
  // 9. Config validation from the orchestrator (bind paths, vibe dirs, etc.)
150
318
  const validation = orch.validateEnv(cfg);
151
319
  for (const warn of validation.warnings) {
152
- results.push({ name: "Config warning", status: "warn", detail: warn });
320
+ emit({ name: "Config warning", status: "warn", detail: warn, group: "Configuration" });
153
321
  }
154
322
  for (const err of validation.errors) {
155
323
  // env file / data dir absence is already surfaced by steps 4–6 above; skip duplicates.
@@ -157,7 +325,7 @@ export async function runChecks(options = {}) {
157
325
  continue;
158
326
  if (/data directory.*is not set/i.test(err))
159
327
  continue;
160
- results.push({ name: "Config error", status: "fail", detail: err });
328
+ emit({ name: "Config error", status: "fail", detail: err, group: "Configuration" });
161
329
  }
162
330
  // 10. Deep verify (opt-in): image/CLI smoke test per selected agent
163
331
  if (options.verify && daemonUp) {
@@ -167,22 +335,24 @@ export async function runChecks(options = {}) {
167
335
  for (const agent of selected) {
168
336
  const tag = cfg.images[agent.imageKey];
169
337
  if (!tag || !imagePresent(orch, tag)) {
170
- results.push({
338
+ emit({
171
339
  name: `Verify: ${agent.type}`,
172
340
  status: "warn",
173
341
  detail: `image ${tag ?? agent.imageKey} not present — skipped`,
342
+ group: "Agents",
174
343
  });
175
344
  continue;
176
345
  }
177
346
  const run = spawnSync("docker", ["run", "--rm", "--network=none", "--memory=512m", tag, agent.bin, "--version"], { encoding: "utf-8", timeout: 60000 });
178
347
  if (run.status === 0) {
179
- results.push({ name: `Verify: ${agent.type}`, status: "ok", detail: `image runs (${(run.stdout || "").trim().split("\n")[0]})` });
348
+ emit({ name: `Verify: ${agent.type}`, status: "ok", detail: `image runs (${(run.stdout || "").trim().split("\n")[0]})`, group: "Agents" });
180
349
  }
181
350
  else {
182
- results.push({
351
+ emit({
183
352
  name: `Verify: ${agent.type}`,
184
353
  status: "warn",
185
354
  detail: `image/CLI smoke test failed: ${(run.stderr || run.stdout || "").trim().split("\n")[0]}`,
355
+ group: "Agents",
186
356
  });
187
357
  }
188
358
  }
@@ -221,7 +391,10 @@ const RELAY_TOKEN_KEY = "PROPR_GH_RELAY_TOKEN";
221
391
  function checkGithubAuth(env, cfg) {
222
392
  const val = (k) => process.env[k] ?? env[k];
223
393
  const out = [];
224
- const relayUrl = val(RELAY_URL_KEY);
394
+ // PROPR_GH_RELAY_URL defaults to the hosted relay when unset, matching the
395
+ // backend (githubAuth) and the docs/.env: a token-only stack still infers relay
396
+ // mode here, so `propr check` cannot drift from boot behavior.
397
+ const relayUrl = val(RELAY_URL_KEY)?.trim() || DEFAULT_PROPR_GH_RELAY_URL;
225
398
  const relayToken = val(RELAY_TOKEN_KEY);
226
399
  const { mode, warnings } = resolveGithubAuthMode({
227
400
  demoMode: isTruthy(val("PROPR_DEMO_MODE")),
@@ -233,10 +406,10 @@ function checkGithubAuth(env, cfg) {
233
406
  installationId: val("GH_INSTALLATION_ID"),
234
407
  });
235
408
  for (const warning of warnings) {
236
- out.push({ name: "GitHub auth", status: "warn", detail: warning });
409
+ out.push({ name: "GitHub auth", status: "warn", detail: warning, group: "GitHub" });
237
410
  }
238
411
  if (mode === "demo") {
239
- out.push({ name: "GitHub auth", status: "ok", detail: "demo mode — GitHub access disabled" });
412
+ out.push({ name: "GitHub auth", status: "ok", detail: "demo mode — GitHub access disabled", group: "GitHub" });
240
413
  return out;
241
414
  }
242
415
  if (mode === "none") {
@@ -244,44 +417,48 @@ function checkGithubAuth(env, cfg) {
244
417
  name: "GitHub auth mode",
245
418
  status: "fail",
246
419
  detail: "no GitHub auth configured — the backend will exit at startup",
420
+ group: "GitHub",
247
421
  fix: "Set GH_APP_ID + GH_INSTALLATION_ID + a private key (own App), or PROPR_GH_RELAY_URL + PROPR_GH_RELAY_TOKEN (token relay), or PROPR_DEMO_MODE=true.",
248
422
  });
249
423
  return out;
250
424
  }
251
425
  if (mode === "relay") {
252
- const urlError = relayUrl ? validateRelayUrl(relayUrl) : `${RELAY_URL_KEY} must be set for relay mode`;
426
+ // relayUrl is always populated (defaulted to the hosted relay above); this only
427
+ // catches an explicitly-set but malformed PROPR_GH_RELAY_URL.
428
+ const urlError = validateRelayUrl(relayUrl);
253
429
  out.push(urlError
254
- ? { name: "GitHub auth mode", status: "fail", detail: urlError, fix: "Use an https:// relay URL (http only for localhost)." }
255
- : { name: "GitHub auth mode", status: "ok", detail: `token relay (${relayUrl})` });
430
+ ? { name: "GitHub auth mode", status: "fail", detail: urlError, group: "GitHub", fix: "Use an https:// relay URL (http only for localhost)." }
431
+ : { name: "GitHub auth mode", status: "ok", detail: `token relay (${relayUrl})`, group: "GitHub" });
256
432
  if (!relayToken) {
257
433
  out.push({
258
434
  name: "Relay credential",
259
435
  status: "fail",
260
436
  detail: `${RELAY_TOKEN_KEY} is not set`,
437
+ group: "GitHub",
261
438
  fix: `Set ${RELAY_TOKEN_KEY} to the relay credential issued for your installation.`,
262
439
  });
263
440
  }
264
441
  else {
265
- out.push({ name: "Relay credential", status: "ok", detail: `${RELAY_TOKEN_KEY} is set` });
442
+ out.push({ name: "Relay credential", status: "ok", detail: `${RELAY_TOKEN_KEY} is set`, group: "GitHub" });
266
443
  }
267
444
  return out;
268
445
  }
269
446
  // App mode (default).
270
- out.push({ name: "GitHub auth mode", status: "ok", detail: "GitHub App (own/shared app)" });
447
+ out.push({ name: "GitHub auth mode", status: "ok", detail: "GitHub App (own/shared app)", group: "GitHub" });
271
448
  const appId = val("GH_APP_ID");
272
449
  const installationId = val("GH_INSTALLATION_ID");
273
450
  out.push(isPlaceholder(appId)
274
- ? { name: "GH_APP_ID", status: "fail", detail: "missing or placeholder", fix: "Set GH_APP_ID from your GitHub App settings." }
275
- : { name: "GH_APP_ID", status: "ok", detail: appId });
451
+ ? { name: "GH_APP_ID", status: "fail", detail: "missing or placeholder", group: "GitHub", fix: "Set GH_APP_ID from your GitHub App settings." }
452
+ : { name: "GH_APP_ID", status: "ok", detail: appId, group: "GitHub" });
276
453
  out.push(isPlaceholder(installationId)
277
- ? { name: "GH_INSTALLATION_ID", status: "fail", detail: "missing or placeholder", fix: "Set GH_INSTALLATION_ID for the App's installation on your account/org." }
278
- : { name: "GH_INSTALLATION_ID", status: "ok", detail: installationId });
454
+ ? { name: "GH_INSTALLATION_ID", status: "fail", detail: "missing or placeholder", group: "GitHub", fix: "Set GH_INSTALLATION_ID for the App's installation on your account/org." }
455
+ : { name: "GH_INSTALLATION_ID", status: "ok", detail: installationId, group: "GitHub" });
279
456
  // Private key reachability. Prefer the explicit host mount (HOST_GH_PRIVATE_KEY).
280
457
  const hostKey = cfg.hostGhPrivateKey;
281
458
  const keyPath = val("GH_PRIVATE_KEY_PATH");
282
459
  if (hostKey) {
283
460
  if (!existsSync(hostKey)) {
284
- out.push({ name: "GitHub App key", status: "fail", detail: `HOST_GH_PRIVATE_KEY (${hostKey}) does not exist` });
461
+ out.push({ name: "GitHub App key", status: "fail", detail: `HOST_GH_PRIVATE_KEY (${hostKey}) does not exist`, group: "GitHub" });
285
462
  }
286
463
  else {
287
464
  let readable = true;
@@ -293,11 +470,12 @@ function checkGithubAuth(env, cfg) {
293
470
  }
294
471
  const looksLikePem = readable && /-----BEGIN [A-Z ]*PRIVATE KEY-----/.test(safeRead(hostKey));
295
472
  out.push(readable && looksLikePem
296
- ? { name: "GitHub App key", status: "ok", detail: `${hostKey} (mounted read-only)` }
473
+ ? { name: "GitHub App key", status: "ok", detail: `${hostKey} (mounted read-only)`, group: "GitHub" }
297
474
  : {
298
475
  name: "GitHub App key",
299
476
  status: "fail",
300
477
  detail: readable ? `${hostKey} does not look like a PEM private key` : `${hostKey} is not readable`,
478
+ group: "GitHub",
301
479
  });
302
480
  }
303
481
  }
@@ -306,6 +484,7 @@ function checkGithubAuth(env, cfg) {
306
484
  name: "GitHub App key",
307
485
  status: "fail",
308
486
  detail: "no private key configured",
487
+ group: "GitHub",
309
488
  fix: "Set HOST_GH_PRIVATE_KEY to your .pem host path (recommended), or stage the key under data/ and set GH_PRIVATE_KEY_PATH.",
310
489
  });
311
490
  }
@@ -314,11 +493,196 @@ function checkGithubAuth(env, cfg) {
314
493
  name: "GitHub App key",
315
494
  status: "warn",
316
495
  detail: `GH_PRIVATE_KEY_PATH=${keyPath} — reachability inside the container not verified`,
496
+ group: "GitHub",
317
497
  fix: "Prefer HOST_GH_PRIVATE_KEY (bind-mounts the key), or ensure this path resolves inside the container (e.g. under data/).",
318
498
  });
319
499
  }
320
500
  return out;
321
501
  }
502
+ /**
503
+ * Validate the prerequisites for the resolved GitHub event intake mode.
504
+ * Reuses the shared validateIntakeModePrerequisites helper so `propr check`
505
+ * and the backend boot path agree on what each mode requires.
506
+ */
507
+ function checkGithubIntakeMode(env) {
508
+ const val = (k) => process.env[k] ?? env[k];
509
+ const out = [];
510
+ // `propr check` is a diagnostic command: a bad value for one variable must
511
+ // surface as a structured failure, never abort the whole run. Both resolvers
512
+ // are therefore guarded — resolveGithubAuthMode is side-effect free today, but
513
+ // guarding it keeps the check resilient if its contract ever changes.
514
+ // Default the relay URL (token-only stacks rely on the hosted default) so the
515
+ // resolved auth mode matches the backend; see checkGithubAuth above.
516
+ const relayUrl = val(RELAY_URL_KEY)?.trim() || DEFAULT_PROPR_GH_RELAY_URL;
517
+ let authMode;
518
+ try {
519
+ ({ mode: authMode } = resolveGithubAuthMode({
520
+ demoMode: isTruthy(val("PROPR_DEMO_MODE")),
521
+ ghAuthMode: val("GH_AUTH_MODE"),
522
+ relayUrl,
523
+ relayToken: val(RELAY_TOKEN_KEY),
524
+ appId: val("GH_APP_ID"),
525
+ privateKeyPath: val("GH_PRIVATE_KEY_PATH"),
526
+ installationId: val("GH_INSTALLATION_ID"),
527
+ }));
528
+ }
529
+ catch (error) {
530
+ out.push({
531
+ name: "GitHub intake mode",
532
+ status: "fail",
533
+ detail: error instanceof Error ? error.message : String(error),
534
+ group: "GitHub",
535
+ fix: 'Set GH_AUTH_MODE to "app", "relay", or "demo" (or leave it unset to auto-detect).',
536
+ });
537
+ return out;
538
+ }
539
+ let intakeMode;
540
+ // Surface the resolver's own warnings (e.g. the ENABLE_GITHUB_WEBHOOKS
541
+ // deprecation notice the daemon/API log at boot) so `propr check` does not
542
+ // silently drop the migration feedback the backend would emit.
543
+ let resolverWarnings = [];
544
+ try {
545
+ ({ mode: intakeMode, warnings: resolverWarnings } = resolveGithubEventIntakeMode({
546
+ eventIntakeMode: val("GITHUB_EVENT_INTAKE_MODE"),
547
+ enableGithubWebhooks: val("ENABLE_GITHUB_WEBHOOKS"),
548
+ }));
549
+ }
550
+ catch (error) {
551
+ out.push({
552
+ name: "GitHub intake mode",
553
+ status: "fail",
554
+ detail: error instanceof Error ? error.message : String(error),
555
+ group: "GitHub",
556
+ fix: 'Set GITHUB_EVENT_INTAKE_MODE to "routing_websocket", "polling", or "direct_webhook".',
557
+ });
558
+ return out;
559
+ }
560
+ for (const warning of resolverWarnings) {
561
+ out.push({ name: "GitHub intake mode", status: "warn", detail: warning, group: "GitHub" });
562
+ }
563
+ const { valid, errors, warnings } = validateIntakeModePrerequisites({
564
+ intakeMode,
565
+ authMode,
566
+ routingUrl: val("PROPR_ROUTING_URL"),
567
+ relayUrl,
568
+ relayToken: val(RELAY_TOKEN_KEY),
569
+ webhookSecret: val("GH_WEBHOOK_SECRET"),
570
+ });
571
+ for (const warning of warnings) {
572
+ out.push({ name: "GitHub intake mode", status: "warn", detail: warning, group: "GitHub" });
573
+ }
574
+ for (const error of errors) {
575
+ out.push({ name: "GitHub intake mode", status: "fail", detail: error, group: "GitHub" });
576
+ }
577
+ if (valid) {
578
+ out.push({ name: "GitHub intake mode", status: "ok", detail: intakeMode, group: "GitHub" });
579
+ }
580
+ return out;
581
+ }
582
+ /**
583
+ * Surface the routing intake configuration and, when the backend is reachable,
584
+ * its live connection state. routing_websocket is the default intake mode, so
585
+ * `propr check` reports the routing URL plus the daemon's WebSocket connectivity,
586
+ * last delivery id, and last ACK to make a default deployment diagnosable.
587
+ *
588
+ * The live state is best-effort: it comes from GET /api/status (published there
589
+ * by the daemon), so a host check run before the stack is up simply omits it
590
+ * rather than failing.
591
+ */
592
+ async function checkRoutingDiagnostics(env) {
593
+ const val = (k) => process.env[k] ?? env[k];
594
+ const out = [];
595
+ let intakeMode;
596
+ try {
597
+ ({ mode: intakeMode } = resolveGithubEventIntakeMode({
598
+ eventIntakeMode: val("GITHUB_EVENT_INTAKE_MODE"),
599
+ enableGithubWebhooks: val("ENABLE_GITHUB_WEBHOOKS"),
600
+ }));
601
+ }
602
+ catch {
603
+ // An invalid mode is already reported by checkGithubIntakeMode; nothing to add.
604
+ return out;
605
+ }
606
+ // Routing diagnostics only apply to the routing_websocket intake path.
607
+ if (intakeMode !== "routing_websocket")
608
+ return out;
609
+ // Config-level routing URL (offline-safe). A missing/invalid URL is already
610
+ // reported as a failure by the mode prerequisites check, so only show it here
611
+ // when it is present to avoid duplicating that failure.
612
+ const routingUrl = val("PROPR_ROUTING_URL");
613
+ if (routingUrl && routingUrl.trim() !== "") {
614
+ out.push({ name: "Routing URL", status: "ok", detail: routingUrl, group: "GitHub" });
615
+ }
616
+ // Live routing state from the running backend (best-effort, short timeout).
617
+ // A stopped local backend rejects immediately (ECONNREFUSED); the timeout only
618
+ // bounds the wait when the configured API URL is reachable but slow, so keep it
619
+ // tight to avoid a noticeable stall during offline/pre-start checks.
620
+ try {
621
+ const client = await createApiClient({ defaultTimeout: 1000 });
622
+ const status = await getSystemStatus(client);
623
+ const routing = status.routing;
624
+ if (routing) {
625
+ out.push(routing.connected
626
+ ? { name: "Routing WebSocket", status: "ok", detail: "connected to relay", group: "GitHub" }
627
+ : {
628
+ name: "Routing WebSocket",
629
+ status: "warn",
630
+ detail: "disconnected — daemon is not connected to the routing relay",
631
+ group: "GitHub",
632
+ fix: "Check the daemon logs and that PROPR_ROUTING_URL / PROPR_GH_RELAY_TOKEN are valid.",
633
+ });
634
+ out.push({
635
+ name: "Last delivery ID",
636
+ status: "ok",
637
+ detail: routing.lastDeliveryId ?? "no deliveries received yet",
638
+ group: "GitHub",
639
+ });
640
+ out.push({
641
+ name: "Last ACK",
642
+ status: "ok",
643
+ detail: formatRoutingTimestamp(routing.lastAckAt),
644
+ group: "GitHub",
645
+ });
646
+ }
647
+ else {
648
+ // The backend answered but published no routing state. In routing_websocket
649
+ // mode that is the *default intake path*, so its absence is not benign: the
650
+ // daemon's routing publisher is not running (or the daemon is down), which
651
+ // means events are not being received and the path is not diagnosable. Warn
652
+ // rather than stay silent so a half-up routing deployment is visible.
653
+ out.push({
654
+ name: "Routing WebSocket",
655
+ status: "warn",
656
+ detail: "backend reachable but no routing state published — the daemon routing intake/publisher may not be running",
657
+ group: "GitHub",
658
+ fix: "Ensure the daemon is running in routing_websocket mode and check its logs and PROPR_ROUTING_URL / PROPR_GH_RELAY_TOKEN.",
659
+ });
660
+ }
661
+ }
662
+ catch {
663
+ // Backend not reachable (stack down or not logged in): live routing state is
664
+ // unavailable. This is the normal case for a pre-start / fresh-install check, so
665
+ // surface it as informational ("ok") rather than a warning — a valid offline
666
+ // setup should not light up a yellow routing warning just because the stack is
667
+ // not running yet. Live WebSocket state appears once the stack is up.
668
+ out.push({
669
+ name: "Routing WebSocket",
670
+ status: "ok",
671
+ detail: "live state unavailable — start the stack to read routing WebSocket state",
672
+ group: "GitHub",
673
+ });
674
+ }
675
+ return out;
676
+ }
677
+ // `lastAckAt` comes from a live Redis value the daemon publishes; a stale or
678
+ // malformed entry must not surface as "Invalid Date" in operator output. Parse it
679
+ // and fall back to the raw string when it is not a usable timestamp.
680
+ function formatRoutingTimestamp(value) {
681
+ if (!value)
682
+ return "no ACK sent yet";
683
+ const parsed = new Date(value);
684
+ return Number.isNaN(parsed.getTime()) ? value : parsed.toLocaleString();
685
+ }
322
686
  function safeRead(path) {
323
687
  try {
324
688
  return readFileSync(path, "utf-8").slice(0, 200);
@@ -327,53 +691,594 @@ function safeRead(path) {
327
691
  return "";
328
692
  }
329
693
  }
330
- /** Print a human-readable check table. */
694
+ function shouldUseColor() {
695
+ return Boolean(process.stdout.isTTY) && process.env.NO_COLOR === undefined;
696
+ }
697
+ function color(text, enabled, ...codes) {
698
+ return enabled ? `${codes.join("")}${text}${ANSI.reset}` : text;
699
+ }
700
+ function statusColor(status) {
701
+ if (status === "ok")
702
+ return ANSI.green;
703
+ if (status === "warn")
704
+ return ANSI.yellow;
705
+ return ANSI.red;
706
+ }
707
+ function formatStatus(status, colorEnabled) {
708
+ const text = `${STATUS_GLYPH[status]} ${STATUS_LABEL[status].padEnd(4)}`;
709
+ return color(text, colorEnabled, statusColor(status), ANSI.bold);
710
+ }
711
+ export function countStatuses(results) {
712
+ const counts = { ok: 0, warn: 0, fail: 0 };
713
+ for (const result of results)
714
+ counts[result.status]++;
715
+ return counts;
716
+ }
717
+ function envSkipsRemoteImageCheck(env = process.env) {
718
+ return env.PROPR_SKIP_REMOTE_IMAGE_CHECK === "true" || env.PROPR_SKIP_REMOTE_IMAGE_CHECK === "1";
719
+ }
720
+ function jsonResult(result) {
721
+ // JSON intentionally stays data-only/stable: UI grouping and remediation
722
+ // metadata are for human renderers and interactive prompts.
723
+ const out = {
724
+ name: result.name,
725
+ status: result.status,
726
+ detail: result.detail,
727
+ };
728
+ if (result.fix)
729
+ out.fix = result.fix;
730
+ return out;
731
+ }
732
+ export function plural(count, singular) {
733
+ return `${count} ${singular}${count === 1 ? "" : "s"}`;
734
+ }
735
+ function formatSummary(counts, colorEnabled) {
736
+ const failures = color(plural(counts.fail, "failure"), colorEnabled && counts.fail > 0, ANSI.red, ANSI.bold);
737
+ const warnings = color(plural(counts.warn, "warning"), colorEnabled && counts.warn > 0, ANSI.yellow, ANSI.bold);
738
+ const ok = color(`${counts.ok} ok`, colorEnabled, ANSI.green);
739
+ return `Summary: ${failures}, ${warnings}, ${ok}`;
740
+ }
741
+ function isInteractiveTerminal() {
742
+ return Boolean(process.stdin.isTTY && process.stdout.isTTY);
743
+ }
744
+ function warnBillableValidationJson() {
745
+ console.error("Warning: agent validation makes real, billable LLM calls even with --json. Restrict with --agents when needed.");
746
+ }
747
+ function printAgentValidationHint() {
748
+ console.log("");
749
+ console.log("To validate agents with live, billable LLM calls, run `propr check agents` or `propr check all`.");
750
+ }
751
+ /** Static, non-interactive renderer (pipes, CI, NO_COLOR). */
752
+ function printStaticChecks(outcome, showRemediationHint) {
753
+ printChecks(outcome);
754
+ if (showRemediationHint) {
755
+ console.log("");
756
+ console.log("Run `propr check --fix` to review interactive remediation options.");
757
+ }
758
+ }
759
+ /**
760
+ * Interactive TTY flow: render a live check pass, and (with --fix) loop —
761
+ * applying the selected remediation outside the Ink tree, then re-rendering a
762
+ * fresh pass — until the user quits or no actions remain. Falls back to the
763
+ * static renderer + readline prompts if the terminal can't drive the live UI.
764
+ */
765
+ async function runChecksInteractive(runOptions, fix, showAgentValidationHint) {
766
+ try {
767
+ const { renderLiveChecks } = await import("../tui/app.js");
768
+ let lastOutcome;
769
+ while (true) {
770
+ const { outcome, selectedKey } = await renderLiveChecks(runOptions, {
771
+ fix,
772
+ showAgentValidationHint,
773
+ getActions: collectRemediationActions,
774
+ });
775
+ lastOutcome = outcome ?? lastOutcome;
776
+ if (!fix || !selectedKey || !outcome)
777
+ return { outcome: lastOutcome };
778
+ const action = collectRemediationActions(outcome).find((a) => a.key === selectedKey);
779
+ if (!action)
780
+ return { outcome: lastOutcome };
781
+ console.log("");
782
+ const result = await action.run();
783
+ if (!result.ok) {
784
+ console.log("Remediation did not fully complete. Continuing with the current check results.");
785
+ }
786
+ if (!result.changed)
787
+ return { outcome: lastOutcome };
788
+ // Loop: re-run a fresh live pass to reflect changes and offer more fixes.
789
+ }
790
+ }
791
+ catch (error) {
792
+ if (!isLiveRendererFallbackError(error))
793
+ throw error;
794
+ // The terminal can't support the live UI (e.g. raw mode unavailable): fall
795
+ // back to the static renderer and readline-based prompts (no Ink to clobber).
796
+ const outcome = await runChecks(runOptions);
797
+ if (fix) {
798
+ printChecks(outcome);
799
+ return { outcome: await runRemediationPrompts(outcome, runOptions) };
800
+ }
801
+ printStaticChecks(outcome, collectRemediationActions(outcome).length > 0);
802
+ if (showAgentValidationHint)
803
+ printAgentValidationHint();
804
+ return { outcome };
805
+ }
806
+ }
807
+ function isLiveRendererFallbackError(error) {
808
+ const message = error instanceof Error ? error.message : String(error);
809
+ return /raw mode|setRawMode|stdin.*tty|not a tty|ink/i.test(message);
810
+ }
811
+ function collectRemediationActions(outcome) {
812
+ const actions = [];
813
+ const remediations = outcome.results
814
+ .filter((result) => result.status !== "ok")
815
+ .map((result) => result.remediation)
816
+ .filter((remediation) => Boolean(remediation));
817
+ if (remediations.some((remediation) => remediation.kind === "init-stack")) {
818
+ actions.push({
819
+ key: "init-stack",
820
+ label: "Show stack initialization guidance",
821
+ detail: `Create the stack root and .env with: propr init stack --root ${outcome.rootDir}`,
822
+ confirm: "Show stack initialization guidance?",
823
+ run: async () => {
824
+ console.log("");
825
+ console.log("Stack root/.env is missing. Run:");
826
+ console.log(` propr init stack --root ${outcome.rootDir}`);
827
+ console.log("Then review .env and run `propr check` again.");
828
+ return { changed: false, ok: true };
829
+ },
830
+ });
831
+ }
832
+ if (remediations.some((remediation) => remediation.kind === "start-docker")) {
833
+ actions.push({
834
+ key: "start-docker",
835
+ label: "Show Docker daemon guidance",
836
+ detail: "Print commands and checks for starting Docker or fixing daemon access.",
837
+ confirm: "Show Docker daemon guidance?",
838
+ run: async () => {
839
+ console.log("");
840
+ console.log("Docker is installed but the daemon is not reachable.");
841
+ console.log("Start Docker, then make sure this user can run `docker info` without failing.");
842
+ console.log("Common Linux command:");
843
+ console.log(" sudo systemctl start docker");
844
+ console.log("If the socket exists but access fails, add your user to the docker group and start a new shell.");
845
+ return { changed: false, ok: true };
846
+ },
847
+ });
848
+ }
849
+ const imageRemediations = remediations
850
+ .filter((remediation) => remediation.kind === "pull-image")
851
+ .filter((remediation, index, all) => all.findIndex((other) => other.tag === remediation.tag) === index);
852
+ if (imageRemediations.length > 0) {
853
+ actions.push({
854
+ key: "pull-images",
855
+ label: `Pull ${plural(imageRemediations.length, "Docker image")}`,
856
+ detail: imageRemediations.map((remediation) => remediation.tag).join(", "),
857
+ confirm: `Pull ${plural(imageRemediations.length, "Docker image")} now?`,
858
+ run: async () => pullMissingImages(imageRemediations),
859
+ });
860
+ }
861
+ const credentialRemediations = remediations
862
+ .filter((remediation) => remediation.kind === "add-agent-credential")
863
+ .filter((remediation) => existsSync(remediation.path))
864
+ .filter((remediation, index, all) => all.findIndex((other) => other.envKey === remediation.envKey) === index);
865
+ if (credentialRemediations.length > 0 && existsSync(outcome.cfg.envFileLocal)) {
866
+ actions.push({
867
+ key: "add-agent-credentials",
868
+ label: `Add ${plural(credentialRemediations.length, "detected agent credential directory")} to .env`,
869
+ detail: credentialRemediations.map((remediation) => `${remediation.envKey}=${remediation.path}`).join(", "),
870
+ confirm: `Write ${plural(credentialRemediations.length, "agent credential directory")} to ${outcome.cfg.envFileLocal}?`,
871
+ run: async () => addAgentCredentials(outcome, credentialRemediations),
872
+ });
873
+ }
874
+ return actions;
875
+ }
876
+ async function pullMissingImages(remediations) {
877
+ let changed = false;
878
+ let ok = true;
879
+ const orch = await loadOrchestrator();
880
+ for (const remediation of remediations) {
881
+ console.log(`Pulling ${remediation.tag}...`);
882
+ const pulled = orch.docker(["pull", remediation.tag], { capture: true });
883
+ if (pulled.status === 0) {
884
+ changed = true;
885
+ try {
886
+ orch.tagAgentLatest(remediation.imageKey, remediation.tag);
887
+ console.log(` ok: ${remediation.tag}`);
888
+ }
889
+ catch (error) {
890
+ ok = false;
891
+ console.error(` failed: ${remediation.tag}: ${error.message}`);
892
+ }
893
+ }
894
+ else {
895
+ ok = false;
896
+ const reason = (pulled.stderr || pulled.stdout || "docker pull failed").trim().split("\n")[0];
897
+ console.error(` failed: ${remediation.tag}: ${reason}`);
898
+ }
899
+ }
900
+ return { changed, ok };
901
+ }
902
+ async function addAgentCredentials(outcome, remediations) {
903
+ const vars = {};
904
+ for (const remediation of remediations) {
905
+ if (existsSync(remediation.path)) {
906
+ vars[remediation.envKey] = remediation.path;
907
+ }
908
+ }
909
+ if (Object.keys(vars).length === 0) {
910
+ console.log("No detected credential directories still exist on this host.");
911
+ return { changed: false, ok: false };
912
+ }
913
+ upsertEnvVars(outcome.cfg.envFileLocal, vars);
914
+ console.log(`Updated ${outcome.cfg.envFileLocal}:`);
915
+ for (const [key, value] of Object.entries(vars)) {
916
+ console.log(` ${key}=${value}`);
917
+ }
918
+ return { changed: true, ok: true };
919
+ }
920
+ async function confirmAction(rl, prompt) {
921
+ const answer = (await rl.question(`${prompt} [y/N] `)).trim().toLowerCase();
922
+ return answer === "y" || answer === "yes";
923
+ }
924
+ async function promptForAction(rl, actions) {
925
+ console.log("");
926
+ console.log("Available remediations:");
927
+ actions.forEach((action, index) => {
928
+ console.log(` ${index + 1}. ${action.label}`);
929
+ console.log(` ${action.detail}`);
930
+ });
931
+ console.log(" q. Quit");
932
+ const answer = (await rl.question("Choose an action: ")).trim().toLowerCase();
933
+ if (answer === "" || answer === "q" || answer === "quit")
934
+ return undefined;
935
+ const selected = Number(answer);
936
+ if (!Number.isInteger(selected) || selected < 1 || selected > actions.length) {
937
+ console.log("Invalid selection.");
938
+ return promptForAction(rl, actions);
939
+ }
940
+ return actions[selected - 1];
941
+ }
942
+ async function runRemediationPrompts(outcome, options) {
943
+ let current = outcome;
944
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
945
+ try {
946
+ while (true) {
947
+ const actions = collectRemediationActions(current);
948
+ if (actions.length === 0) {
949
+ console.log("");
950
+ console.log("No actionable remediations found.");
951
+ return current;
952
+ }
953
+ const action = await promptForAction(rl, actions);
954
+ if (!action)
955
+ return current;
956
+ if (!(await confirmAction(rl, action.confirm))) {
957
+ console.log("Skipped.");
958
+ continue;
959
+ }
960
+ const result = await action.run();
961
+ if (!result.ok) {
962
+ console.log("Remediation did not fully complete. Continuing with the current check results.");
963
+ }
964
+ if (result.changed) {
965
+ current = await runChecks(options);
966
+ printChecks(current);
967
+ }
968
+ }
969
+ }
970
+ finally {
971
+ rl.close();
972
+ }
973
+ }
974
+ /** Print human-readable checks grouped by subsystem. */
331
975
  export function printChecks(outcome) {
976
+ const colorEnabled = shouldUseColor();
977
+ const counts = countStatuses(outcome.results);
332
978
  console.log("");
333
- console.log(`ProPR environment check (stack root: ${outcome.rootDir})`);
979
+ console.log(`${color("ProPR environment check", colorEnabled, ANSI.bold)} ${color(`(stack root: ${outcome.rootDir})`, colorEnabled, ANSI.dim)}`);
980
+ console.log(formatSummary(counts, colorEnabled));
334
981
  console.log("─".repeat(60));
335
- for (const r of outcome.results) {
336
- const glyph = STATUS_GLYPH[r.status];
337
- console.log(`${glyph} ${r.name.padEnd(24)} ${r.detail}`);
338
- if (r.fix && r.status !== "ok") {
339
- console.log(` ↳ ${r.fix}`);
982
+ let printedGroup = false;
983
+ for (const group of CHECK_GROUPS) {
984
+ const groupResults = outcome.results.filter((result) => result.group === group);
985
+ if (groupResults.length === 0)
986
+ continue;
987
+ const groupCounts = countStatuses(groupResults);
988
+ const nameWidth = Math.max(18, ...groupResults.map((result) => result.name.length));
989
+ if (printedGroup)
990
+ console.log("");
991
+ printedGroup = true;
992
+ const countSuffix = groupCounts.fail > 0 || groupCounts.warn > 0 ? ` (${plural(groupCounts.fail, "failure")}, ${plural(groupCounts.warn, "warning")})` : "";
993
+ console.log(color(`${GROUP_TITLES[group]}${countSuffix}`, colorEnabled, ANSI.cyan, ANSI.bold));
994
+ console.log(color(` ${GROUP_DESCRIPTIONS[group]}`, colorEnabled, ANSI.dim));
995
+ for (const r of groupResults) {
996
+ console.log(` ${formatStatus(r.status, colorEnabled)} ${r.name.padEnd(nameWidth)} ${r.detail}`);
997
+ if (r.fix && r.status !== "ok") {
998
+ console.log(` ${color("↳", colorEnabled, ANSI.dim)} ${r.fix}`);
999
+ }
1000
+ }
1001
+ }
1002
+ const ungrouped = outcome.results.filter((result) => !result.group);
1003
+ if (ungrouped.length > 0) {
1004
+ const nameWidth = Math.max(18, ...ungrouped.map((result) => result.name.length));
1005
+ if (printedGroup)
1006
+ console.log("");
1007
+ console.log(color("Other", colorEnabled, ANSI.cyan, ANSI.bold));
1008
+ for (const r of ungrouped) {
1009
+ console.log(` ${formatStatus(r.status, colorEnabled)} ${r.name.padEnd(nameWidth)} ${r.detail}`);
1010
+ if (r.fix && r.status !== "ok") {
1011
+ console.log(` ${color("↳", colorEnabled, ANSI.dim)} ${r.fix}`);
1012
+ }
340
1013
  }
341
1014
  }
342
1015
  console.log("─".repeat(60));
343
- const counts = { ok: 0, warn: 0, fail: 0 };
344
- for (const r of outcome.results)
345
- counts[r.status]++;
346
- console.log(`${counts.ok} ok, ${counts.warn} warning(s), ${counts.fail} failure(s)`);
1016
+ console.log(formatSummary(counts, colorEnabled));
1017
+ }
1018
+ /** Status badge for an agent cell: plain text + optional ANSI color code. */
1019
+ function agentBadge(cell) {
1020
+ if (!cell)
1021
+ return { text: "n/a" };
1022
+ if (cell.status === "ok")
1023
+ return { text: "✓ ok", code: ANSI.green };
1024
+ if (cell.status === "fail")
1025
+ return { text: "✗ fail", code: ANSI.red };
1026
+ return { text: "— skip", code: ANSI.yellow };
1027
+ }
1028
+ const DASH = "—";
1029
+ const driftText = (r) => r.drift ?? (r.hostVersion && r.imageVersion ? "same" : "");
1030
+ /** Static (non-TTY) agent table — the live equivalent is AgentTableApp. */
1031
+ function printAgentTable(rows, colorEnabled) {
1032
+ const pad = (s, w) => s.padEnd(w);
1033
+ const colored = (text, w, code) => (code ? color(pad(text, w), colorEnabled, code) : pad(text, w));
1034
+ const w = {
1035
+ agent: Math.max("Agent".length, ...rows.map((r) => r.type.length)),
1036
+ host: Math.max("Host ver".length, ...rows.map((r) => (r.hostVersion ?? DASH).length)),
1037
+ image: Math.max("Image ver".length, ...rows.map((r) => (r.imageVersion ?? DASH).length)),
1038
+ drift: Math.max("Drift".length, ...rows.map((r) => driftText(r).length)),
1039
+ hstat: Math.max("Host".length, ...rows.map((r) => agentBadge(r.host).text.length)),
1040
+ };
1041
+ console.log("");
1042
+ console.log(` ${color([pad("Agent", w.agent), pad("Host ver", w.host), pad("Image ver", w.image), pad("Drift", w.drift), pad("Host", w.hstat), "Image"].join(" "), colorEnabled, ANSI.bold)}`);
1043
+ for (const r of rows) {
1044
+ const hb = agentBadge(r.host);
1045
+ const ib = agentBadge(r.image);
1046
+ const drift = driftText(r);
1047
+ const driftCode = drift === "older" ? ANSI.yellow : drift && drift !== "same" ? ANSI.dim : undefined;
1048
+ console.log(` ${pad(r.type, w.agent)} ${pad(r.hostVersion ?? DASH, w.host)} ${pad(r.imageVersion ?? DASH, w.image)} ${colored(drift, w.drift, driftCode)} ${colored(hb.text, w.hstat, hb.code)} ${colored(ib.text, ib.text.length, ib.code)}`);
1049
+ }
1050
+ }
1051
+ /** Raw host/image responses (and errors) per agent, for debugging. */
1052
+ function printAgentResponses(rows, colorEnabled) {
1053
+ const agentW = Math.max("Agent".length, ...rows.map((r) => r.type.length));
1054
+ const pad = (s, len) => s.padEnd(len);
1055
+ console.log("");
1056
+ console.log(` ${color("Responses", colorEnabled, ANSI.bold)}`);
1057
+ for (const r of rows) {
1058
+ const entries = [];
1059
+ if (r.host)
1060
+ entries.push(["host", r.host]);
1061
+ entries.push(["image", r.image]);
1062
+ entries.forEach(([level, cell], i) => {
1063
+ console.log(` ${pad(i === 0 ? r.type : "", agentW)} ${pad(level, 5)} ${cell.detail}`);
1064
+ if (cell.fix)
1065
+ console.log(` ${pad("", agentW)} ${pad("", 5)} ${color("↳", colorEnabled, ANSI.dim)} ${cell.fix}`);
1066
+ });
1067
+ }
1068
+ }
1069
+ /** Render Agent Tank subscription usage (or an install hint when absent). */
1070
+ function printAgentTankUsage(usage, colorEnabled) {
1071
+ console.log("");
1072
+ console.log(color("Subscription Usage (Agent Tank)", colorEnabled, ANSI.cyan, ANSI.bold));
1073
+ if (!usage.installed) {
1074
+ console.log(color(" Not installed — track Claude/Codex/Antigravity plan usage per task with:", colorEnabled, ANSI.dim));
1075
+ console.log(" npm install -g agent-tank");
1076
+ return;
1077
+ }
1078
+ console.log(color(` agent-tank ${usage.version ?? ""}`.trimEnd(), colorEnabled, ANSI.dim));
1079
+ if (usage.error) {
1080
+ console.log(` ${color("!", colorEnabled, ANSI.yellow)} could not read usage: ${usage.error}`);
1081
+ return;
1082
+ }
1083
+ const agents = Object.keys(usage.usage ?? {});
1084
+ if (agents.length === 0) {
1085
+ console.log(color(" No agents reported.", colorEnabled, ANSI.dim));
1086
+ return;
1087
+ }
1088
+ const nameWidth = Math.max(5, ...agents.map((a) => a.length));
1089
+ for (const name of agents) {
1090
+ const entry = usage.usage[name];
1091
+ if (entry?.error) {
1092
+ console.log(` ${name.padEnd(nameWidth)} ${color(entry.error, colorEnabled, ANSI.yellow)}`);
1093
+ continue;
1094
+ }
1095
+ const metrics = Object.values(entry?.usage ?? {});
1096
+ if (metrics.length === 0) {
1097
+ console.log(` ${name.padEnd(nameWidth)} (no data)`);
1098
+ continue;
1099
+ }
1100
+ metrics.forEach((m, i) => {
1101
+ const label = m.label ?? "usage";
1102
+ const pct = m.percent ?? m.percentUsed;
1103
+ const resets = m.resetsIn ? ` (resets ${m.resetsIn})` : "";
1104
+ console.log(` ${(i === 0 ? name : "").padEnd(nameWidth)} ${label}: ${pct ?? "?"}%${resets}`);
1105
+ });
1106
+ }
1107
+ }
1108
+ /**
1109
+ * Run agent validation and print results. Uses a live, streaming table on an
1110
+ * interactive terminal; a static table otherwise. Raw responses follow. Returns
1111
+ * true if any agent failed. Loads its own orchestrator/config so it can run
1112
+ * after the main check.
1113
+ */
1114
+ async function runAndPrintValidation(runOptions) {
1115
+ const colorEnabled = shouldUseColor();
1116
+ const configManager = await createConfigManager();
1117
+ const { orch, cfg } = await getHostConfig({ configManager, root: runOptions.root });
1118
+ console.log("");
1119
+ console.log(color("Agent Validation", colorEnabled, ANSI.cyan, ANSI.bold));
1120
+ console.log(color(" Live test calls for configured agents (host CLI + image) to confirm auth works", colorEnabled, ANSI.dim));
1121
+ console.log("");
1122
+ // Read Agent Tank subscription usage concurrently with the validation (it is
1123
+ // slow and independent), then render it after the responses.
1124
+ const tankUsagePromise = getAgentTankUsage();
1125
+ let rows;
1126
+ const runStaticValidation = async () => {
1127
+ rows = await validateAgents(orch, cfg, {
1128
+ agents: runOptions.agents,
1129
+ onProgress: (message) => console.log(color(` … ${message}`, colorEnabled, ANSI.dim)),
1130
+ });
1131
+ if (rows.length > 0)
1132
+ printAgentTable(rows, colorEnabled);
1133
+ return rows;
1134
+ };
1135
+ if (isInteractiveTerminal() && process.env.NO_COLOR === undefined) {
1136
+ try {
1137
+ const { renderAgentValidation } = await import("../tui/app.js");
1138
+ rows = await renderAgentValidation(orch, cfg, runOptions.agents);
1139
+ }
1140
+ catch (error) {
1141
+ if (!isLiveRendererFallbackError(error))
1142
+ throw error;
1143
+ rows = await runStaticValidation();
1144
+ }
1145
+ }
1146
+ else {
1147
+ rows = await runStaticValidation();
1148
+ }
1149
+ if (rows.length === 0)
1150
+ return false;
1151
+ printAgentResponses(rows, colorEnabled);
1152
+ printAgentTankUsage(await tankUsagePromise, colorEnabled);
1153
+ return rows.some((r) => r.host?.status === "fail" || r.image.status === "fail");
347
1154
  }
348
1155
  /** Creates the `check` command. */
349
1156
  export function createCheckCommand() {
350
1157
  return new Command("check")
351
- .description("Verify the host is ready to run a local ProPR stack (Docker, images, agents)")
1158
+ .description("Verify the host is ready to run a local ProPR stack")
1159
+ .argument("[mode]", "what to check: system (default) | agents | all", "system")
352
1160
  .option("--root <dir>", "Stack root directory (where .env/data/logs/repos live)")
353
1161
  .option("--verify", "Also run an image/CLI smoke test for each agent (slower)")
354
- .option("--agents <list>", "Comma-separated agent types to --verify (default: all)")
1162
+ .option("--agents <list>", "Comma-separated agent types to validate (default: configured stack agents)")
1163
+ .option("--skip-remote-image-check", "Skip registry image freshness checks (also set by PROPR_SKIP_REMOTE_IMAGE_CHECK=1)")
355
1164
  .option("--json", "Output raw JSON")
1165
+ .option("--fix", "Offer interactive remediation prompts for actionable issues")
1166
+ .option("--validate-agents", "Append live agent validation to a system check (makes billable LLM calls; same as `check all`)")
356
1167
  .addHelpText("after", `
1168
+ Modes:
1169
+ system Docker, images, stack, agent credentials, GitHub, config (default)
1170
+ agents Live test calls for configured agents (host CLI + image); makes billable LLM calls
1171
+ all Everything: system checks followed by billable agent validation
1172
+
357
1173
  Examples:
358
- $ propr check
359
- $ propr check --verify
360
- $ propr check --verify --agents claude,codex
1174
+ $ propr check # system checks
1175
+ $ propr check agents # only validate agents (billable LLM calls)
1176
+ $ propr check all # system checks + billable agent validation
1177
+ $ propr check --fix
1178
+ $ propr check agents --agents claude,codex
361
1179
  $ propr check --json
1180
+
1181
+ Notes:
1182
+ "check all", "check agents", and --validate-agents run a real prompt through
1183
+ configured agents' host CLIs and Docker images (mounts credentials, makes
1184
+ billable LLM calls). This is also true with --json. Override with --agents.
1185
+ Use --skip-remote-image-check or PROPR_SKIP_REMOTE_IMAGE_CHECK=1 for offline image checks.
362
1186
  `)
363
- .action(async (options) => {
1187
+ .action(async (mode, options) => {
364
1188
  try {
365
- const outcome = await runChecks({
1189
+ const MODES = ["system", "agents", "all"];
1190
+ if (!MODES.includes(mode)) {
1191
+ console.error(`Error: unknown check mode '${mode}'. Use one of: ${MODES.join(", ")}.`);
1192
+ process.exit(1);
1193
+ }
1194
+ if (options.json && options.fix) {
1195
+ console.error("Error: --json cannot be combined with --fix; JSON output is data-only and never prompts.");
1196
+ process.exit(1);
1197
+ }
1198
+ const runOptions = {
366
1199
  root: options.root,
367
1200
  verify: options.verify,
368
1201
  agents: options.agents ? options.agents.split(",").map((s) => s.trim()).filter(Boolean) : undefined,
369
- });
1202
+ skipRemoteImageCheck: options.skipRemoteImageCheck,
1203
+ };
1204
+ const { agents: agentFilter, unknown } = validateAgentFilter(runOptions.agents);
1205
+ if (unknown.length > 0) {
1206
+ console.error(`Error: unknown agent type${unknown.length === 1 ? "" : "s"} '${unknown.join(", ")}'. Valid agents: ${validAgentTypes().join(", ")}.`);
1207
+ process.exit(1);
1208
+ }
1209
+ runOptions.agents = agentFilter.length ? agentFilter : undefined;
1210
+ // `check agents`: only agent validation, no system checks.
1211
+ if (mode === "agents") {
1212
+ if (options.fix && !options.json) {
1213
+ console.error("Note: --fix has no remediation flow for `propr check agents`; running validation only.");
1214
+ }
1215
+ if (options.json) {
1216
+ warnBillableValidationJson();
1217
+ const { cfg, rootDir, orch } = await getHostConfig({ configManager: await createConfigManager(), root: runOptions.root });
1218
+ const rows = await validateAgents(orch, cfg, { agents: runOptions.agents });
1219
+ const results = agentRowsToChecks(rows);
1220
+ printOutput({ rootDir, results: results.map(jsonResult) }, true);
1221
+ if (results.some((r) => r.status === "fail"))
1222
+ process.exit(1);
1223
+ return;
1224
+ }
1225
+ if (await runAndPrintValidation(runOptions))
1226
+ process.exit(1);
1227
+ return;
1228
+ }
1229
+ if (options.fix && !isInteractiveTerminal()) {
1230
+ console.error("Error: --fix requires an interactive terminal.");
1231
+ process.exit(1);
1232
+ }
1233
+ // `check` / `check system` / `check all`. Agents run when mode=all or --validate-agents.
1234
+ const runAgents = mode === "all" || Boolean(options.validateAgents);
1235
+ // JSON: data-only; agent results merged in when requested.
370
1236
  if (options.json) {
371
- printOutput({ rootDir: outcome.rootDir, results: outcome.results }, true);
1237
+ const outcome = await runChecks(runOptions);
1238
+ let results = outcome.results;
1239
+ if (runAgents) {
1240
+ warnBillableValidationJson();
1241
+ const { orch, cfg } = await getHostConfig({ configManager: await createConfigManager(), root: runOptions.root });
1242
+ const rows = await validateAgents(orch, cfg, { agents: runOptions.agents });
1243
+ results = [...results, ...agentRowsToChecks(rows)];
1244
+ }
1245
+ printOutput({ rootDir: outcome.rootDir, results: results.map(jsonResult) }, true);
1246
+ if (results.some((r) => r.status === "fail"))
1247
+ process.exit(1);
1248
+ return;
372
1249
  }
373
- else {
374
- printChecks(outcome);
1250
+ // Non-interactive (pipes, CI, NO_COLOR): static one-shot report.
1251
+ // With --fix on a TTY, NO_COLOR only disables Ink/color; readline prompts still run.
1252
+ if (!isInteractiveTerminal() || process.env.NO_COLOR !== undefined) {
1253
+ const outcome = await runChecks(runOptions);
1254
+ if (options.fix) {
1255
+ printChecks(outcome);
1256
+ const remediated = await runRemediationPrompts(outcome, runOptions);
1257
+ let anyFail = remediated.anyFail;
1258
+ if (runAgents)
1259
+ anyFail = (await runAndPrintValidation(runOptions)) || anyFail;
1260
+ if (anyFail)
1261
+ process.exit(1);
1262
+ return;
1263
+ }
1264
+ printStaticChecks(outcome, !options.fix && collectRemediationActions(outcome).length > 0);
1265
+ let anyFail = outcome.anyFail;
1266
+ if (runAgents)
1267
+ anyFail = (await runAndPrintValidation(runOptions)) || anyFail;
1268
+ else
1269
+ printAgentValidationHint();
1270
+ if (anyFail)
1271
+ process.exit(1);
1272
+ return;
1273
+ }
1274
+ // Interactive TTY: live, streaming view (+ in-app remediation with --fix,
1275
+ // plus a hint showing how to opt into billable agent validation).
1276
+ const { outcome } = await runChecksInteractive(runOptions, Boolean(options.fix), !options.fix && !runAgents);
1277
+ let anyFail = outcome?.anyFail ?? false;
1278
+ if (runAgents) {
1279
+ anyFail = (await runAndPrintValidation(runOptions)) || anyFail;
375
1280
  }
376
- if (outcome.anyFail)
1281
+ if (anyFail)
377
1282
  process.exit(1);
378
1283
  }
379
1284
  catch (error) {