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
|
@@ -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 {
|
|
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 {
|
|
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: "
|
|
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
|
-
|
|
109
|
+
emit({ name: "Docker installed", status: "ok", detail: dockerVersion.stdout.trim(), group: "Docker" });
|
|
39
110
|
}
|
|
40
111
|
else {
|
|
41
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
156
|
+
emit({ name: STACK_CONFIG_CHECK_NAME, status: "ok", detail: envPath, group: "Stack" });
|
|
82
157
|
}
|
|
83
158
|
else {
|
|
84
|
-
|
|
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
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
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
|
-
|
|
101
|
-
|
|
102
|
-
|
|
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}
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
if (
|
|
339
|
-
|
|
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
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
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
|
|
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
|
|
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
|
|
360
|
-
$ propr check
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
374
|
-
|
|
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 (
|
|
1281
|
+
if (anyFail)
|
|
377
1282
|
process.exit(1);
|
|
378
1283
|
}
|
|
379
1284
|
catch (error) {
|