dsh-plugin-wsl-env 0.11.0 → 0.13.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 (45) hide show
  1. package/CHANGELOG.md +181 -0
  2. package/CONTRIBUTING.md +29 -7
  3. package/README.md +124 -138
  4. package/SECURITY.md +11 -1
  5. package/SUPPORT.md +29 -2
  6. package/cordis.patch.yml +17 -19
  7. package/docs/ARCHITECTURE.md +30 -29
  8. package/docs/CONFIGURATION.md +81 -21
  9. package/docs/LIMITATIONS.md +71 -6
  10. package/docs/MIGRATION.md +98 -0
  11. package/docs/PARITY.md +1 -1
  12. package/docs/PERFORMANCE.md +60 -0
  13. package/docs/QUICKSTART.md +125 -0
  14. package/docs/README.zh.md +117 -137
  15. package/docs/ROADMAP-1.0.md +780 -0
  16. package/docs/ROADMAP.md +4 -0
  17. package/docs/UPSTREAM-FILE-REFERENCE-SEAM.md +80 -0
  18. package/docs/UPSTREAM-LSP-SEAM.md +90 -0
  19. package/docs/UPSTREAM-SESSION-NOTICE.md +95 -0
  20. package/lib/agent-errors.d.ts +19 -0
  21. package/lib/agent-errors.js +22 -0
  22. package/lib/agent-exec.js +2 -2
  23. package/lib/agent.d.ts +68 -5
  24. package/lib/agent.js +129 -22
  25. package/lib/distro-route.d.ts +75 -0
  26. package/lib/distro-route.js +86 -0
  27. package/lib/fs-routing.d.ts +4 -1
  28. package/lib/index.d.ts +166 -37
  29. package/lib/index.js +353 -75
  30. package/lib/paths.d.ts +15 -0
  31. package/lib/paths.js +28 -0
  32. package/lib/preflight.d.ts +46 -3
  33. package/lib/preflight.js +83 -14
  34. package/lib/shell-env.d.ts +9 -3
  35. package/lib/shell-env.js +121 -43
  36. package/lib/subprocess.d.ts +12 -3
  37. package/lib/subprocess.js +21 -3
  38. package/package.json +23 -15
  39. package/scripts/diagnose.sh +9 -88
  40. package/scripts/doctor.mjs +548 -0
  41. package/scripts/profile-preset.mjs +254 -0
  42. package/lib/workspace-files-route.d.ts +0 -136
  43. package/lib/workspace-files-route.js +0 -225
  44. package/lib/workspace-files-wsl.d.ts +0 -257
  45. package/lib/workspace-files-wsl.js +0 -418
