nomarmy 0.1.0-alpha.0

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 (74) hide show
  1. package/LICENSE +202 -0
  2. package/NOTICE +25 -0
  3. package/README.md +484 -0
  4. package/bin/nomarmy.mjs +2248 -0
  5. package/config/agents.yml.example +63 -0
  6. package/config/common.env +31 -0
  7. package/config/profiles/bedrock-cheap.env +26 -0
  8. package/config/profiles/bedrock.env +28 -0
  9. package/config/profiles/cpu-linux.env +8 -0
  10. package/config/profiles/dgx-spark.env +12 -0
  11. package/config/profiles/macbook-pro.env +9 -0
  12. package/config/profiles/nvidia-linux.env +9 -0
  13. package/docker/Dockerfile +15 -0
  14. package/docker/Dockerfile.go +29 -0
  15. package/docker/Dockerfile.rust +19 -0
  16. package/e2e.sh +153 -0
  17. package/install.sh +125 -0
  18. package/lib/agents.mjs +285 -0
  19. package/lib/army.mjs +400 -0
  20. package/lib/budget.mjs +368 -0
  21. package/lib/claude-transcript.mjs +150 -0
  22. package/lib/config.mjs +193 -0
  23. package/lib/connect.mjs +409 -0
  24. package/lib/coordinator-instructions.mjs +23 -0
  25. package/lib/decompose.mjs +389 -0
  26. package/lib/dispatch-config.mjs +164 -0
  27. package/lib/dispatch-schema.mjs +280 -0
  28. package/lib/doctor.mjs +443 -0
  29. package/lib/evidence.mjs +679 -0
  30. package/lib/gguf.mjs +589 -0
  31. package/lib/hardware.mjs +476 -0
  32. package/lib/health.mjs +278 -0
  33. package/lib/model-catalog.mjs +71 -0
  34. package/lib/notifier-app.mjs +95 -0
  35. package/lib/notify.mjs +66 -0
  36. package/lib/openclaw-config.mjs +65 -0
  37. package/lib/openclaw-errors.mjs +40 -0
  38. package/lib/propose.mjs +110 -0
  39. package/lib/prune.mjs +77 -0
  40. package/lib/repo-query.mjs +267 -0
  41. package/lib/runs.mjs +150 -0
  42. package/lib/sabotage.mjs +128 -0
  43. package/lib/sandbox-images.mjs +434 -0
  44. package/lib/scan.mjs +1538 -0
  45. package/lib/schema.mjs +288 -0
  46. package/lib/scout.mjs +544 -0
  47. package/lib/sizing.mjs +1322 -0
  48. package/lib/slots.mjs +112 -0
  49. package/lib/statusline.mjs +126 -0
  50. package/lib/subscription-config.mjs +68 -0
  51. package/lib/subscription-setup.mjs +217 -0
  52. package/lib/transcript.mjs +195 -0
  53. package/lib/verify.mjs +700 -0
  54. package/mcp/server.mjs +4206 -0
  55. package/notifier/icon.swift +34 -0
  56. package/notifier/main.swift +52 -0
  57. package/notifier/nomarmy-icon.png +0 -0
  58. package/package.json +67 -0
  59. package/playbooks/feature.md +43 -0
  60. package/policies/coder.md +49 -0
  61. package/policies/orchestrator.md +35 -0
  62. package/policies/reviewer.md +35 -0
  63. package/policies/scout.md +65 -0
  64. package/scripts/configure-openclaw.sh +96 -0
  65. package/scripts/configure-orchestrator.sh +84 -0
  66. package/scripts/install-llama-cpp.sh +16 -0
  67. package/scripts/lib.sh +198 -0
  68. package/scripts/select-model.mjs +96 -0
  69. package/scripts/select-model.sh +4 -0
  70. package/scripts/setup-sandbox.sh +38 -0
  71. package/scripts/start-inference.sh +46 -0
  72. package/scripts/stop-inference.sh +5 -0
  73. package/scripts/uninstall.sh +6 -0
  74. package/scripts/verify-install.sh +68 -0
