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.
- package/LICENSE +202 -0
- package/NOTICE +25 -0
- package/README.md +484 -0
- package/bin/nomarmy.mjs +2248 -0
- package/config/agents.yml.example +63 -0
- package/config/common.env +31 -0
- package/config/profiles/bedrock-cheap.env +26 -0
- package/config/profiles/bedrock.env +28 -0
- package/config/profiles/cpu-linux.env +8 -0
- package/config/profiles/dgx-spark.env +12 -0
- package/config/profiles/macbook-pro.env +9 -0
- package/config/profiles/nvidia-linux.env +9 -0
- package/docker/Dockerfile +15 -0
- package/docker/Dockerfile.go +29 -0
- package/docker/Dockerfile.rust +19 -0
- package/e2e.sh +153 -0
- package/install.sh +125 -0
- package/lib/agents.mjs +285 -0
- package/lib/army.mjs +400 -0
- package/lib/budget.mjs +368 -0
- package/lib/claude-transcript.mjs +150 -0
- package/lib/config.mjs +193 -0
- package/lib/connect.mjs +409 -0
- package/lib/coordinator-instructions.mjs +23 -0
- package/lib/decompose.mjs +389 -0
- package/lib/dispatch-config.mjs +164 -0
- package/lib/dispatch-schema.mjs +280 -0
- package/lib/doctor.mjs +443 -0
- package/lib/evidence.mjs +679 -0
- package/lib/gguf.mjs +589 -0
- package/lib/hardware.mjs +476 -0
- package/lib/health.mjs +278 -0
- package/lib/model-catalog.mjs +71 -0
- package/lib/notifier-app.mjs +95 -0
- package/lib/notify.mjs +66 -0
- package/lib/openclaw-config.mjs +65 -0
- package/lib/openclaw-errors.mjs +40 -0
- package/lib/propose.mjs +110 -0
- package/lib/prune.mjs +77 -0
- package/lib/repo-query.mjs +267 -0
- package/lib/runs.mjs +150 -0
- package/lib/sabotage.mjs +128 -0
- package/lib/sandbox-images.mjs +434 -0
- package/lib/scan.mjs +1538 -0
- package/lib/schema.mjs +288 -0
- package/lib/scout.mjs +544 -0
- package/lib/sizing.mjs +1322 -0
- package/lib/slots.mjs +112 -0
- package/lib/statusline.mjs +126 -0
- package/lib/subscription-config.mjs +68 -0
- package/lib/subscription-setup.mjs +217 -0
- package/lib/transcript.mjs +195 -0
- package/lib/verify.mjs +700 -0
- package/mcp/server.mjs +4206 -0
- package/notifier/icon.swift +34 -0
- package/notifier/main.swift +52 -0
- package/notifier/nomarmy-icon.png +0 -0
- package/package.json +67 -0
- package/playbooks/feature.md +43 -0
- package/policies/coder.md +49 -0
- package/policies/orchestrator.md +35 -0
- package/policies/reviewer.md +35 -0
- package/policies/scout.md +65 -0
- package/scripts/configure-openclaw.sh +96 -0
- package/scripts/configure-orchestrator.sh +84 -0
- package/scripts/install-llama-cpp.sh +16 -0
- package/scripts/lib.sh +198 -0
- package/scripts/select-model.mjs +96 -0
- package/scripts/select-model.sh +4 -0
- package/scripts/setup-sandbox.sh +38 -0
- package/scripts/start-inference.sh +46 -0
- package/scripts/stop-inference.sh +5 -0
- package/scripts/uninstall.sh +6 -0
- 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
|
+
}
|