nomarmy 0.1.0-alpha.2 → 0.1.0-alpha.21
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 +86 -480
- package/bin/nomarmy.mjs +1081 -185
- package/docker/Dockerfile +2 -2
- package/docker/Dockerfile.go +6 -4
- package/docker/Dockerfile.rust +17 -2
- package/harnesses/_template/README.md +27 -0
- package/harnesses/_template/harness.yml +26 -0
- package/harnesses/browser-playwright/README.md +35 -0
- package/harnesses/browser-playwright/fixture/package.json +1 -0
- package/harnesses/browser-playwright/fixture/page.html +1 -0
- package/harnesses/browser-playwright/fixture/page.spec.js +5 -0
- package/harnesses/browser-playwright/fixture/playwright.config.js +8 -0
- package/harnesses/browser-playwright/harness.yml +18 -0
- package/harnesses/go/README.md +45 -0
- package/harnesses/go/harness.yml +14 -0
- package/harnesses/mock-oidc/README.md +31 -0
- package/harnesses/mock-oidc/fixture/.nomarmy.yml +4 -0
- package/harnesses/mock-oidc/fixture/discovery.test.mjs +16 -0
- package/harnesses/mock-oidc/harness.yml +19 -0
- package/harnesses/node/README.md +53 -0
- package/harnesses/node/harness.yml +18 -0
- package/harnesses/python/README.md +46 -0
- package/harnesses/python/harness.yml +16 -0
- package/harnesses/rust/README.md +45 -0
- package/harnesses/rust/harness.yml +13 -0
- package/install.sh +29 -9
- package/lib/admission.mjs +178 -30
- package/lib/agents.mjs +8 -6
- package/lib/army.mjs +25 -10
- package/lib/codex-link.mjs +37 -0
- package/lib/config.mjs +15 -0
- package/lib/connect.mjs +232 -19
- package/lib/continue-from.mjs +103 -0
- package/lib/coordinator-instructions.mjs +5 -1
- package/lib/diff-checks.mjs +114 -0
- package/lib/dispatch-schema.mjs +14 -12
- package/lib/doctor.mjs +98 -9
- package/lib/egress-proxy.mjs +116 -0
- package/lib/execute.mjs +241 -33
- package/lib/git-record.mjs +27 -3
- package/lib/harness-schema.mjs +61 -0
- package/lib/harnesses.mjs +99 -0
- package/lib/health.mjs +162 -18
- package/lib/install-freshness.mjs +114 -0
- package/lib/jev-checks.mjs +110 -0
- package/lib/job-format.mjs +54 -0
- package/lib/judge.mjs +130 -0
- package/lib/limits.mjs +77 -0
- package/lib/model-probe.mjs +61 -0
- package/lib/mutation.mjs +159 -0
- package/lib/notify.mjs +30 -3
- package/lib/openclaw-install.mjs +122 -0
- package/lib/openclaw-path.mjs +28 -0
- package/lib/openclaw-run.mjs +74 -12
- package/lib/openclaw-runtime-health.mjs +56 -0
- package/lib/outcome.mjs +21 -2
- package/lib/outcomes.mjs +6 -0
- package/lib/path-utils.mjs +4 -0
- package/lib/podman-health.mjs +41 -0
- package/lib/process.mjs +4 -1
- package/lib/propose.mjs +10 -11
- package/lib/refusal-retry.mjs +16 -0
- package/lib/registry-python.mjs +98 -0
- package/lib/registry-secrets.mjs +140 -0
- package/lib/repo-query.mjs +13 -7
- package/lib/runs.mjs +7 -1
- package/lib/same-path.mjs +14 -0
- package/lib/sandbox-images.mjs +499 -83
- package/lib/sandbox-vm.mjs +32 -0
- package/lib/scan.mjs +5 -1
- package/lib/schema.mjs +20 -11
- package/lib/scout.mjs +21 -3
- package/lib/server-context.mjs +21 -1
- package/lib/setup-steps.mjs +55 -0
- package/lib/share.mjs +82 -0
- package/lib/stale-sessions.mjs +60 -0
- package/lib/stats.mjs +315 -0
- package/lib/statusline.mjs +32 -6
- package/lib/subscription-setup.mjs +13 -0
- package/lib/suggestions.mjs +153 -0
- package/lib/thinking.mjs +23 -0
- package/lib/transcript.mjs +30 -5
- package/lib/usage-limits.mjs +329 -0
- package/lib/user-config.mjs +106 -0
- package/lib/validators.mjs +220 -0
- package/lib/verification-artifacts.mjs +46 -0
- package/lib/verification-flow.mjs +52 -7
- package/lib/verification-network.mjs +66 -0
- package/lib/verify.mjs +338 -85
- package/lib/worker-prompt.mjs +5 -2
- package/lib/wsl-cli.mjs +152 -0
- package/lib/wsl.mjs +230 -0
- package/lib/zod-issues.mjs +15 -0
- package/mcp/server.mjs +165 -34
- package/package.json +7 -5
- package/playbooks/feature.md +8 -5
- package/scripts/configure-openclaw.sh +4 -2
- package/scripts/generate-harness-docs.mjs +42 -0
- package/scripts/install-openclaw.mjs +23 -0
- package/scripts/lib.sh +9 -2
- package/scripts/select-model.mjs +12 -5
- package/scripts/start-inference.sh +2 -2
package/lib/wsl-cli.mjs
ADDED
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
// Native Windows CLI decisions. All operating-system boundaries are injectable.
|
|
2
|
+
import fsDefault from "node:fs";
|
|
3
|
+
import { spawnSync } from "node:child_process";
|
|
4
|
+
import { listDistros, pickDistro, readWindowsSettings, writeWindowsSettings, resolveWslNomarmy, windowsToWslPath, wslCommand } from "./wsl.mjs";
|
|
5
|
+
|
|
6
|
+
const WSL_FIX = "WSL isn't installed. In an administrator PowerShell run: wsl --install, restart Windows, then run nomarmy setup again.";
|
|
7
|
+
const DISTRO_FIX = "Install a Linux distro: wsl --install -d Ubuntu, open it once to create your user, then run nomarmy setup again.";
|
|
8
|
+
|
|
9
|
+
export function windowsPlan(argv) {
|
|
10
|
+
return ["jobs", "stats", "agents", "army", "validators", "config", "update", "mcp", "install", "start", "stop", "sandbox", "health", "model", "statusline", "uninstall"].includes(argv[0]) ? "FORWARD" : "LOCAL";
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
// Adapt either execFileSync (stdout/throw) or spawnSync (result object).
|
|
14
|
+
function capture(run) {
|
|
15
|
+
return (command, args, options = {}) => {
|
|
16
|
+
const result = run(command, args, { encoding: "utf8", stdio: ["ignore", "pipe", "pipe"], ...options });
|
|
17
|
+
if (result && typeof result === "object" && !Buffer.isBuffer(result)) {
|
|
18
|
+
if (result.error || result.status !== 0) throw result.error || new Error(`Command exited ${result.status}`);
|
|
19
|
+
return result.stdout ?? "";
|
|
20
|
+
}
|
|
21
|
+
return result;
|
|
22
|
+
};
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function execute(run, launch) {
|
|
26
|
+
try {
|
|
27
|
+
const result = run(launch.command, launch.args, { stdio: "inherit" });
|
|
28
|
+
return result && typeof result === "object" && !Buffer.isBuffer(result) ? result.status ?? 1 : 0;
|
|
29
|
+
} catch (error) { return Number.isInteger(error.status) ? error.status : 1; }
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function windowsForwardCommand(argv, { run = spawnSync, env = process.env, fs = fsDefault, cwd = process.cwd() } = {}) {
|
|
33
|
+
const { distro: savedDistro, node, script } = readWindowsSettings({ env, fs });
|
|
34
|
+
const distro = savedDistro || pickDistro({ run: capture(run), env, fs });
|
|
35
|
+
const translated = windowsToWslPath(cwd);
|
|
36
|
+
const sameDistro = translated?.distro?.toLowerCase() === distro.toLowerCase();
|
|
37
|
+
const directory = translated && (/^[A-Za-z]:[\\/]/.test(cwd) || sameDistro) ? translated.path : null;
|
|
38
|
+
return wslCommand({ distro, args: argv, cwd: directory, node, script });
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function windowsForward(argv, { run = spawnSync, print = console.error, ...options } = {}) {
|
|
42
|
+
let launch;
|
|
43
|
+
try { launch = windowsForwardCommand(argv, { run, ...options }); }
|
|
44
|
+
catch (error) {
|
|
45
|
+
print(`${error.message}; then run nomarmy setup`);
|
|
46
|
+
return 1;
|
|
47
|
+
}
|
|
48
|
+
return execute(run, launch);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// The fix when the distro has no Node 24.16+, or null when it has one.
|
|
52
|
+
function nodeFix(distro, probe) {
|
|
53
|
+
let version = "";
|
|
54
|
+
try { version = String(probe("wsl.exe", ["-d", distro, "--exec", "bash", "-lic", "node --version"])); } catch { /* Missing Node needs the same fix. */ }
|
|
55
|
+
const match = /^v?(\d+)\.(\d+)\.\d+\s*$/m.exec(version);
|
|
56
|
+
if (match && (Number(match[1]) > 24 || (Number(match[1]) === 24 && Number(match[2]) >= 16))) return null;
|
|
57
|
+
return `Install Node 24 (24.16+) inside ${distro}:\ncurl -o- https://raw.githubusercontent.com/nvm-sh/nvm/master/install.sh | bash\nThen reopen the distro shell and run: nvm install 24\nThen run nomarmy setup again.`;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export async function windowsSetup(argv, { run = spawnSync, env = process.env, fs = fsDefault, print = console.log, ask } = {}) {
|
|
61
|
+
const probe = capture(run);
|
|
62
|
+
const distros = listDistros({ run: probe });
|
|
63
|
+
if (distros === null) { print(WSL_FIX); return 1; }
|
|
64
|
+
if (!distros.length) { print(DISTRO_FIX); return 1; }
|
|
65
|
+
let distro;
|
|
66
|
+
try {
|
|
67
|
+
distro = pickDistro({ run: probe, env, fs });
|
|
68
|
+
} catch (error) { print(error.message); return 1; }
|
|
69
|
+
let resolved;
|
|
70
|
+
try { resolved = resolveWslNomarmy({ distro, run: probe }); }
|
|
71
|
+
catch {
|
|
72
|
+
const fix = nodeFix(distro, probe);
|
|
73
|
+
if (fix) { print(fix); return 1; }
|
|
74
|
+
const answer = await ask?.(`Install nomArmy inside ${distro} now? [Y/n]`);
|
|
75
|
+
if (answer === undefined || !/^(?:y|yes)?$/i.test(String(answer).trim())) return 1;
|
|
76
|
+
const installed = execute(run, { command: "wsl.exe", args: ["-d", distro, "--exec", "bash", "-lic", "npm install -g nomarmy@alpha"] });
|
|
77
|
+
if (installed !== 0) return installed;
|
|
78
|
+
try { resolved = resolveWslNomarmy({ distro, run: probe }); }
|
|
79
|
+
catch (error) { print(error.message); return 1; }
|
|
80
|
+
}
|
|
81
|
+
try { writeWindowsSettings({ distro, ...resolved }, { env, fs }); }
|
|
82
|
+
catch (error) { print(error.message); return 1; }
|
|
83
|
+
const status = execute(run, wslCommand({ distro, ...resolved, args: argv }));
|
|
84
|
+
// With --json the engine's document is the whole of stdout.
|
|
85
|
+
if (status === 0 && !argv.includes("--json")) print("Register your coordinators on Windows: nomarmy connect claude (or codex, cursor)");
|
|
86
|
+
return status;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export function windowsDoctorChecks({ run = spawnSync, env = process.env, fs = fsDefault, cwd = process.cwd() } = {}) {
|
|
90
|
+
const probe = capture(run);
|
|
91
|
+
const distros = listDistros({ run: probe });
|
|
92
|
+
const checks = [{ id: "wsl", ok: distros !== null, message: distros === null ? "WSL isn't installed." : "WSL is installed.", fix: distros === null ? WSL_FIX : null }];
|
|
93
|
+
if (distros === null) return checks;
|
|
94
|
+
let distro;
|
|
95
|
+
try { distro = pickDistro({ run: probe, env, fs }); }
|
|
96
|
+
catch (error) {
|
|
97
|
+
checks.push({ id: "wsl-distro", ok: false, message: "No WSL distro chosen.", fix: distros.length ? error.message : DISTRO_FIX });
|
|
98
|
+
return checks;
|
|
99
|
+
}
|
|
100
|
+
const version = distros.find((d) => d.name === distro).version;
|
|
101
|
+
checks.push({ id: "wsl-distro", ok: version === 2, message: `${distro} uses WSL ${version}.`, fix: version === 2 ? null : `wsl --set-version ${distro} 2` });
|
|
102
|
+
try {
|
|
103
|
+
resolveWslNomarmy({ distro, run: probe });
|
|
104
|
+
checks.push({ id: "wsl-nomarmy", ok: true, message: `nomArmy is installed in ${distro}.`, fix: null });
|
|
105
|
+
} catch (error) {
|
|
106
|
+
// Without Node there is nothing to install nomArmy with: say that first.
|
|
107
|
+
const fix = nodeFix(distro, probe);
|
|
108
|
+
checks.push({ id: "wsl-nomarmy", ok: false, message: fix ? `Node 24.16+ and nomArmy aren't installed in ${distro}.` : `nomArmy wasn't found in ${distro}.`, fix: fix ?? error.message });
|
|
109
|
+
}
|
|
110
|
+
const translated = windowsToWslPath(cwd);
|
|
111
|
+
const fast = translated?.distro?.toLowerCase() === distro.toLowerCase();
|
|
112
|
+
const drive = /^[A-Za-z]:[\\/]/.test(cwd);
|
|
113
|
+
checks.push({
|
|
114
|
+
id: "wsl-repo", ok: fast || drive,
|
|
115
|
+
message: fast ? "Repository is inside WSL, the fast location for jobs." : drive
|
|
116
|
+
? `A Windows-drive repository works, but a repo inside WSL (for example ~/src in ${distro}, opened as \\\\wsl.localhost\\${distro}\\src) is much faster for jobs.`
|
|
117
|
+
: "Repository location cannot be forwarded to the chosen WSL distro.",
|
|
118
|
+
fix: fast || drive ? null : `Open a repo inside ${distro} using \\\\wsl.localhost\\${distro}\\, or use a Windows drive.`,
|
|
119
|
+
});
|
|
120
|
+
return checks;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
export function windowsDoctor({ run = spawnSync, print = console.log, json = false, argv = ["doctor"], ...options } = {}) {
|
|
124
|
+
const checks = windowsDoctorChecks({ run, ...options });
|
|
125
|
+
const ok = checks.every((check) => check.ok);
|
|
126
|
+
if (!json) {
|
|
127
|
+
print("nomArmy doctor report\n");
|
|
128
|
+
for (const check of checks) {
|
|
129
|
+
print(` ${check.ok ? "✓" : "✗"} ${check.message}`);
|
|
130
|
+
if (!check.ok && check.fix) print(` Fix: ${check.fix}`);
|
|
131
|
+
}
|
|
132
|
+
print(ok ? "\nAll checks passed." : "\nSome checks failed.");
|
|
133
|
+
}
|
|
134
|
+
const engineReady = ["wsl", "wsl-distro", "wsl-nomarmy"].every((id) => checks.some((check) => check.id === id && check.ok));
|
|
135
|
+
if (json) {
|
|
136
|
+
let engine = null;
|
|
137
|
+
let status = 0;
|
|
138
|
+
if (engineReady) {
|
|
139
|
+
try {
|
|
140
|
+
const launch = windowsForwardCommand(argv.includes("--json") ? argv : [...argv, "--json"], { run, ...options });
|
|
141
|
+
const result = run(launch.command, launch.args, { encoding: "utf8", stdio: ["inherit", "pipe", "inherit"] });
|
|
142
|
+
status = result?.status ?? 1;
|
|
143
|
+
engine = JSON.parse(result?.stdout ?? "");
|
|
144
|
+
if (result?.error || engine === null || engine?.ok === false) status = Math.max(status, 1);
|
|
145
|
+
} catch { status = Math.max(status, 1); }
|
|
146
|
+
}
|
|
147
|
+
const exitStatus = Math.max(ok ? 0 : 1, status);
|
|
148
|
+
print(JSON.stringify({ ok: exitStatus === 0, windows: { checks }, engine }, null, 2));
|
|
149
|
+
return exitStatus;
|
|
150
|
+
}
|
|
151
|
+
return Math.max(ok ? 0 : 1, engineReady ? windowsForward(argv, { run, print, ...options }) : 0);
|
|
152
|
+
}
|
package/lib/wsl.mjs
ADDED
|
@@ -0,0 +1,230 @@
|
|
|
1
|
+
// Windows is a thin front end: run the engine inside WSL while keeping path,
|
|
2
|
+
// launch and settings handling injectable for tests on any operating system.
|
|
3
|
+
import fsDefault from "node:fs";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
|
|
6
|
+
// NOMARMY_WINDOWS_ENGINE=native runs every command in this Windows process
|
|
7
|
+
// instead of WSL. The test suite and CI use it to exercise the commands
|
|
8
|
+
// themselves; jobs still need the engine in WSL.
|
|
9
|
+
export function isNativeWindows({ platform = process.platform, env = process.env } = {}) {
|
|
10
|
+
return platform === "win32" && !env.WSL_DISTRO_NAME && !env.WSL_INTEROP && env.NOMARMY_WINDOWS_ENGINE !== "native";
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
// One switch for all Windows front-end decisions, including local commands.
|
|
14
|
+
export function windowsFrontEnd({ platform = process.platform, env = process.env } = {}) {
|
|
15
|
+
// One escape hatch, NOMARMY_WINDOWS_ENGINE=native, which isNativeWindows honors.
|
|
16
|
+
return isNativeWindows({ platform, env });
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
// Tells the engine inside WSL that its coordinators live on Windows (as
|
|
20
|
+
// connect's registration does), so commands forwarded from the Windows front
|
|
21
|
+
// end, install.sh included, point at `nomarmy connect` on Windows.
|
|
22
|
+
export function markWindowsCoordinator(env = process.env) {
|
|
23
|
+
env.NOMARMY_COORDINATOR_OS = "windows";
|
|
24
|
+
const shared = (env.WSLENV ?? "").split(":").filter(Boolean);
|
|
25
|
+
if (!shared.some((entry) => entry.split("/")[0] === "NOMARMY_COORDINATOR_OS")) env.WSLENV = [...shared, "NOMARMY_COORDINATOR_OS/u"].join(":");
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
// Decorate response objects only; keep Linux job records and paths intact.
|
|
29
|
+
export function withWindowsPaths(obj, env = process.env) {
|
|
30
|
+
if (env.NOMARMY_COORDINATOR_OS !== "windows" || !env.WSL_DISTRO_NAME) return obj;
|
|
31
|
+
if (Array.isArray(obj)) return obj.map(value => withWindowsPaths(value, env));
|
|
32
|
+
if (!obj || typeof obj !== "object") return obj;
|
|
33
|
+
const result = Object.fromEntries(Object.entries(obj).map(([key, value]) => [key, withWindowsPaths(value, env)]));
|
|
34
|
+
for (const key of ["jobDir", "worktree"]) {
|
|
35
|
+
if (typeof obj[key] !== "string") continue;
|
|
36
|
+
const windows = wslToWindowsPath(obj[key], { distro: env.WSL_DISTRO_NAME });
|
|
37
|
+
if (windows !== null) result[`${key}Windows`] = windows;
|
|
38
|
+
}
|
|
39
|
+
return result;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// WSL appends the Windows PATH (/mnt/c/...), so inside the distro `openclaw`
|
|
43
|
+
// or `nomarmy` can resolve to the Windows npm install: install.sh then skipped
|
|
44
|
+
// installing OpenClaw, and every call read its code across the Windows/VM
|
|
45
|
+
// boundary (about a minute each). The engine uses only the distro's tools,
|
|
46
|
+
// plus the Windows directory itself: powershell.exe (lib/notify.mjs's toasts)
|
|
47
|
+
// lives there, and never node or openclaw.
|
|
48
|
+
const WINDOWS_MOUNT_RE = /^\/mnt\/[A-Za-z](?:\/|$)/;
|
|
49
|
+
const WINDOWS_SYSTEM_RE = /^\/mnt\/[A-Za-z]\/windows(?:\/|$)/i;
|
|
50
|
+
|
|
51
|
+
export function isWindowsMountPath(p) {
|
|
52
|
+
return WINDOWS_MOUNT_RE.test(String(p));
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export function dropWindowsPath(env = process.env, { platform = process.platform } = {}) {
|
|
56
|
+
if (platform !== "linux" || !(env.WSL_DISTRO_NAME || env.WSL_INTEROP) || !env.PATH) return;
|
|
57
|
+
env.PATH = env.PATH.split(":").filter((dir) => dir && (!isWindowsMountPath(dir) || WINDOWS_SYSTEM_RE.test(dir))).join(":");
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export function parseWslList(output) {
|
|
61
|
+
let text = Buffer.isBuffer(output)
|
|
62
|
+
? output.toString(output.includes(0) || (output[0] === 0xff && output[1] === 0xfe) ? "utf16le" : "utf8")
|
|
63
|
+
: String(output);
|
|
64
|
+
// Also accept UTF-16LE accidentally decoded as UTF-8 by a caller.
|
|
65
|
+
text = text.replace(/\0/g, "").replace(/^[\uFEFF\uFFFD]+/, "");
|
|
66
|
+
const distros = [];
|
|
67
|
+
for (const line of text.split(/\r?\n/)) {
|
|
68
|
+
const match = /^\s*(\*)?\s*(.+?)\s{2,}(\S+)\s+(\d+)\s*$/.exec(line);
|
|
69
|
+
if (match) distros.push({ name: match[2], state: match[3], version: Number(match[4]), isDefault: Boolean(match[1]) });
|
|
70
|
+
}
|
|
71
|
+
return distros;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// Docker Desktop, Rancher Desktop and Podman machine register their own WSL
|
|
75
|
+
// distros. They have no bash or user Node and Docker's is often the default,
|
|
76
|
+
// so they are never a place to run the engine.
|
|
77
|
+
const UTILITY_DISTRO_RE = /^(?:docker-desktop(?:-data)?|rancher-desktop(?:-data)?|podman-machine-.+)$/i;
|
|
78
|
+
|
|
79
|
+
export function isUtilityDistro(name) {
|
|
80
|
+
return UTILITY_DISTRO_RE.test(name);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// run has the execFileSync contract: return stdout or throw on failure.
|
|
84
|
+
export function listDistros({ run }) {
|
|
85
|
+
let distros;
|
|
86
|
+
try { distros = parseWslList(run("wsl.exe", ["-l", "-v"])); } catch { return null; }
|
|
87
|
+
return distros.filter((d) => !isUtilityDistro(d.name));
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export function windowsToWslPath(p) {
|
|
91
|
+
const normalized = p.replace(/\\/g, "/");
|
|
92
|
+
if (normalized.startsWith("//")) {
|
|
93
|
+
const match = /^\/\/(?:wsl\$|wsl\.localhost)\/([^/]+)(\/.*)?$/i.exec(normalized);
|
|
94
|
+
return match ? { path: match[2] || "/", distro: match[1] } : null;
|
|
95
|
+
}
|
|
96
|
+
const drive = /^([A-Za-z]):\/(.*)$/.exec(normalized);
|
|
97
|
+
return { path: drive ? `/mnt/${drive[1].toLowerCase()}/${drive[2]}` : normalized, distro: null };
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export function wslToWindowsPath(p, { distro } = {}) {
|
|
101
|
+
const drive = /^\/mnt\/([a-z])(?:\/(.*))?$/.exec(p);
|
|
102
|
+
if (drive) return `${drive[1].toUpperCase()}:\\${(drive[2] || "").replace(/\//g, "\\")}`;
|
|
103
|
+
if (!p.startsWith("/")) return null;
|
|
104
|
+
validateDistro(distro);
|
|
105
|
+
return `\\\\wsl.localhost\\${distro}${p.replace(/\//g, "\\")}`;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
export function shellQuote(arg) {
|
|
109
|
+
const text = String(arg);
|
|
110
|
+
return /^[A-Za-z0-9_./:=@%+-]+$/.test(text) ? text : `'${text.replace(/'/g, "'\\''")}'`;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function validateDistro(distro) {
|
|
114
|
+
if (typeof distro !== "string" || distro.startsWith("-") || distro === "." || distro === ".." || !/^[A-Za-z0-9._-]{1,64}$(?![\s\S])/.test(distro)) {
|
|
115
|
+
throw new Error("Invalid WSL distro name");
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// Always --exec: after `--`, wsl.exe hands the joined arguments to the distro's
|
|
120
|
+
// shell, which expands $(...) and splits "a b" before the command sees them.
|
|
121
|
+
export function wslCommand({ distro, args, cwd = null, node = null, script = null }) {
|
|
122
|
+
validateDistro(distro);
|
|
123
|
+
if (cwd !== null && (typeof cwd !== "string" || !cwd.startsWith("/") || /[\r\n\0]/.test(cwd))) {
|
|
124
|
+
throw new Error("Invalid WSL cwd: expected an absolute POSIX path without newlines or NUL");
|
|
125
|
+
}
|
|
126
|
+
const direct = node !== null && script !== null;
|
|
127
|
+
if (direct) {
|
|
128
|
+
validateWslPath(node, "node");
|
|
129
|
+
validateWslPath(script, "script");
|
|
130
|
+
}
|
|
131
|
+
return {
|
|
132
|
+
command: "wsl.exe",
|
|
133
|
+
args: ["-d", distro, ...(cwd ? ["--cd", cwd] : []), "--exec", ...(direct ? [node, script, ...args] : ["bash", "-lic", `exec nomarmy ${args.map(shellQuote).join(" ")}`])],
|
|
134
|
+
};
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function validateWslPath(value, name) {
|
|
138
|
+
if (typeof value !== "string" || !/^\/[A-Za-z0-9_./:@+ =-]+$(?![\s\S])/.test(value)) {
|
|
139
|
+
throw new Error(`Invalid WSL ${name} path: expected an absolute POSIX path using only ASCII letters, digits, /, _, ., :, @, +, space, =, and -`);
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
export function resolveWslNomarmy({ distro, run }) {
|
|
144
|
+
validateDistro(distro);
|
|
145
|
+
const fix = `nomArmy isn't installed in WSL distro ${distro}: inside it run \`npm install -g nomarmy@alpha\` (Node 24.16+), then run nomarmy connect again`;
|
|
146
|
+
let output;
|
|
147
|
+
try {
|
|
148
|
+
output = run("wsl.exe", ["-d", distro, "--exec", "bash", "-lic", 'printf "NOMARMY_NODE=%s\\n" "$(command -v node)"; printf "NOMARMY_SCRIPT=%s\\n" "$(readlink -f "$(command -v nomarmy)")"'],
|
|
149
|
+
{ encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] });
|
|
150
|
+
} catch { throw new Error(fix); }
|
|
151
|
+
const paths = {};
|
|
152
|
+
let marked = false;
|
|
153
|
+
const lines = String(output ?? "").replace(/\r/g, "").split("\n");
|
|
154
|
+
if (lines.at(-1) === "") lines.pop();
|
|
155
|
+
for (const line of lines) {
|
|
156
|
+
const match = /^(NOMARMY_NODE|NOMARMY_SCRIPT)=(.*)$/.exec(line);
|
|
157
|
+
if (!match) {
|
|
158
|
+
// Login banners may precede the probe, but continuation lines must not
|
|
159
|
+
// silently truncate a path containing an embedded newline.
|
|
160
|
+
if (marked) throw new Error(fix);
|
|
161
|
+
continue;
|
|
162
|
+
}
|
|
163
|
+
marked = true;
|
|
164
|
+
if (!match[2] || Object.hasOwn(paths, match[1])) throw new Error(fix);
|
|
165
|
+
paths[match[1]] = match[2];
|
|
166
|
+
}
|
|
167
|
+
const { NOMARMY_NODE: node, NOMARMY_SCRIPT: script } = paths;
|
|
168
|
+
if (!node || !script) throw new Error(fix);
|
|
169
|
+
// Found through WSL's appended Windows PATH: that's the Windows install.
|
|
170
|
+
if (isWindowsMountPath(node) || isWindowsMountPath(script)) throw new Error(fix);
|
|
171
|
+
validateWslPath(node, "node");
|
|
172
|
+
validateWslPath(script, "script");
|
|
173
|
+
return { node, script };
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
export function pickDistro({ run, env = process.env, fs = fsDefault }) {
|
|
177
|
+
const distros = listDistros({ run });
|
|
178
|
+
if (distros === null) throw new Error("install WSL: run `wsl --install` in an administrator PowerShell, restart, then run nomarmy setup");
|
|
179
|
+
if (!distros.length) throw new Error("install a Linux distro: `wsl --install -d Ubuntu`");
|
|
180
|
+
const saved = readWindowsSettings({ env, fs }).distro;
|
|
181
|
+
const selected = distros.find((d) => d.name === saved)
|
|
182
|
+
|| distros.find((d) => d.isDefault && d.version === 2)
|
|
183
|
+
|| (distros.length === 1 ? distros[0] : null);
|
|
184
|
+
if (!selected) throw new Error("choose a default WSL 2 distro: run `wsl --set-default <distro>`, then run nomarmy connect again");
|
|
185
|
+
validateDistro(selected.name);
|
|
186
|
+
return selected.name;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
export function mcpBridgeLaunch({ distro, node, script }) {
|
|
190
|
+
validateDistro(distro);
|
|
191
|
+
const direct = node !== undefined || script !== undefined;
|
|
192
|
+
if (direct) {
|
|
193
|
+
validateWslPath(node, "node");
|
|
194
|
+
validateWslPath(script, "script");
|
|
195
|
+
}
|
|
196
|
+
return {
|
|
197
|
+
command: "wsl.exe",
|
|
198
|
+
args: direct ? ["-d", distro, "--exec", node, script, "mcp"] : ["-d", distro, "--exec", "bash", "-lc", "exec nomarmy mcp"],
|
|
199
|
+
env: { WSLENV: "CLAUDE_PROJECT_DIR/pu:NOMARMY_PROJECT_DIR/pu" },
|
|
200
|
+
};
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
// Select path semantics from the supplied home, not the CI host platform.
|
|
204
|
+
function settingsPaths(env) {
|
|
205
|
+
const home = env.USERPROFILE || env.HOME;
|
|
206
|
+
if (!home) throw new Error("USERPROFILE or HOME is required");
|
|
207
|
+
const paths = /^[A-Za-z]:[\\/]|^[\\/]{2}/.test(home) ? path.win32 : path.posix;
|
|
208
|
+
return { paths, file: paths.join(home, ".config", "nomarmy", "windows.json") };
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
export function windowsSettingsPath({ env = process.env } = {}) {
|
|
212
|
+
return settingsPaths(env).file;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
export function readWindowsSettings({ env = process.env, fs = fsDefault } = {}) {
|
|
216
|
+
try {
|
|
217
|
+
const settings = JSON.parse(fs.readFileSync(windowsSettingsPath({ env }), "utf8"));
|
|
218
|
+
const validPath = (name) => {
|
|
219
|
+
try { validateWslPath(settings?.[name], name); return settings[name]; }
|
|
220
|
+
catch { return null; }
|
|
221
|
+
};
|
|
222
|
+
return { distro: typeof settings?.distro === "string" ? settings.distro : null, node: validPath("node"), script: validPath("script") };
|
|
223
|
+
} catch { return { distro: null, node: null, script: null }; }
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
export function writeWindowsSettings(settings, { env = process.env, fs = fsDefault } = {}) {
|
|
227
|
+
const { paths, file } = settingsPaths(env);
|
|
228
|
+
fs.mkdirSync(paths.dirname(file), { recursive: true });
|
|
229
|
+
fs.writeFileSync(file, `${JSON.stringify(settings, null, 2)}\n`, "utf8");
|
|
230
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
// Zod 4 helpers shared by nomArmy's config schemas and their issue formatters.
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The `error` option for a primitive schema: "is required" when the field is
|
|
5
|
+
* absent, "must be <expected>" when it has the wrong type. Zod 4 replaced
|
|
6
|
+
* zod 3's required_error and invalid_type_error with this single function.
|
|
7
|
+
* @param {string} expected e.g. "a string"
|
|
8
|
+
*/
|
|
9
|
+
export const typeError = (expected) => (issue) => (issue.input === undefined ? "is required" : `must be ${expected}`);
|
|
10
|
+
|
|
11
|
+
/** A field that is absent entirely, with no custom message of its own. */
|
|
12
|
+
export const isMissingField = (issue) => issue.code === "invalid_type" && / received undefined$/.test(issue.message);
|
|
13
|
+
|
|
14
|
+
/** A discriminated union whose discriminator matched none of its options. */
|
|
15
|
+
export const isUnknownDiscriminator = (issue) => issue.code === "invalid_union" && issue.note === "No matching discriminator";
|