package/lib/doctor.mjs ADDED
@@ -0,0 +1,443 @@
1
+ // lib/doctor.mjs
2
+ // Pure logic for the `nomarmy doctor` command.
3
+ // Side effects (probing PATH, spawning podman, hitting the worker endpoint)
4
+ // are isolated in `collectFacts()`. Everything that decides pass/fail is a
5
+ // plain function over a facts object, so the decisions are testable without
6
+ // touching the filesystem, a subprocess or the network.
7
+
8
+ import fs from "node:fs";
9
+ import path from "node:path";
10
+ import os from "node:os";
11
+ import { spawn } from "node:child_process";
12
+
13
+ const MIN_NODE_MAJOR = 18;
14
+ const DEFAULT_LLAMA_HOST = "127.0.0.1";
15
+ const DEFAULT_LLAMA_PORT = "8080";
16
+ const BEDROCK_REGION_RE = /^[a-z]{2}(-gov)?-[a-z]+-[0-9]$/;
17
+
18
+ /**
19
+ * Parse a Node.js version string (e.g. "v18.12.1") into an object.
20
+ * @param {string} version
21
+ * @returns {{major: number, minor: number, patch: number} | null}
22
+ */
23
+ export function parseNodeVersion(version) {
24
+ const m = /^v?(\d+)\.(\d+)\.(\d+)/.exec(version || "");
25
+ if (!m) return null;
26
+ return {
27
+ major: Number(m[1]),
28
+ minor: Number(m[2]),
29
+ patch: Number(m[3]),
30
+ };
31
+ }
32
+
33
+ /**
34
+ * Check that the Node.js version satisfies the minimum requirement.
35
+ * @param {string} versionString
36
+ * @returns {{ok: boolean, message: string, fix?: string}}
37
+ */
38
+ export function checkNodeVersion(versionString) {
39
+ const parsed = parseNodeVersion(versionString);
40
+ if (!parsed) {
41
+ return {
42
+ ok: false,
43
+ message: `Could not parse Node.js version '${versionString}'.`,
44
+ fix: `Install a supported Node.js version (>= v${MIN_NODE_MAJOR}.0.0).`,
45
+ };
46
+ }
47
+ if (parsed.major < MIN_NODE_MAJOR) {
48
+ return {
49
+ ok: false,
50
+ message: `Node.js v${parsed.major}.${parsed.minor}.${parsed.patch} is too old.`,
51
+ fix: `Upgrade Node.js to v${MIN_NODE_MAJOR}.0.0 or newer.`,
52
+ };
53
+ }
54
+ return { ok: true, message: `Node.js v${parsed.major}.${parsed.minor}.${parsed.patch} is OK.` };
55
+ }
56
+
57
+ /**
58
+ * Locate an executable on PATH. Mirrors resolveExecutable() in mcp/server.mjs:
59
+ * on Windows an executable may carry .exe, .cmd, .bat (or whatever PATHEXT
60
+ * says) or no extension at all, so a shim installed by e.g. npm is missed if
61
+ * only .exe is tried. POSIX executables carry no extension.
62
+ * @param {string} name
63
+ * @param {{pathEnv?: string, pathExt?: string, platform?: string, existsFile?: (candidate: string) => boolean}} [opts]
64
+ * @returns {string | null} absolute path if found, else null
65
+ */
66
+ export function findExecutable(name, opts = {}) {
67
+ const platform = opts.platform ?? os.platform();
68
+ const isWin = platform === "win32";
69
+ // `path.join`/`path.delimiter` reflect the OS this process is actually
70
+ // running on, not the `platform` being simulated -- joining a Windows path
71
+ // with POSIX path.join on a Mac does not produce a Windows path (backslash
72
+ // is not a separator to it), and splitting a `C:\a;C:\b` PATH on POSIX's
73
+ // `:` delimiter shreds it at the drive-letter colons. That silently broke
74
+ // every test here that simulated platform: "win32" on a non-Windows host,
75
+ // real Windows was never affected, since there `os.platform()` already
76
+ // matches and the ambient path module was already the right one. Use the
77
+ // platform-specific module explicitly so simulation and reality agree.
78
+ const platformPath = isWin ? path.win32 : path.posix;
79
+ // process.env is case-insensitive for names on Windows (Node normalizes
80
+ // this itself), so plain PATH/PATHEXT reads work on every platform without
81
+ // the '||' vs '?:' precedence trap the original code fell into.
82
+ const dirs = (opts.pathEnv ?? process.env.PATH ?? "").split(platformPath.delimiter).filter(Boolean);
83
+ const exts = isWin
84
+ ? (opts.pathExt ?? process.env.PATHEXT ?? ".COM;.EXE;.BAT;.CMD").split(";").filter(Boolean)
85
+ : [];
86
+ const existsFile = opts.existsFile ?? defaultExistsFile;
87
+ for (const dir of dirs) {
88
+ for (const ext of [...exts, ""]) {
89
+ const candidate = platformPath.join(dir, name + ext.toLowerCase());
90
+ if (existsFile(candidate)) return candidate;
91
+ }
92
+ }
93
+ return null;
94
+ }
95
+
96
+ function defaultExistsFile(candidate) {
97
+ try {
98
+ return fs.statSync(candidate).isFile();
99
+ } catch {
100
+ return false;
101
+ }
102
+ }
103
+
104
+ /**
105
+ * Check that the git executable is available.
106
+ * @param {{gitFound: boolean}} facts
107
+ * @returns {{ok: boolean, message: string, fix?: string}}
108
+ */
109
+ export function checkGit(facts) {
110
+ if (facts.gitFound) return { ok: true, message: "git is available." };
111
+ return {
112
+ ok: false,
113
+ message: "git executable not found.",
114
+ fix: "Install Git and ensure 'git' is on your PATH.",
115
+ };
116
+ }
117
+
118
+ /**
119
+ * Windows only: nomArmy creates a worktree per job under the state directory,
120
+ * so every repository path gets longer by that prefix. Without
121
+ * core.longpaths, Git refuses to create anything past 260 characters and the
122
+ * job fails at `git worktree add` with "Filename too long" (observed on a
123
+ * repository whose deepest path was a GitHub workflow fixture).
124
+ * @param {{platform: string, gitFound: boolean, gitLongPaths: boolean|null}} facts
125
+ * @returns {{ok: boolean, message: string, fix?: string}}
126
+ */
127
+ export function checkGitLongPaths(facts) {
128
+ if (facts.platform !== "win32") return { ok: true, message: "Git long paths: not needed on this platform." };
129
+ if (!facts.gitFound) return { ok: true, message: "Git long paths: skipped, git not found." };
130
+ if (facts.gitLongPaths === true) return { ok: true, message: "Git core.longpaths is enabled." };
131
+ return {
132
+ ok: false,
133
+ message: "Git core.longpaths is not enabled; worktrees under the job directory can exceed Windows' 260-character path limit.",
134
+ fix: "git config --global core.longpaths true",
135
+ };
136
+ }
137
+
138
+ /**
139
+ * Check that the podman executable is available. This is distinct from
140
+ * Podman actually being usable: the CLI can be installed while the macOS VM
141
+ * (`podman machine`) is uninitialized or stopped, or a Linux install is
142
+ * otherwise broken.
143
+ * @param {{podmanFound: boolean}} facts
144
+ * @returns {{ok: boolean, message: string, fix?: string}}
145
+ */
146
+ export function checkPodmanPresent(facts) {
147
+ if (facts.podmanFound) return { ok: true, message: "podman is available." };
148
+ return {
149
+ ok: false,
150
+ message: "podman executable not found.",
151
+ fix: "Install Podman ('brew install podman' on macOS, or your distro's package manager on Linux) and ensure 'podman' is on your PATH.",
152
+ };
153
+ }
154
+
155
+ /**
156
+ * Check that podman actually answers, not just that the CLI exists. Podman in
157
+ * its default rootless mode has no persistent background daemon the way
158
+ * Docker does: on Linux this just works once the package is installed, and on
159
+ * macOS it depends on the `podman machine` VM being initialized and started.
160
+ * @param {{podmanFound: boolean, podmanDaemonReachable: boolean, podmanDaemonError?: string|null}} facts
161
+ * @returns {{ok: boolean, message: string, fix?: string}}
162
+ */
163
+ export function checkPodmanDaemon(facts) {
164
+ if (!facts.podmanFound) {
165
+ return {
166
+ ok: false,
167
+ message: "Podman check skipped: podman executable not found.",
168
+ fix: "Install Podman ('brew install podman' on macOS, or your distro's package manager on Linux), then re-run 'nomarmy doctor'.",
169
+ };
170
+ }
171
+ if (facts.podmanDaemonReachable) {
172
+ return { ok: true, message: "Podman is available." };
173
+ }
174
+ return {
175
+ ok: false,
176
+ message: `Podman is not usable${facts.podmanDaemonError ? `: ${facts.podmanDaemonError}` : "."}`,
177
+ fix: "Run 'podman machine init' (if you have not already) and 'podman machine start' if you're on macOS, or check your Podman installation on Linux, then re-run 'nomarmy doctor'.",
178
+ };
179
+ }
180
+
181
+ /**
182
+ * Check the worker model endpoint appropriate to NOMARMY_EXECUTION. A local
183
+ * profile runs llama-server and exposes /health; a bedrock profile has no
184
+ * endpoint to ping, so this validates the region and that credentials are
185
+ * discoverable instead.
186
+ * @param {{execution: string, endpoint: object}} facts
187
+ * @returns {{ok: boolean, message: string, fix?: string}}
188
+ */
189
+ export function checkEndpoint(facts) {
190
+ const { execution, endpoint } = facts;
191
+ if (execution === "bedrock") return checkBedrockEndpoint(endpoint);
192
+ if (execution === "local") return checkLocalEndpoint(endpoint);
193
+ return {
194
+ ok: false,
195
+ message: `NOMARMY_EXECUTION '${execution}' is not recognized.`,
196
+ fix: "Set NOMARMY_EXECUTION to 'local' or 'bedrock' (see config/common.env).",
197
+ };
198
+ }
199
+
200
+ function checkLocalEndpoint(endpoint) {
201
+ const { url, healthy, error } = endpoint;
202
+ if (healthy) return { ok: true, message: `llama-server is healthy at ${url}.` };
203
+ return {
204
+ ok: false,
205
+ message: `llama-server health check failed at ${url}${error ? `: ${error}` : "."}`,
206
+ fix: "Start the local worker with './scripts/start-inference.sh' (after 'source scripts/lib.sh && load_profile <profile>'), then re-run 'nomarmy doctor'.",
207
+ };
208
+ }
209
+
210
+ function checkBedrockEndpoint(endpoint) {
211
+ const { region, baseUrl, regionValid, credentialsPresent } = endpoint;
212
+ if (!region) {
213
+ return {
214
+ ok: false,
215
+ message: "NOMARMY_BEDROCK_REGION is not set.",
216
+ fix: "Set NOMARMY_BEDROCK_REGION, e.g. export NOMARMY_BEDROCK_REGION=eu-west-2 (see config/profiles/bedrock.env).",
217
+ };
218
+ }
219
+ if (!regionValid) {
220
+ return {
221
+ ok: false,
222
+ message: `NOMARMY_BEDROCK_REGION '${region}' is not a valid AWS region name.`,
223
+ fix: "Use a region name like eu-west-2 or us-east-1.",
224
+ };
225
+ }
226
+ if (!credentialsPresent) {
227
+ return {
228
+ ok: false,
229
+ message: "No AWS credentials were found for the Bedrock worker endpoint.",
230
+ fix: "Run 'aws configure', or set AWS_ACCESS_KEY_ID/AWS_SECRET_ACCESS_KEY/AWS_PROFILE.",
231
+ };
232
+ }
233
+ return { ok: true, message: `Bedrock worker endpoint configured for region '${region}' (${baseUrl}).` };
234
+ }
235
+
236
+ // --- fact collection: every side effect the checks above need lives here ---
237
+
238
+ /**
239
+ * Probe whether podman actually answers, not just whether the CLI exists.
240
+ * The output of `podman info` is not parsed here, only the exit code matters,
241
+ * so no --format field (Podman's info JSON shape differs from Docker's) is
242
+ * needed.
243
+ * @param {string} podmanPath
244
+ * @returns {Promise<{reachable: boolean, error: string|null}>}
245
+ */
246
+ function probePodmanDaemon(podmanPath) {
247
+ return new Promise((resolve) => {
248
+ let settled = false;
249
+ const finish = (result) => {
250
+ if (settled) return;
251
+ settled = true;
252
+ resolve(result);
253
+ };
254
+ const args = ["info"];
255
+ // Windows cannot execute a .cmd/.bat shim directly (EINVAL) - it needs
256
+ // cmd.exe. shell:true alone re-joins command+args with plain spaces, which
257
+ // breaks on a path like "C:\Program Files\RedHat\Podman\podman.exe";
258
+ // quoting each token into one command string avoids that split.
259
+ const needsShell = os.platform() === "win32" && /\.(cmd|bat)$/i.test(podmanPath);
260
+ let child;
261
+ try {
262
+ child = needsShell
263
+ ? spawn([podmanPath, ...args].map((t) => `"${t}"`).join(" "), [], {
264
+ stdio: ["ignore", "pipe", "pipe"],
265
+ shell: true,
266
+ })
267
+ : spawn(podmanPath, args, { stdio: ["ignore", "pipe", "pipe"] });
268
+ } catch (err) {
269
+ finish({ reachable: false, error: err.message });
270
+ return;
271
+ }
272
+ const timer = setTimeout(() => {
273
+ child.kill();
274
+ finish({ reachable: false, error: "timed out waiting for podman" });
275
+ }, 3000);
276
+ let stderr = "";
277
+ child.stderr?.on("data", (d) => { stderr += d.toString(); });
278
+ child.on("error", (err) => { clearTimeout(timer); finish({ reachable: false, error: err.message }); });
279
+ child.on("close", (code) => {
280
+ clearTimeout(timer);
281
+ if (code === 0) finish({ reachable: true, error: null });
282
+ else finish({ reachable: false, error: stderr.trim().split("\n")[0] || `podman info exited ${code}` });
283
+ });
284
+ });
285
+ }
286
+
287
+ /**
288
+ * Probe the local llama-server. /health only reports that a model is loaded
289
+ * into slots, not that the backend can actually compute -- a GPU allocation
290
+ * failure during load (observed in practice: a real Metal "Insufficient
291
+ * Memory" error) still leaves /health reporting fine. A real completion
292
+ * request catches that a /health-only check would miss.
293
+ * @param {string} host
294
+ * @param {string|number} port
295
+ * @returns {Promise<{mode: "local", url: string, healthy: boolean, error: string|null}>}
296
+ */
297
+ async function probeLocalEndpoint(host, port) {
298
+ const url = `http://${host}:${port}/health`;
299
+ const controller = new AbortController();
300
+ const timer = setTimeout(() => controller.abort(), 2000);
301
+ try {
302
+ const res = await fetch(url, { signal: controller.signal });
303
+ if (!res.ok) return { mode: "local", url, healthy: false, error: `HTTP ${res.status}` };
304
+ } catch (err) {
305
+ return { mode: "local", url, healthy: false, error: err.name === "AbortError" ? "timed out" : err.message };
306
+ } finally {
307
+ clearTimeout(timer);
308
+ }
309
+ const completionUrl = `http://${host}:${port}/completion`;
310
+ const completionController = new AbortController();
311
+ const completionTimer = setTimeout(() => completionController.abort(), 30000);
312
+ try {
313
+ const res = await fetch(completionUrl, {
314
+ method: "POST",
315
+ headers: { "Content-Type": "application/json" },
316
+ body: JSON.stringify({ prompt: "ok", n_predict: 1 }),
317
+ signal: completionController.signal,
318
+ });
319
+ return { mode: "local", url: completionUrl, healthy: res.ok, error: res.ok ? null : `HTTP ${res.status} (model loaded but a real completion request failed -- likely a GPU/compute allocation error)` };
320
+ } catch (err) {
321
+ return { mode: "local", url: completionUrl, healthy: false, error: err.name === "AbortError" ? "completion request timed out" : err.message };
322
+ } finally {
323
+ clearTimeout(completionTimer);
324
+ }
325
+ }
326
+
327
+ /**
328
+ * Gather the (non-network-secret) facts a bedrock profile needs: is a region
329
+ * configured and syntactically valid, and are credentials discoverable at
330
+ * all. This deliberately does not call AWS - that belongs to
331
+ * scripts/verify-install.sh, which actually invokes the API.
332
+ * @param {NodeJS.ProcessEnv} env
333
+ * @returns {{mode: "bedrock", region: string|null, baseUrl: string|null, regionValid: boolean, credentialsPresent: boolean}}
334
+ */
335
+ function collectBedrockFacts(env) {
336
+ const region = env.NOMARMY_BEDROCK_REGION || null;
337
+ const regionValid = Boolean(region && BEDROCK_REGION_RE.test(region));
338
+ const baseUrl = env.NOMARMY_BEDROCK_BASE_URL || (region ? `https://bedrock-runtime.${region}.amazonaws.com/openai/v1` : null);
339
+ const credentialsPresent = Boolean(
340
+ (env.AWS_ACCESS_KEY_ID && env.AWS_SECRET_ACCESS_KEY)
341
+ || env.AWS_PROFILE
342
+ || (() => { try { return fs.statSync(path.join(os.homedir(), ".aws", "credentials")).isFile(); } catch { return false; } })(),
343
+ );
344
+ return { mode: "bedrock", region, baseUrl, regionValid, credentialsPresent };
345
+ }
346
+
347
+ /**
348
+ * Collect every fact the checks need. Side effects only - no decisions here.
349
+ * @param {NodeJS.ProcessEnv} [env]
350
+ * @returns {Promise<object>}
351
+ */
352
+ /**
353
+ * Read `git config --get core.longpaths`. Only consulted on Windows.
354
+ * @param {string} gitPath
355
+ * @returns {Promise<boolean|null>} true/false, or null if git did not answer
356
+ */
357
+ async function probeGitLongPaths(gitPath) {
358
+ return new Promise((resolve) => {
359
+ let out = "";
360
+ let child;
361
+ try {
362
+ child = spawn(gitPath, ["config", "--get", "core.longpaths"], { stdio: ["ignore", "pipe", "ignore"] });
363
+ } catch { return resolve(null); }
364
+ const timer = setTimeout(() => { child.kill(); resolve(null); }, 3000);
365
+ child.stdout.on("data", (d) => { out += d.toString(); });
366
+ child.on("error", () => { clearTimeout(timer); resolve(null); });
367
+ child.on("close", (code) => {
368
+ clearTimeout(timer);
369
+ // exit 1 with no output means "unset", which is a definite false.
370
+ if (code === 1 && !out.trim()) return resolve(false);
371
+ if (code !== 0) return resolve(null);
372
+ resolve(/^(true|1|yes|on)$/i.test(out.trim()));
373
+ });
374
+ });
375
+ }
376
+
377
+ export async function collectFacts(env = process.env) {
378
+ const platform = os.platform();
379
+ const gitPath = findExecutable("git", { platform });
380
+ const gitLongPaths = platform === "win32" && gitPath ? await probeGitLongPaths(gitPath) : null;
381
+ const podmanPath = findExecutable("podman", { platform });
382
+ const podmanDaemon = podmanPath
383
+ ? await probePodmanDaemon(podmanPath)
384
+ : { reachable: false, error: "podman not found" };
385
+ const execution = (env.NOMARMY_EXECUTION || "local").trim();
386
+ const endpoint = execution === "bedrock"
387
+ ? collectBedrockFacts(env)
388
+ : await probeLocalEndpoint(env.NOMARMY_LLAMA_HOST || DEFAULT_LLAMA_HOST, env.NOMARMY_LLAMA_PORT || DEFAULT_LLAMA_PORT);
389
+ return {
390
+ nodeVersion: process.version,
391
+ platform,
392
+ gitFound: Boolean(gitPath),
393
+ gitLongPaths,
394
+ podmanFound: Boolean(podmanPath),
395
+ podmanDaemonReachable: podmanDaemon.reachable,
396
+ podmanDaemonError: podmanDaemon.error,
397
+ execution,
398
+ endpoint,
399
+ };
400
+ }
401
+
402
+ /**
403
+ * Run every check against a facts object and report ok/fail plus an id.
404
+ * @param {object} facts
405
+ * @returns {Array<{id: string, ok: boolean, message: string, fix?: string}>}
406
+ */
407
+ export function evaluateChecks(facts) {
408
+ return [
409
+ { id: "node", ...checkNodeVersion(facts.nodeVersion) },
410
+ { id: "git", ...checkGit(facts) },
411
+ { id: "git-longpaths", ...checkGitLongPaths(facts) },
412
+ { id: "podman", ...checkPodmanPresent(facts) },
413
+ { id: "podman-daemon", ...checkPodmanDaemon(facts) },
414
+ { id: "endpoint", ...checkEndpoint(facts) },
415
+ ];
416
+ }
417
+
418
+ /**
419
+ * Run the doctor checks and print a report.
420
+ * @param {{json?: boolean, exit?: boolean, facts?: object, env?: NodeJS.ProcessEnv}} opts
421
+ * @returns {Promise<{ok: boolean, checks: Array<object>}>}
422
+ */
423
+ export async function runDoctor(opts = {}) {
424
+ const { json = false, exit = false, env = process.env } = opts;
425
+ const facts = opts.facts ?? await collectFacts(env);
426
+ const checks = evaluateChecks(facts);
427
+ const allOk = checks.every((c) => c.ok);
428
+ const result = { ok: allOk, checks };
429
+
430
+ if (json) {
431
+ console.log(JSON.stringify(result, null, 2));
432
+ } else {
433
+ console.log("nomArmy doctor report\n");
434
+ for (const c of checks) {
435
+ console.log(` ${c.ok ? "✓" : "✗"} ${c.message}`);
436
+ if (!c.ok && c.fix) console.log(` Fix: ${c.fix}`);
437
+ }
438
+ console.log(allOk ? "\nAll checks passed." : "\nSome checks failed.");
439
+ }
440
+
441
+ if (exit) process.exit(allOk ? 0 : 1);
442
+ return result;
443
+ }