@@ -0,0 +1,548 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * One-command environment doctor for this plugin (P0.2 / WSL-102).
4
+ *
5
+ * Answers "will this plugin work here, and if not, what do I paste?" in one
6
+ * read-only pass: Windows and WSL versions, the distro and its shell, bubblewrap
7
+ * usability, systemd / the user manager / `systemd-run` / Linger, the optional
8
+ * tools, the peer versions the plugin is loaded against, the native-agent cache,
9
+ * a fresh-agent handshake, stale session scopes, and the known profile-patch trap.
10
+ *
11
+ * node scripts/doctor.mjs [distro] [--json] [--fix] [--install] [--yes] [--read-only]
12
+ *
13
+ * Exit status is 0 when nothing failed and 1 when a `fail` check did; warnings
14
+ * (optional tools, a missing cache) do not fail the run. `--fix` performs only
15
+ * safe actions: it sweeps stale `dsh-wsl-*.scope` units (the same sweep the
16
+ * session preflight runs). Installing packages needs `--install` *and* an
17
+ * explicit confirmation (`--yes` skips the prompt, not the flag), so a fix run
18
+ * can never install silently.
19
+ *
20
+ * `--read-only` refuses `--fix`/`--install`; `scripts/diagnose.sh` uses it to stay an
21
+ * alias that can never change the machine.
22
+ *
23
+ * `--json` prints one machine-readable report instead of the human sections.
24
+ * The report shape is versioned (`schemaVersion`).
25
+ *
26
+ * @module dsh-plugin-wsl/scripts/doctor
27
+ */
28
+ import { execFile, spawn } from "node:child_process";
29
+ import { createHash } from "node:crypto";
30
+ import { createRequire } from "node:module";
31
+ import { existsSync, readdirSync, readFileSync } from "node:fs";
32
+ import { homedir } from "node:os";
33
+ import { dirname, join, resolve } from "node:path";
34
+ import { createInterface } from "node:readline/promises";
35
+ import { fileURLToPath } from "node:url";
36
+ import { parseDistroList } from "../lib/wsl.js";
37
+ import { AGENT_NAME, PROTOCOL_VERSION } from "../lib/agent-protocol.js";
38
+ import { sessionScopeSweepScript } from "../lib/session-scope.js";
39
+
40
+ const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), "..");
41
+ const WSL = "wsl.exe";
42
+ const CMD = "cmd.exe";
43
+ const TIMEOUT_MS = 60_000;
44
+
45
+ /** The package families the install remedies know, in probe order. */
46
+ const FAMILIES = ["apt-get", "dnf", "pacman", "zypper"];
47
+ /** The package names the plugin uses, per family. */
48
+ const PACKAGES = "bubblewrap git ripgrep inotify-tools";
49
+ /** One install command per family; `zypper` spells its non-interactive flag differently. */
50
+ const INSTALL = /** @type {Record<string, string>} */ ({
51
+ "apt-get": `apt-get install -y ${PACKAGES}`,
52
+ dnf: `dnf install -y ${PACKAGES}`,
53
+ pacman: `pacman -Sy --noconfirm ${PACKAGES}`,
54
+ zypper: `zypper --non-interactive install ${PACKAGES}`,
55
+ });
56
+ /** The read-only stale-scope report; `--fix` runs the shipped sweep instead. */
57
+ const STALE_SCOPE_REPORT = [
58
+ "systemctl --user is-system-running >/dev/null 2>&1 || { echo none; exit 0; }",
59
+ "systemctl --user list-units 'dsh-wsl-*.scope' --all --plain --no-legend --no-pager 2>/dev/null | while read -r unit _; do",
60
+ ' [ -n "$unit" ] || continue',
61
+ ' cg=$(systemctl --user show -p ControlGroup --value "$unit" 2>/dev/null)',
62
+ " alive=0",
63
+ ' if [ -n "$cg" ] && [ -r "/sys/fs/cgroup$cg/cgroup.procs" ]; then',
64
+ ' while read -r pid; do',
65
+ ' [ -n "$pid" ] || continue',
66
+ " if tr '\\0' ' ' < \"/proc/$pid/cmdline\" 2>/dev/null | grep -q -e 'wsl-agent.sh' -e 'dsh-wsl-agent'; then alive=1; break; fi",
67
+ ' done < "/sys/fs/cgroup$cg/cgroup.procs"',
68
+ " fi",
69
+ ' echo "$unit $([ "$alive" -eq 1 ] && echo active || echo stale)"',
70
+ "done",
71
+ ].join("\n");
72
+ /** The agent-cache inventory: one `state digest` line per cached binary. */
73
+ const CACHE_REPORT = [
74
+ 'd="$HOME/.cache/dsh-wsl/agent"',
75
+ '[ -d "$d" ] || { echo none; exit 0; }',
76
+ 'for f in "$d"/*/dsh-wsl-agent; do',
77
+ " [ -e \"$f\" ] || continue",
78
+ ' if [ -x "$f" ]; then echo "exec $(basename "$(dirname "$f")")"; else echo "noexec $(basename "$(dirname "$f")")"; fi',
79
+ "done",
80
+ ].join("\n");
81
+
82
+ /**
83
+ * One check result.
84
+ * @typedef {object} Check
85
+ * @property {string} id - stable dotted id.
86
+ * @property {"ok"|"warn"|"fail"|"info"} status - the verdict.
87
+ * @property {string} title - what was checked.
88
+ * @property {string} [detail] - the evidence.
89
+ * @property {string} [remedy] - a copy-paste next step when not ok.
90
+ */
91
+
92
+ /**
93
+ * Run one host command; never throws.
94
+ * @param {string[]} argv - executable and arguments.
95
+ * @returns {Promise<{ ok: boolean, stdout: string, stderr: string, code?: string }>} the capture.
96
+ */
97
+ function run(argv) {
98
+ return new Promise((settle) => {
99
+ const [file, ...args] = argv;
100
+ if (file === undefined) throw new Error("doctor: empty argv");
101
+ /**
102
+ * @param {Error | null} error - the spawn/exit error, if any.
103
+ * @param {string} stdout - captured stdout.
104
+ * @param {string} stderr - captured stderr.
105
+ */
106
+ const done = (error, stdout, stderr) => {
107
+ const code = error === null ? undefined : /** @type {NodeJS.ErrnoException} */ (error).code ?? "1";
108
+ settle({ ok: error === null, stdout: stdout ?? "", stderr: stderr ?? "", code });
109
+ };
110
+ execFile(file, args, { encoding: "utf8", timeout: TIMEOUT_MS, windowsHide: true, env: { ...process.env, WSL_UTF8: "1" } }, done);
111
+ });
112
+ }
113
+
114
+ /**
115
+ * Run one `sh -c` script inside the distro.
116
+ * @param {string} distro - the distro name.
117
+ * @param {string} script - the shell script.
118
+ * @returns {Promise<{ ok: boolean, stdout: string, stderr: string, code?: string }>} the capture.
119
+ */
120
+ function inDistro(distro, script) {
121
+ return run([WSL, "-d", distro, "--exec", "sh", "-c", script]);
122
+ }
123
+
124
+ /**
125
+ * Parse the doctor's own arguments.
126
+ * @param {string[]} argv - `process.argv.slice(2)`.
127
+ * @returns {{ distro: string, json: boolean, fix: boolean, install: boolean, yes: boolean, readOnly: boolean, help: boolean }} the options.
128
+ */
129
+ function parseArgs(argv) {
130
+ const options = { distro: "", json: false, fix: false, install: false, yes: false, readOnly: false, help: false };
131
+ for (let i = 0; i < argv.length; i += 1) {
132
+ const arg = argv[i] ?? "";
133
+ if (arg === "--json") options.json = true;
134
+ else if (arg === "--fix") options.fix = true;
135
+ else if (arg === "--install") options.install = true;
136
+ else if (arg === "--yes" || arg === "-y") options.yes = true;
137
+ else if (arg === "--read-only") options.readOnly = true;
138
+ else if (arg === "--help" || arg === "-h") options.help = true;
139
+ else if (arg === "--distro") options.distro = argv[++i] ?? "";
140
+ else if (arg.startsWith("--distro=")) options.distro = arg.slice("--distro=".length);
141
+ else if (arg.startsWith("-")) throw new Error(`doctor: unknown option "${arg}"`);
142
+ else options.distro = arg;
143
+ }
144
+ return options;
145
+ }
146
+
147
+ /**
148
+ * Whether an installed version satisfies one peer range. The plugin's pins are
149
+ * exact or tilde ranges, so those two shapes are all this understands; anything
150
+ * else is reported as unchecked rather than guessed at.
151
+ * @param {string} installed - the installed version.
152
+ * @param {string} range - the declared peer range.
153
+ * @returns {boolean | undefined} true/false, or undefined for an unsupported shape.
154
+ */
155
+ export function peerSatisfied(installed, range) {
156
+ if (range.startsWith("~")) {
157
+ const [major, minor, patch] = range.slice(1).split(".");
158
+ return compareVersions(installed, `${major}.${minor}.${patch ?? "0"}`) >= 0
159
+ && installed.split(".")[0] === major
160
+ && installed.split(".")[1] === minor;
161
+ }
162
+ if (/^[0-9]/.test(range)) return installed === range;
163
+ return undefined;
164
+ }
165
+
166
+ /**
167
+ * Compare two semver-ish versions (prerelease sorts below its release).
168
+ * @param {string} left - the first version.
169
+ * @param {string} right - the second version.
170
+ * @returns {number} negative, zero or positive like a comparator.
171
+ */
172
+ export function compareVersions(left, right) {
173
+ /**
174
+ * @param {string} value - one version.
175
+ * @returns {{ major: number, minor: number, patch: number, pre: string }} its parts.
176
+ */
177
+ const split = (value) => {
178
+ const [rawBase, pre = ""] = value.split("-", 2);
179
+ const base = rawBase ?? "";
180
+ const parts = base.split(".").map((part) => Number(part.replace(/\D.*$/u, "")) || 0);
181
+ return { major: parts[0] ?? 0, minor: parts[1] ?? 0, patch: parts[2] ?? 0, pre };
182
+ };
183
+ const a = split(left);
184
+ const b = split(right);
185
+ if (a.major !== b.major) return a.major - b.major;
186
+ if (a.minor !== b.minor) return a.minor - b.minor;
187
+ if (a.patch !== b.patch) return a.patch - b.patch;
188
+ if (a.pre === b.pre) return 0;
189
+ if (a.pre === "") return 1;
190
+ if (b.pre === "") return -1;
191
+ return a.pre.localeCompare(b.pre);
192
+ }
193
+
194
+ /**
195
+ * Read the installed version of one peer, or undefined when it cannot resolve.
196
+ * @param {string} packageName - the peer package.
197
+ * @returns {string | undefined} the installed version.
198
+ */
199
+ function installedPeer(packageName) {
200
+ const require = createRequire(join(ROOT, "package.json"));
201
+ try {
202
+ const manifest = /** @type {{ version?: string }} */ (JSON.parse(readFileSync(require.resolve(`${packageName}/package.json`), "utf8")));
203
+ return manifest.version;
204
+ } catch {
205
+ return undefined;
206
+ }
207
+ }
208
+
209
+ /**
210
+ * The install remedy for one distro and family.
211
+ * @param {string} distro - the distro name.
212
+ * @param {string} family - the detected family, or `unknown`.
213
+ * @returns {string} the copy-paste command.
214
+ */
215
+ function installRemedy(distro, family) {
216
+ return `wsl.exe -d ${distro} -u root -- ${INSTALL[family] ?? INSTALL["apt-get"]}`;
217
+ }
218
+
219
+ /**
220
+ * Refuse a fix through the read-only alias.
221
+ * @returns {number} the exit status.
222
+ */
223
+ function rejectReadOnly() {
224
+ console.error("doctor: --read-only refuses --fix/--install; run `pnpm run doctor -- --fix` instead");
225
+ return 2;
226
+ }
227
+
228
+ /**
229
+ * Run the doctor.
230
+ * @param {{ distro: string, json: boolean, fix: boolean, install: boolean, yes: boolean, readOnly: boolean }} options - the parsed options.
231
+ * @returns {Promise<number>} the exit status.
232
+ */
233
+ async function main(options) {
234
+ if (options.readOnly && (options.fix || options.install)) return rejectReadOnly();
235
+ /** @type {Check[]} */
236
+ const checks = [];
237
+ /**
238
+ * @param {string} id - the check id.
239
+ * @param {Check["status"]} status - the verdict.
240
+ * @param {string} title - what was checked.
241
+ * @param {string} [detail] - the evidence.
242
+ * @param {string} [remedy] - the next step.
243
+ */
244
+ const check = (id, status, title, detail, remedy) => {
245
+ checks.push({ id, status, title, ...(detail === undefined ? {} : { detail }), ...(remedy === undefined ? {} : { remedy }) });
246
+ };
247
+
248
+ // ---- host ----------------------------------------------------------------
249
+ const windows = await run([CMD, "/c", "ver"]);
250
+ const build = /[0-9][0-9.]*/.exec(windows.stdout.replace(/\0/g, ""));
251
+ if (build !== null) check("host.windows", "ok", "Windows build", build[0]);
252
+ else check("host.windows", "info", "Windows build", "unreadable through cmd.exe; run this from Windows or a WSL with interop enabled");
253
+
254
+ const version = await run([WSL, "--version"]);
255
+ const wslVersion = version.stdout.replace(/\0/g, "").split(/\r?\n/).filter((line) => line.trim().length > 0).slice(0, 2).join(" ");
256
+ if (version.ok && wslVersion.length > 0) check("host.wsl", "ok", "WSL version", wslVersion);
257
+ else check("host.wsl", "warn", "WSL version", "wsl.exe --version failed; WSL 1 or a very old build", "wsl.exe --update, then confirm the distro runs as WSL 2 (wsl.exe -l -v shows VERSION 2)");
258
+
259
+ const listed = await run([WSL, "-l", "-q"]);
260
+ const distros = listed.ok ? parseDistroList(listed.stdout.replace(/\0/g, "")) : [];
261
+ if (distros.length === 0) {
262
+ check("host.distros", "fail", "Installed distros", "none", "wsl.exe --list --online lists names; wsl.exe --install -d <name> installs one, then reopen the session");
263
+ } else {
264
+ check("host.distros", "ok", "Installed distros", distros.join(", "));
265
+ }
266
+ const distro = options.distro.length > 0 ? options.distro : distros[0] ?? "";
267
+ if (distro.length === 0) return report(checks, { json: options.json });
268
+
269
+ if (options.distro.length > 0 && !distros.includes(options.distro)) {
270
+ check("distro.present", "fail", `Distro "${distro}"`, `not in [${distros.join(", ")}]`, `wsl.exe --install -d ${distro}; this doctor never installs a distro for you`);
271
+ return report(checks, { json: options.json });
272
+ }
273
+
274
+ const alive = await inDistro(distro, "printf %s ok");
275
+ if (!alive.ok || alive.stdout.trim() !== "ok") {
276
+ check("distro.reachable", "fail", `Distro "${distro}"`, "wsl.exe could not run a command in it (stopped, wedged, or missing)", `wsl.exe -d ${distro} -- true; if that hangs, wsl.exe --shutdown, then retry`);
277
+ return report(checks, { json: options.json });
278
+ }
279
+ check("distro.reachable", "ok", `Distro "${distro}"`, "answers commands");
280
+
281
+ // ---- distro facts --------------------------------------------------------
282
+ const shellAnswer = await inDistro(distro, 's=$(getent passwd "$(id -u)" | cut -d: -f7); [ -n "$s" ] || s=$SHELL; printf %s "$s"');
283
+ const shell = shellAnswer.stdout.trim();
284
+ if (shell.startsWith("/")) check("distro.shell", "ok", "Login shell", shell);
285
+ else check("distro.shell", "fail", "Login shell", shell.length === 0 ? "no shell reported" : shell, `check the user's /etc/passwd entry in "${distro}": getent passwd "$(id -u)"`);
286
+
287
+ const homeAnswer = await inDistro(distro, 'printf %s "$HOME"');
288
+ const home = homeAnswer.stdout.trim();
289
+ if (home.startsWith("/")) check("distro.home", "ok", "Home", home);
290
+ else check("distro.home", "fail", "Home", home.length === 0 ? "no home reported" : home, `check the default user's home in "${distro}": getent passwd "$(id -u)"`);
291
+
292
+ const kernel = (await inDistro(distro, "uname -r")).stdout.trim();
293
+ check("distro.kernel", "info", "Kernel", kernel.length > 0 ? kernel : "unreadable");
294
+
295
+ const familyAnswer = await inDistro(distro, `for f in ${FAMILIES.join(" ")}; do command -v "$f" >/dev/null 2>&1 && { echo "$f"; exit 0; }; done; echo unknown`);
296
+ const family = familyAnswer.stdout.trim();
297
+ check("distro.family", "info", "Package family", family.length > 0 ? family : "unknown");
298
+
299
+ // ---- sandbox prerequisites ----------------------------------------------
300
+ const bwrapPath = (await inDistro(distro, "command -v bwrap")).stdout.trim();
301
+ const install = installRemedy(distro, family);
302
+ if (bwrapPath.length === 0) {
303
+ check("sandbox.bwrap", "fail", "bubblewrap", "missing; every confined command fails closed without it", `${install}, or run "pnpm run bootstrap -- ${distro} --install"`);
304
+ } else {
305
+ check("sandbox.bwrap", "ok", "bubblewrap", bwrapPath);
306
+ const userns = await inDistro(distro, "bwrap --ro-bind / / -- true");
307
+ if (userns.ok) check("sandbox.bwrap-usable", "ok", "bubblewrap usability", "a read-only profile runs");
308
+ else check("sandbox.bwrap-usable", "fail", "bubblewrap usability", "installed but the read-only profile failed", "the kernel likely disables unprivileged user namespaces; reinstalling will not help — see docs/LIMITATIONS.md");
309
+ }
310
+
311
+ // ---- session sandbox prerequisites --------------------------------------
312
+ const systemd = await inDistro(distro, 'if [ -d /run/systemd/system ]; then printf "systemd %s" "$(cat /proc/1/comm)"; else printf "none %s" "$(cat /proc/1/comm)"; fi');
313
+ if (!systemd.stdout.startsWith("systemd")) {
314
+ check("session.systemd", "fail", "systemd as PID 1", systemd.stdout.trim().length > 0 ? systemd.stdout.trim() : "not systemd", `set [boot] systemd=true in /etc/wsl.conf inside "${distro}", run "wsl.exe --shutdown", then reopen the session. Without it, sessionSandbox cannot run`);
315
+ } else check("session.systemd", "ok", "systemd as PID 1", "running");
316
+
317
+ const systemdRun = (await inDistro(distro, "command -v systemd-run")).stdout.trim();
318
+ if (systemdRun.length > 0) check("session.systemd-run", "ok", "systemd-run", systemdRun);
319
+ else check("session.systemd-run", "fail", "systemd-run", "missing; the distro's systemd package is incomplete", `reinstall systemd inside "${distro}" (apt-get install --reinstall systemd, or the family's equivalent)`);
320
+
321
+ const userManager = (await inDistro(distro, "systemctl --user is-system-running 2>/dev/null || true")).stdout.trim();
322
+ if (userManager === "running" || userManager === "degraded") check("session.user-manager", "ok", "systemd user manager", userManager);
323
+ else check("session.user-manager", "fail", "systemd user manager", userManager.length > 0 ? userManager : "unreachable", `log in to "${distro}" once, or enable lingering (next remedy), then retry`);
324
+
325
+ const linger = (await inDistro(distro, 'loginctl show-user "$(id -un)" -p Linger 2>/dev/null')).stdout.trim();
326
+ if (linger === "Linger=yes") check("session.linger", "ok", "Linger", linger);
327
+ else check("session.linger", "fail", "Linger", linger.length > 0 ? linger : "unset", `wsl.exe -d ${distro} -- loginctl enable-linger "$(id -un)" (this needs the user's own session; run it inside the distro)`);
328
+
329
+ // ---- optional tools ------------------------------------------------------
330
+ /** The optional tools, in the order users notice them losing. */
331
+ /** @type {Array<[string, string]>} */
332
+ const optionalTools = [["rg", "in-distro search"], ["git", "workspace-change baselines"], ["inotifywait", "the watcher's primary backend"]];
333
+ for (const [tool, purpose] of optionalTools) {
334
+ const found = (await inDistro(distro, `command -v ${tool}`)).stdout.trim();
335
+ if (found.length > 0) check(`tools.${tool}`, "ok", tool, found);
336
+ else check(`tools.${tool}`, "warn", tool, `missing; ${purpose} degrades to a slower fallback`, install);
337
+ }
338
+
339
+ // ---- agents and caches ---------------------------------------------------
340
+ const arch = (await inDistro(distro, "uname -m")).stdout.trim();
341
+ const packaged = ["x86_64", "aarch64"].includes(arch) ? join(ROOT, "agent", "native", `linux-${arch}`, "dsh-wsl-agent") : "";
342
+ if (arch.length === 0) check("agent.arch", "info", "Distro architecture", "unreadable");
343
+ else if (packaged.length === 0) check("agent.arch", "info", "Distro architecture", `${arch} has no packaged native agent; the shell agent is the only backend`);
344
+ else if (existsSync(packaged)) check("agent.native", "ok", "Packaged native agent", packaged.slice(ROOT.length + 1));
345
+ else check("agent.native", "warn", "Packaged native agent", `missing for ${arch}`, "reinstall the plugin (the binaries ship with the package), or use agentBackend: shell");
346
+
347
+ const cache = (await inDistro(distro, CACHE_REPORT)).stdout.trim();
348
+ if (cache === "none" || cache.length === 0) check("agent.cache", "info", "Agent cache", "empty (a first command populates it)");
349
+ else {
350
+ const entries = cache.split(/\r?\n/).filter((line) => line.trim().length > 0);
351
+ const bad = entries.filter((line) => line.startsWith("noexec ")).length;
352
+ check("agent.cache", bad > 0 ? "warn" : "ok", "Agent cache", `${entries.length} cached binary(ies)${bad > 0 ? `, ${bad} not executable` : ""}`, bad > 0 ? `rm -rf ~/.cache/dsh-wsl/agent inside "${distro}"; the next command repopulates it` : undefined);
353
+ }
354
+
355
+ // ---- resident handshake --------------------------------------------------
356
+ const agentScript = join(ROOT, "agent", "wsl-agent.sh");
357
+ if (!agentScript.startsWith("/")) {
358
+ check("agent.handshake", "info", "Agent handshake", "skipped: run the doctor from inside the distro (the script path is a Windows path)");
359
+ } else if (!existsSync(agentScript)) {
360
+ check("agent.handshake", "fail", "Agent handshake", "agent/wsl-agent.sh is missing from this install", "reinstall the plugin; the shell agent ships with the package");
361
+ } else {
362
+ const handshake = await probeResident(distro, agentScript);
363
+ if (handshake.ok) {
364
+ check("agent.handshake", handshake.digestMatches ? "ok" : "fail", "Agent handshake",
365
+ `pid ${handshake.pid}, protocol ${PROTOCOL_VERSION}, digest ${handshake.digestMatches ? "matches" : "MISMATCH — the distro reads a different script than this package"}`,
366
+ handshake.digestMatches ? undefined : "resync the runtime copy (pnpm run sync:windows), then restart the app");
367
+ } else {
368
+ check("agent.handshake", "fail", "Agent handshake", handshake.detail, "restart the DSH app; if it persists, delete ~/.cache/dsh-wsl/agent inside the distro and rerun the doctor");
369
+ }
370
+ }
371
+
372
+ // ---- stale session scopes ------------------------------------------------
373
+ const scopes = (await inDistro(distro, STALE_SCOPE_REPORT)).stdout.trim();
374
+ const scopeLines = scopes === "none" || scopes.length === 0 ? [] : scopes.split(/\r?\n/);
375
+ const stale = scopeLines.filter((line) => line.endsWith(" stale")).length;
376
+ if (scopeLines.length === 0) check("runtime.scopes", "info", "Session scopes", "none (no user manager, or none left behind)");
377
+ else if (stale === 0) check("runtime.scopes", "ok", "Session scopes", `${scopeLines.length} live, none stale`);
378
+ else check("runtime.scopes", "warn", "Session scopes", `${stale} stale of ${scopeLines.length}`, "--fix stops them (the same sweep the session preflight runs); they are safe to stop when no agent process is inside");
379
+
380
+ // ---- peers ---------------------------------------------------------------
381
+ const manifest = /** @type {{ peerDependencies?: Record<string, string> }} */ (JSON.parse(readFileSync(join(ROOT, "package.json"), "utf8")));
382
+ const peers = Object.entries(manifest.peerDependencies ?? {});
383
+ const badPeers = [];
384
+ const unknownPeers = [];
385
+ for (const [name, range] of peers) {
386
+ const installed = installedPeer(name);
387
+ if (installed === undefined) badPeers.push(`${name}: not installed (want ${range})`);
388
+ else if (peerSatisfied(installed, range) === false) badPeers.push(`${name}: ${installed} (want ${range})`);
389
+ else if (peerSatisfied(installed, range) === undefined) unknownPeers.push(`${name}: ${installed} (range ${range} unchecked)`);
390
+ }
391
+ if (badPeers.length === 0) check("peers.versions", "ok", "Peer versions", `${peers.length} pinned peer(s) match${unknownPeers.length > 0 ? `; ${unknownPeers.length} unchecked` : ""}`);
392
+ else check("peers.versions", "fail", "Peer versions", badPeers.join("; "), "install the peer set this plugin pins (pnpm install in the plugin, or reinstall/upgrade it against this harness); see docs/PEER-PARITY.md");
393
+
394
+ // ---- profile-patch trap --------------------------------------------------
395
+ const dshHome = process.env.DSH_HOME !== undefined && process.env.DSH_HOME.length > 0 ? process.env.DSH_HOME : join(homedir(), ".dsh");
396
+ const profilesDir = join(dshHome, "profiles");
397
+ if (!existsSync(profilesDir)) {
398
+ check("config.profiles", "info", "Profile patches", `no profiles under ${profilesDir}`);
399
+ } else {
400
+ /** @type {string[]} */
401
+ const trapped = [];
402
+ let seen = 0;
403
+ for (const entry of readdirSync(profilesDir, { withFileTypes: true })) {
404
+ if (!entry.isDirectory()) continue;
405
+ const patch = join(profilesDir, entry.name, "cordis.patch.yml");
406
+ if (!existsSync(patch)) continue;
407
+ seen += 1;
408
+ const text = readFileSync(patch, "utf8");
409
+ if (/^\s*-\s*id:\s*(wsl-shell|wsl-fs)\s*$/m.test(text)) trapped.push(entry.name);
410
+ }
411
+ if (trapped.length > 0) {
412
+ check("config.preset-trap", "warn", "Profile patches", `nested rows patched by id in: ${trapped.join(", ")}`, "wsl-shell and wsl-fs live inside preset-wsl; an id patch matches nothing and is silently ignored. Run \"pnpm run preset -- --check <patch>\" to get the complete block, or see docs/CONFIGURATION.md#overriding-the-rows-inside-preset-wsl");
413
+ } else {
414
+ check("config.profiles", "ok", "Profile patches", `${seen} profile patch(es) scanned`);
415
+ }
416
+ }
417
+
418
+ // ---- fixes ---------------------------------------------------------------
419
+ if (options.fix) {
420
+ if (stale > 0) {
421
+ const swept = await inDistro(distro, sessionScopeSweepScript());
422
+ check("fix.scopes", swept.ok ? "ok" : "warn", "Swept stale scopes", swept.ok ? `${stale} stale scope(s) stopped` : "the sweep failed", swept.ok ? undefined : `systemctl --user list-units 'dsh-wsl-*.scope' inside "${distro}"`);
423
+ }
424
+ if (bwrapPath.length === 0) {
425
+ if (!options.install) {
426
+ check("fix.install", "info", "Package installation", "not attempted", "re-run with --fix --install (and --yes for a non-interactive run)");
427
+ } else if (!(await confirm(`Install ${PACKAGES} in "${distro}" as root?`, options.yes))) {
428
+ check("fix.install", "info", "Package installation", "declined", install);
429
+ } else {
430
+ const command = INSTALL[family] ?? INSTALL["apt-get"] ?? `apt-get install -y ${PACKAGES}`;
431
+ const installed = await run([WSL, "-d", distro, "-u", "root", "--", "sh", "-c", command]);
432
+ check("fix.install", installed.ok ? "ok" : "warn", "Package installation", installed.ok ? "completed" : "failed; see the output above", installed.ok ? undefined : install);
433
+ }
434
+ }
435
+ }
436
+
437
+ return report(checks, { json: options.json });
438
+ }
439
+
440
+ /**
441
+ * Probe a fresh resident: start the shipped shell agent in the distro, read its
442
+ * HELLO, verify the script digest, and stop it. This proves the agent path can
443
+ * start and that the file the distro reads is the one this package shipped.
444
+ *
445
+ * @param {string} distro - the distro to probe.
446
+ * @param {string} scriptPath - the agent script as a Linux path inside the distro.
447
+ * @returns {Promise<{ ok: true, pid: number, digestMatches: boolean } | { ok: false, detail: string }>} the verdict, with the pid on success.
448
+ */
449
+ function probeResident(distro, scriptPath) {
450
+ return new Promise((settle) => {
451
+ let settled = false;
452
+ /** @param {{ ok: true, pid: number, digestMatches: boolean } | { ok: false, detail: string }} value - the verdict. */
453
+ const finish = (value) => {
454
+ if (settled) return;
455
+ settled = true;
456
+ clearTimeout(timer);
457
+ child.kill();
458
+ settle(value);
459
+ };
460
+ const child = spawn(WSL, ["-d", distro, "--exec", "sh", scriptPath], {
461
+ stdio: ["pipe", "pipe", "pipe"],
462
+ windowsHide: true,
463
+ env: { ...process.env, WSL_UTF8: "1" },
464
+ });
465
+ const timer = setTimeout(() => finish({ ok: false, detail: "no HELLO within 5 s" }), 5000);
466
+ let buffered = "";
467
+ child.stdout.on("data", (chunk) => {
468
+ buffered += String(chunk);
469
+ const newline = buffered.indexOf("\n");
470
+ if (newline < 0 || settled) return;
471
+ const line = buffered.slice(0, newline).trim();
472
+ const [kind, name, version, digest, pid] = line.split("|");
473
+ if (kind !== "HELLO" || name !== AGENT_NAME) {
474
+ finish({ ok: false, detail: `unexpected first line: ${line.slice(0, 80)}` });
475
+ return;
476
+ }
477
+ if (Number(version) !== PROTOCOL_VERSION) {
478
+ finish({ ok: false, detail: `protocol ${version}, expected ${PROTOCOL_VERSION}` });
479
+ return;
480
+ }
481
+ const expected = createHash("sha256").update(readFileSync(scriptPath)).digest("hex");
482
+ finish({ ok: true, pid: Number(pid), digestMatches: digest === expected });
483
+ });
484
+ child.on("error", (error) => finish({ ok: false, detail: error.message }));
485
+ child.on("exit", () => {
486
+ if (!settled) finish({ ok: false, detail: `agent exited before its HELLO (${buffered.trim().slice(0, 80)})` });
487
+ });
488
+ child.stdin.end();
489
+ });
490
+ }
491
+
492
+ /**
493
+ * Ask one yes/no question on the terminal.
494
+ * @param {string} question - the prompt.
495
+ * @param {boolean} yes - skip the prompt when true.
496
+ * @returns {Promise<boolean>} the answer; false without a TTY.
497
+ */
498
+ async function confirm(question, yes) {
499
+ if (yes) return true;
500
+ if (!process.stdin.isTTY) return false;
501
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
502
+ try {
503
+ const answer = await rl.question(`${question} [y/N] `);
504
+ return /^y(es)?$/i.test(answer.trim());
505
+ } finally {
506
+ rl.close();
507
+ }
508
+ }
509
+
510
+ /**
511
+ * Print the report and return the exit status.
512
+ * @param {Check[]} checks - the collected checks.
513
+ * @param {{ json: boolean }} options - the output mode.
514
+ * @returns {number} 0 when nothing failed, 1 otherwise.
515
+ */
516
+ function report(checks, options) {
517
+ /** @param {string} status - the status to count. @returns {number} how many checks carry it. */
518
+ const count = (status) => checks.filter((entry) => entry.status === status).length;
519
+ const summary = { ok: count("ok"), warn: count("warn"), fail: count("fail"), info: count("info") };
520
+ if (options.json) {
521
+ console.log(JSON.stringify({ schemaVersion: 1, generatedAt: new Date().toISOString(), checks, summary }, null, 2));
522
+ } else {
523
+ console.log("dsh-plugin-wsl-env doctor");
524
+ let section = "";
525
+ for (const entry of checks) {
526
+ const current = entry.id.split(".")[0] ?? "";
527
+ if (current !== section) {
528
+ section = current;
529
+ console.log(`\n${section}`);
530
+ }
531
+ const mark = entry.status === "ok" ? "ok " : entry.status === "warn" ? "warn" : entry.status === "fail" ? "fail" : "info";
532
+ console.log(` ${mark} ${entry.title}${entry.detail === undefined ? "" : ` — ${entry.detail}`}`);
533
+ if (entry.remedy !== undefined) console.log(` fix: ${entry.remedy}`);
534
+ }
535
+ console.log(`\n${summary.ok} ok, ${summary.warn} warn, ${summary.fail} fail, ${summary.info} info`);
536
+ if (summary.fail > 0) console.log("run with --fix to sweep stale scopes; --fix --install asks before installing packages");
537
+ }
538
+ return summary.fail > 0 ? 1 : 0;
539
+ }
540
+
541
+ if (process.argv[1] !== undefined && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
542
+ const options = parseArgs(process.argv.slice(2));
543
+ if (options.help) {
544
+ console.log("node scripts/doctor.mjs [distro] [--json] [--fix] [--install] [--yes] [--read-only]");
545
+ } else {
546
+ process.exitCode = await main(options);
547
+ }
548
+ }