Open History to see the last commit
1import * as Context from "effect/Context";
2import * as Duration from "effect/Duration";
3import * as Effect from "effect/Effect";
4import * as FileSystem from "effect/FileSystem";
5import * as Layer from "effect/Layer";
6import * as Option from "effect/Option";
7import * as Path from "effect/Path";
8import type * as PlatformError from "effect/PlatformError";
9import * as Ref from "effect/Ref";
10import * as Schema from "effect/Schema";
11import * as Stream from "effect/Stream";
12import { ChildProcess, ChildProcessSpawner } from "effect/process";
13
14import * as HostProcess from "@t3tools/shared/HostProcess";
15import { buildRemoteNodeEnvScript } from "@t3tools/ssh/tunnel";
16import { satisfiesSemverRange } from "@t3tools/shared/semver";
17
18import * as DesktopEnvironment from "../app/DesktopEnvironment.ts";
19import * as DesktopManagedPolicy from "../app/DesktopManagedPolicy.ts";
20import { parseWslDistroList, type WslDistro } from "./wslPathParsing.ts";
21
22const PROCESS_TERMINATE_GRACE = Duration.seconds(1);
23const LIST_TIMEOUT = Duration.seconds(8);
24// Booting a stopped distro needs more time than converting paths in a running one.
25const PRE_WARM_TIMEOUT = Duration.seconds(30);
26const WSLPATH_TIMEOUT = Duration.seconds(10);
27const PROBE_TIMEOUT = Duration.seconds(10);
28const TOOLCHAIN_TIMEOUT = Duration.seconds(10);
29const BUILD_TIMEOUT = Duration.minutes(5);
30const RUNTIME_INSTALL_TIMEOUT = Duration.minutes(2);
31const RUNTIME_PRUNE_TIMEOUT = Duration.seconds(30);
32const RUNTIME_INVALIDATE_TIMEOUT = Duration.seconds(15);
33const USER_HOME_TIMEOUT = Duration.seconds(5);
34const TOOLCHAIN_TRANSPORT_RETRY_LIMIT = 12;
35const BUILD_TRANSPORT_RETRY_LIMIT = 2;
36
37export interface EnsureWslNodePtyOptions {
38 readonly allowBuild?: boolean;
39 readonly nodeEngineRange?: string | null;
40}
41
42export interface WslRuntimeArchive {
43 readonly windowsPath: string;
44 readonly runtimeId: string;
45 readonly sha256: string;
46}
47
48export type PrepareWslRuntimeResult =
49 | {
50 readonly ok: true;
51 readonly linuxAppRoot: string;
52 }
53 | {
54 readonly ok: false;
55 readonly reason: string;
56 };
57
58export type EnsureWslNodePtyResult =
59 | {
60 readonly ok: true;
61 readonly nodePath: string;
62 readonly resolvedPath: string;
63 }
64 | {
65 readonly ok: false;
66 readonly reason: string;
67 readonly fatal: boolean;
68 readonly retryLimit?: number;
69 };
70
71export type ProbeWslRuntimeResult =
72 | {
73 readonly ok: true;
74 readonly resolvedPath: string;
75 }
76 | {
77 readonly ok: false;
78 readonly reason: string;
79 };
80
81export class DesktopWslDistroListError extends Schema.TaggedError<DesktopWslDistroListError>()(
82 "DesktopWslDistroListError",
83 { reason: Schema.String },
84) {
85 override get message(): string {
86 return this.reason;
87 }
88}
89
90const isDesktopWslDistroListError = Schema.is(DesktopWslDistroListError);
91
92export class DesktopWslEnvironment extends Context.Service<
93 DesktopWslEnvironment,
94 {
95 readonly isAvailable: Effect.Effect<boolean>;
96 // UX-only best effort; health checks need probeDistros to distinguish failures from an empty installation.
97 readonly listDistros: Effect.Effect<readonly WslDistro[]>;
98 readonly probeDistros: Effect.Effect<readonly WslDistro[], DesktopWslDistroListError>;
99 readonly preWarm: (distro: string | null) => Effect.Effect<void>;
100 readonly windowsToWslPath: (
101 distro: string | null,
102 windowsPath: string,
103 ) => Effect.Effect<Option.Option<string>>;
104 readonly getUserHome: (distro: string | null) => Effect.Effect<Option.Option<string>>;
105 // Use the distro IP to avoid unreliable wslhost localhost forwarding.
106 readonly getDistroIp: (distro: string | null) => Effect.Effect<Option.Option<string>>;
107 readonly prepareRuntime: (
108 distro: string | null,
109 archive: WslRuntimeArchive,
110 ) => Effect.Effect<PrepareWslRuntimeResult>;
111 readonly pruneRuntimes: (distro: string | null, runtimeId: string) => Effect.Effect<void>;
112 readonly invalidateRuntime: (distro: string | null, runtimeId: string) => Effect.Effect<void>;
113 readonly probeRuntime: (distro: string | null) => Effect.Effect<ProbeWslRuntimeResult>;
114 readonly ensureNodePty: (
115 distro: string | null,
116 linuxAppRoot: string,
117 options?: EnsureWslNodePtyOptions,
118 ) => Effect.Effect<EnsureWslNodePtyResult>;
119 }
120>()("@t3tools/desktop/wsl/DesktopWslEnvironment") {}
121
122const buildDistroArgs = (distro: string | null): ReadonlyArray<string> =>
123 distro ? ["-d", distro] : [];
124
125const concatChunks = (arrays: ReadonlyArray<Uint8Array>): Uint8Array => {
126 let totalLength = 0;
127 for (const arr of arrays) totalLength += arr.byteLength;
128 const out = new Uint8Array(totalLength);
129 let offset = 0;
130 for (const arr of arrays) {
131 out.set(arr, offset);
132 offset += arr.byteLength;
133 }
134 return out;
135};
136
137const decodeUtf8 = (bytes: Uint8Array): string => new TextDecoder("utf-8").decode(bytes);
138
139interface ShellResult {
140 readonly exitCode: number;
141 readonly stdout: string;
142 readonly stderr: string;
143 readonly transportFailure: "timeout" | "spawn" | "process" | null;
144}
145
146const TIMEOUT_RESULT: ShellResult = {
147 exitCode: 124,
148 stdout: "",
149 stderr: "\n[timeout]",
150 transportFailure: "timeout",
151};
152
153const formatWslShellTransportFailureReason = (
154 failure: ShellResult["transportFailure"],
155 subject = "Node.js",
156): string | null => {
157 switch (failure) {
158 case "timeout":
159 return `WSL backend preflight timed out while probing for ${subject}. WSL may be slow to start; retry, or check that the distro is healthy.`;
160 case "spawn":
161 return `WSL backend preflight could not start wsl.exe to probe for ${subject}. Check that WSL is installed and the distro is accessible.`;
162 case "process":
163 return `WSL backend preflight lost communication with wsl.exe while probing for ${subject}. Retry, or check that the distro is healthy.`;
164 case null:
165 return null;
166 }
167};
168
169const buildWslNodeEnvPreamble = (
170 nodeEngineRange?: string | null,
171): string => `${buildRemoteNodeEnvScript({ nodeEngineRange: nodeEngineRange ?? null })}
172ensure_remote_node_path || true
173`;
174
175const runWslProcess = (
176 args: ReadonlyArray<string>,
177 stdin: Stream.Stream<Uint8Array, PlatformError.PlatformError>,
178 timeout: Duration.Duration,
179): Effect.Effect<ShellResult, never, ChildProcessSpawner.ChildProcessSpawner> => {
180 const spawner = ChildProcessSpawner.ChildProcessSpawner;
181 const command = ChildProcess.make("wsl.exe", args, {
182 stdin,
183 stdout: "pipe",
184 stderr: "pipe",
185 killSignal: "SIGTERM",
186 forceKillAfter: PROCESS_TERMINATE_GRACE,
187 });
188
189 return Effect.scoped(
190 Effect.gen(function* () {
191 const spawnerService = yield* spawner;
192 const spawnResult = yield* spawnerService.spawn(command).pipe(
193 Effect.match({
194 onFailure: (error) => ({ _tag: "Failure", error }) as const,
195 onSuccess: (handle) => ({ _tag: "Success", handle }) as const,
196 }),
197 );
198 if (spawnResult._tag === "Failure") {
199 return {
200 exitCode: 127,
201 stdout: "",
202 stderr: `\n${spawnResult.error.message}`,
203 transportFailure: "spawn",
204 } satisfies ShellResult;
205 }
206 const handle = spawnResult.handle;
207 // Drain both pipes concurrently to prevent node-gyp output from blocking the child.
208 const [stdoutBytes, stderrBytes, exitCode] = yield* Effect.all(
209 [Stream.runCollect(handle.stdout), Stream.runCollect(handle.stderr), handle.exitCode],
210 { concurrency: "unbounded" },
211 );
212 return {
213 exitCode: exitCode as unknown as number,
214 stdout: decodeUtf8(concatChunks(stdoutBytes)),
215 stderr: decodeUtf8(concatChunks(stderrBytes)),
216 transportFailure: null,
217 } satisfies ShellResult;
218 }),
219 ).pipe(
220 Effect.timeoutOption(timeout),
221 Effect.map(Option.getOrElse((): ShellResult => TIMEOUT_RESULT)),
222 Effect.catch((error) =>
223 Effect.succeed<ShellResult>({
224 exitCode: 127,
225 stdout: "",
226 stderr: `\n${error.message}`,
227 transportFailure: "process",
228 }),
229 ),
230 );
231};
232
233// wsl.exe re-escaping mangles quotes inside bash -lc scripts.
234const runWslShell = (
235 distro: string | null,
236 bashScript: string,
237 timeout: Duration.Duration,
238 options: {
239 readonly nodeEngineRange?: string | null;
240 readonly resolveNode?: boolean;
241 } = {},
242): Effect.Effect<ShellResult, never, ChildProcessSpawner.ChildProcessSpawner> => {
243 const resolveNode = options.resolveNode !== false;
244 return runWslProcess(
245 resolveNode
246 ? [...buildDistroArgs(distro), "--", "bash", "-l", "-s"]
247 : [...buildDistroArgs(distro), "--exec", "sh", "-s"],
248 Stream.encodeText(
249 Stream.make(
250 resolveNode
251 ? `${buildWslNodeEnvPreamble(options.nodeEngineRange)}${bashScript}`
252 : bashScript,
253 ),
254 ),
255 timeout,
256 );
257};
258
259const shellQuote = (value: string): string => `'${value.replaceAll("'", "'\\''")}'`;
260
261const WSL_RUNTIME_READY_MARKER = ".t3code-wsl-runtime-ready";
262const WSL_RUNTIME_SELECTED_MARKER = ".t3code-wsl-runtime-selected";
263const WSL_RUNTIME_SELECTION_GRACE_MINUTES = 5;
264
265const sanitizeWslRuntimeId = (value: string): string => value.replace(/[^A-Za-z0-9._-]/g, "_");
266
267// A null archive path asks for a staging directory to copy the archive into, unless the cache is ready.
268export const buildWslRuntimeInstallScript = (
269 linuxArchivePath: string | null,
270 runtimeId: string,
271 archiveSha256: string,
272): string => {
273 const safeRuntimeId = sanitizeWslRuntimeId(runtimeId);
274 const readyCheck = [
275 "set -eu",
276 'runtime_parent="$HOME/.t3/wsl-runtime"',
277 `runtime_root="$runtime_parent/${safeRuntimeId}"`,
278 `ready_marker="$runtime_root/${WSL_RUNTIME_READY_MARKER}"`,
279 "runtime_entry_runs() {",
280 ' [ -x "$1/t3" ] && "$1/t3" --version >/dev/null',
281 "}",
282 // Hash the entry to detect a swapped or partially written executable, even if it still runs.
283 "runtime_server_entry_digest() {",
284 ` sha256sum "$1/t3" 2>/dev/null | cut -d ' ' -f 1`,
285 "}",
286 "runtime_is_ready() {",
287 ' [ -f "$ready_marker" ] &&',
288 ' runtime_entry_runs "$runtime_root" 2>/dev/null &&',
289 ` recorded_entry_digest=$(tr -d '[:space:]' < "$ready_marker" 2>/dev/null) &&`,
290 ' [ -n "$recorded_entry_digest" ] &&',
291 ' [ "$recorded_entry_digest" = "$(runtime_server_entry_digest "$runtime_root")" ]',
292 "}",
293 'mkdir -p "$runtime_parent"',
294 `runtime_lock="$runtime_parent/.${safeRuntimeId}.install.lock"`,
295 "trap 'exit 1' HUP INT TERM",
296 'exec 9> "$runtime_lock"',
297 "flock -x 9",
298 "if runtime_is_ready; then",
299 ` touch "$runtime_root/${WSL_RUNTIME_SELECTED_MARKER}"`,
300 ` printf 'runtimeRoot:%s\\n' "$runtime_root"`,
301 " exit 0",
302 "fi",
303 ];
304 if (linuxArchivePath === null) {
305 return [
306 ...readyCheck,
307 // The .tmp. name lets pruning collect a stage orphaned by a crash.
308 `archive_stage=$(mktemp -d "$runtime_parent/.${safeRuntimeId}.tmp.XXXXXX")`,
309 `printf 'archiveStage:%s\\n' "$archive_stage"`,
310 ].join("\n");
311 }
312 return [
313 ...readyCheck,
314 `archive_sha=$(sha256sum ${shellQuote(linuxArchivePath)} | cut -d ' ' -f 1)`,
315 `if [ "$archive_sha" != ${shellQuote(archiveSha256)} ]; then`,
316 ` printf 'WSL runtime archive does not match its recorded SHA-256 (expected %s, got %s)\\n' ${shellQuote(archiveSha256)} "$archive_sha" >&2`,
317 " exit 1",
318 "fi",
319 // Deletion can remove a live backend's lazy imports; check cmdline before rename preserves its old path.
320 "runtime_in_use() {",
321 // Without /proc, assume the runtime may still be in use.
322 " [ -d /proc/1 ] || return 0",
323 ' grep -qF -- "$1/" /proc/[0-9]*/cmdline 2>/dev/null',
324 "}",
325 'if [ -e "$runtime_root" ]; then',
326 ' if runtime_in_use "$runtime_root"; then',
327 " runtime_root_in_use=1",
328 " else",
329 " runtime_root_in_use=0",
330 " fi",
331 ` runtime_stale=$(mktemp -d "$runtime_parent/.${safeRuntimeId}.stale.XXXXXX")`,
332 ' rmdir "$runtime_stale"',
333 ' if mv -T "$runtime_root" "$runtime_stale" 2>/dev/null; then',
334 ' if [ "$runtime_root_in_use" = 1 ]; then',
335 // Renaming keeps the directory's old mtime, so restart the cleanup clock.
336 ' touch "$runtime_stale"',
337 " else",
338 ' rm -rf "$runtime_stale"',
339 " fi",
340 " fi",
341 "fi",
342 `runtime_tmp=$(mktemp -d "$runtime_parent/.${safeRuntimeId}.tmp.XXXXXX")`,
343 'cleanup_runtime_install() { rm -rf "$runtime_tmp"; }',
344 "trap cleanup_runtime_install EXIT",
345 `tar -xzf ${shellQuote(linuxArchivePath)} -C "$runtime_tmp" --strip-components=1`,
346 'if ! runtime_entry_runs "$runtime_tmp"; then',
347 " printf 'WSL runtime archive does not contain a working t3 executable\\n' >&2",
348 " exit 1",
349 "fi",
350 'installed_entry_digest=$(runtime_server_entry_digest "$runtime_tmp")',
351 'if [ -z "$installed_entry_digest" ]; then',
352 " printf 'Could not hash the WSL runtime server entry\\n' >&2",
353 " exit 1",
354 "fi",
355 `printf '%s\\n' "$installed_entry_digest" > "$runtime_tmp/${WSL_RUNTIME_READY_MARKER}"`,
356 'if mv -T "$runtime_tmp" "$runtime_root" 2>/dev/null; then',
357 " :",
358 "elif runtime_is_ready; then",
359 ' rm -rf "$runtime_tmp"',
360 "else",
361 ` printf 'Could not promote WSL runtime cache at %s\\n' "$runtime_root" >&2`,
362 " exit 1",
363 "fi",
364 `touch "$runtime_root/${WSL_RUNTIME_SELECTED_MARKER}"`,
365 `printf 'runtimeRoot:%s\\n' "$runtime_root"`,
366 ].join("\n");
367};
368
369// Two hours exceeds cold-install time while bounding orphan retention.
370const ORPHANED_RUNTIME_SCRATCH_MAX_AGE_MINUTES = 120;
371
372export const buildWslRuntimePruneScript = (runtimeId: string): string => {
373 const safeRuntimeId = sanitizeWslRuntimeId(runtimeId);
374 return [
375 "set -eu",
376 'runtime_parent="$HOME/.t3/wsl-runtime"',
377 `current_runtime="$runtime_parent/${safeRuntimeId}"`,
378 '[ -d "$runtime_parent" ] || exit 0',
379 // Serialize pruning so backends cannot select and delete different previous caches.
380 'prune_lock="$runtime_parent/.prune.lock"',
381 'exec 8> "$prune_lock"',
382 "flock -x 8",
383 // Without /proc, retention cannot distinguish caches used by live backends.
384 "[ -d /proc/1 ] || exit 0",
385 "runtime_in_use() {",
386 ' grep -qF -- "$1/" /proc/[0-9]*/cmdline 2>/dev/null',
387 "}",
388 'previous_runtime=""',
389 'for candidate in "$runtime_parent"/sha256-*; do',
390 ' [ -d "$candidate" ] || continue',
391 ' [ "$candidate" != "$current_runtime" ] || continue',
392 ` [ -f "$candidate/${WSL_RUNTIME_READY_MARKER}" ] || continue`,
393 ' if [ -z "$previous_runtime" ] || [ "$candidate" -nt "$previous_runtime" ]; then',
394 ' previous_runtime="$candidate"',
395 " fi",
396 "done",
397 'for candidate in "$runtime_parent"/sha256-*; do',
398 ' [ -d "$candidate" ] || continue',
399 ' [ "$candidate" != "$current_runtime" ] || continue',
400 ' [ "$candidate" != "$previous_runtime" ] || continue',
401 ' ! runtime_in_use "$candidate" || continue',
402 " candidate_name=${candidate##*/}",
403 ' candidate_lock="$runtime_parent/.${candidate_name}.install.lock"',
404 ' exec 9> "$candidate_lock"',
405 " flock -n 9 || continue",
406 ` selected_marker="$candidate/${WSL_RUNTIME_SELECTED_MARKER}"`,
407 ` if [ -f "$selected_marker" ] && find "$selected_marker" -maxdepth 0 -mmin -${String(WSL_RUNTIME_SELECTION_GRACE_MINUTES)} -print -quit | grep -q .; then`,
408 " flock -u 9",
409 " continue",
410 " fi",
411 ' rm -rf -- "$candidate"',
412 " flock -u 9",
413 "done",
414 'for scratch in "$runtime_parent"/.*.tmp.* "$runtime_parent"/.*.stale.*; do',
415 ' [ -d "$scratch" ] || continue',
416 ` find "$scratch" -maxdepth 0 -mmin +${String(ORPHANED_RUNTIME_SCRATCH_MAX_AGE_MINUTES)} -print -quit | grep -q . || continue`,
417 ' rm -rf -- "$scratch"',
418 "done",
419 ].join("\n");
420};
421
422// Invalidate trees that pass install checks but fail launch probes, or each launch reuses the broken cache.
423export const buildWslRuntimeInvalidateScript = (runtimeId: string): string => {
424 const safeRuntimeId = sanitizeWslRuntimeId(runtimeId);
425 return [
426 "set -eu",
427 `rm -f "$HOME/.t3/wsl-runtime/${safeRuntimeId}/${WSL_RUNTIME_READY_MARKER}"`,
428 ].join("\n");
429};
430
431const parseAbsolutePathField = (stdout: string, field: string): string | null => {
432 const prefix = `${field}:`;
433 const line = stdout.split("\n").find((candidate) => candidate.startsWith(prefix));
434 if (line === undefined) return null;
435 const value = line.slice(prefix.length).replace(/\r$/, "");
436 return value.startsWith("/") ? value : null;
437};
438
439export const parseWslRuntimeRoot = (stdout: string): string | null =>
440 parseAbsolutePathField(stdout, "runtimeRoot");
441
442const NODE_PTY_BINARY_MISSING_EXIT_CODE = 4;
443
444const formatNodePtyProbeFailureReason = (exitCode: number): string | null =>
445 exitCode === NODE_PTY_BINARY_MISSING_EXIT_CODE
446 ? "WSL support is missing from this T3 Code build: the packaged Linux node-pty binary was not included. Install a build that includes WSL support."
447 : null;
448
449// Capture login-shell PATH because the server launches provider CLIs by name.
450const RESOLVED_PATH_LINE = `printf 'resolvedPath:%s\\n' "$PATH"`;
451
452const NODE_PTY_PROBE_SCRIPT = (
453 linuxServerDir: string,
454) => `printf 'nodePath:%s\\n' "$(command -v node 2>/dev/null)"
455printf 'nodeVersion:%s\\n' "$(node -p 'process.versions.node' 2>/dev/null)"
456${RESOLVED_PATH_LINE}
457cd ${shellQuote(linuxServerDir)} && node <<'NODE' >/dev/null 2>&1
458// The WSL Node can't read inside app.asar, so confirm what the server needs is
459// unpacked on the real filesystem before reporting the backend healthy. Exit 3
460// marks this distinct from a node-pty binary problem so the caller can report
461// it accurately instead of letting the server crash on ERR_MODULE_NOT_FOUND at
462// launch (which, in wsl-only mode, would just fail to launch with no fallback).
463//
464// The sentinel must be a package the CLI bundle leaves external. The bundle
465// inlines its JS dependencies, so only the native packages exist on disk.
466// Resolving node-pty is what actually validates the unpacked tree.
467try { require.resolve("node-pty/package.json"); } catch (_e) { process.exit(3); }
468const fs = require("node:fs");
469const path = require("node:path");
470const pkgDir = path.dirname(require.resolve("node-pty/package.json"));
471// node-pty 1.x is N-API based, so a single Linux pty.node is ABI-stable across
472// Node versions, so require() succeeding IS the real compatibility test. Look in
473// the same places node-pty's own loader does.
474const candidates = [
475 path.join(pkgDir, "build", "Release", "pty.node"),
476 path.join(pkgDir, "prebuilds", "linux-" + process.arch, "pty.node"),
477];
478if (!candidates.some((candidate) => fs.existsSync(candidate))) process.exit(${NODE_PTY_BINARY_MISSING_EXIT_CODE});
479require("node-pty");
480NODE`;
481
482// Resolve version-managed Node for providers without making runtime readiness depend on it.
483// The install script already ran the staged t3, so running it again here only adds a second startup.
484export const buildWslRuntimeProbeScript = () =>
485 `bash -lc ${shellQuote(`${buildWslNodeEnvPreamble()}${RESOLVED_PATH_LINE}`)} 2>/dev/null || ${RESOLVED_PATH_LINE}`;
486
487const TOOLCHAIN_CHECK_SCRIPT = [
488 "for tool in node make g++ python3; do",
489 ' command -v "$tool" >/dev/null 2>&1 || echo "missing:$tool"',
490 "done",
491 "if command -v node >/dev/null 2>&1; then",
492 ` ver="$(node -p 'process.versions.node' 2>/dev/null)"`,
493 ' if [ -n "$ver" ]; then printf "nodeVersion:%s\\n" "$ver"; fi',
494 "fi",
495].join("\n");
496
497const NODE_PTY_BUILD_SCRIPT = (linuxServerDir: string) =>
498 [
499 "set -e",
500 `cd ${shellQuote(linuxServerDir)}`,
501 `pkg_dir=$(node -p "require('node:path').dirname(require.resolve('node-pty/package.json'))")`,
502 `cd "$pkg_dir"`,
503 "npx --yes node-gyp rebuild",
504 `node -e 'require("node-pty")'`,
505 ].join("\n");
506
507export interface ToolchainReport {
508 readonly missingTools: ReadonlyArray<string>;
509 readonly nodeVersion: string | null;
510}
511
512const parseToolchainReport = (stdout: string): ToolchainReport => {
513 const lines = stdout
514 .split("\n")
515 .map((line) => line.trim())
516 .filter((line) => line.length > 0);
517 const missingTools = lines
518 .filter((line) => line.startsWith("missing:"))
519 .map((line) => line.slice("missing:".length));
520 const nodeVersionLine = lines.find((line) => line.startsWith("nodeVersion:"));
521 const nodeVersion = nodeVersionLine
522 ? nodeVersionLine.slice("nodeVersion:".length).trim() || null
523 : null;
524 return { missingTools, nodeVersion };
525};
526
527const parseNodePath = (stdout: string): string | null => {
528 const path = stdout
529 .split("\n")
530 .map((line) => line.trim())
531 .filter((line) => line.startsWith("nodePath:"))
532 .map((line) => line.slice("nodePath:".length).trim())
533 .find((value) => value.length > 0);
534 return path ?? null;
535};
536
537const parseNodeVersion = (stdout: string): string | null => {
538 const version = stdout
539 .split("\n")
540 .map((line) => line.trim())
541 .filter((line) => line.startsWith("nodeVersion:"))
542 .map((line) => line.slice("nodeVersion:".length).trim())
543 .find((value) => value.length > 0);
544 return version ?? null;
545};
546
547// Strip only Windows CR; spaces and apostrophes must survive as one env argument.
548export const parseResolvedPath = (stdout: string): string | null => {
549 const prefix = "resolvedPath:";
550 const line = stdout.split("\n").find((candidate) => candidate.startsWith(prefix));
551 if (line === undefined) return null;
552 const resolvedPath = line.slice(prefix.length).replace(/\r$/, "");
553 return resolvedPath.length > 0 ? resolvedPath : null;
554};
555
556const formatMissingToolsReason = (
557 report: ToolchainReport,
558 requiredRange: string | null,
559): string | null => {
560 const nodeMissing = report.missingTools.includes("node");
561 const nodeOutOfRange =
562 !nodeMissing &&
563 requiredRange !== null &&
564 report.nodeVersion !== null &&
565 !satisfiesSemverRange(report.nodeVersion, requiredRange);
566 const buildToolsMissing = report.missingTools.filter((tool) => tool !== "node");
567
568 if (!nodeMissing && !nodeOutOfRange && buildToolsMissing.length === 0) {
569 return null;
570 }
571
572 const issues: string[] = [];
573 const remediations: string[] = [];
574
575 if (nodeMissing) {
576 issues.push("node");
577 remediations.push(
578 `Node.js${requiredRange ? ` satisfying \`${requiredRange}\`` : " 18+"} (e.g. via nvm)`,
579 );
580 } else if (nodeOutOfRange) {
581 issues.push(`node ${report.nodeVersion} (requires ${requiredRange})`);
582 remediations.push(
583 `a newer Node.js satisfying \`${requiredRange}\` (e.g. \`nvm install 24 && nvm alias default 24\`)`,
584 );
585 }
586
587 if (buildToolsMissing.length > 0) {
588 issues.push(...buildToolsMissing);
589 remediations.push(
590 "the build toolchain (e.g. `sudo apt install -y build-essential python3` on Ubuntu/Debian)",
591 );
592 }
593
594 return `WSL distro is missing required tools: ${issues.join(", ")}. Install ${remediations.join(" and ")}, then retry.`;
595};
596
597const probeWslRuntimeImpl = Effect.fn("DesktopWslEnvironment.probeWslRuntimeImpl")(function* (
598 distro: string | null,
599): Effect.fn.Return<ProbeWslRuntimeResult, never, ChildProcessSpawner.ChildProcessSpawner> {
600 const probe = yield* runWslShell(distro, buildWslRuntimeProbeScript(), PROBE_TIMEOUT, {
601 resolveNode: false,
602 });
603 const transportFailureReason = formatWslShellTransportFailureReason(
604 probe.transportFailure,
605 "the login-shell PATH",
606 );
607 if (transportFailureReason !== null) {
608 return { ok: false, reason: transportFailureReason } as const;
609 }
610 const resolvedPath = parseResolvedPath(probe.stdout);
611 if (resolvedPath === null) {
612 return {
613 ok: false,
614 reason: "WSL login-shell PATH could not be resolved during backend preflight.",
615 } as const;
616 }
617 return { ok: true, resolvedPath } as const;
618});
619
620const ensureNodePtyImpl = Effect.fn("DesktopWslEnvironment.ensureNodePtyImpl")(function* (
621 distro: string | null,
622 linuxRepoRoot: string,
623 options: EnsureWslNodePtyOptions = {},
624): Effect.fn.Return<EnsureWslNodePtyResult, never, ChildProcessSpawner.ChildProcessSpawner> {
625 // node-pty lives in the apps/server workspace's node_modules; resolve from
626 // there rather than the monorepo root, where Bun's hoist layout omits it.
627 const linuxServerDir = `${linuxRepoRoot}/apps/server`;
628
629 const probe = yield* runWslShell(
630 distro,
631 NODE_PTY_PROBE_SCRIPT(linuxServerDir),
632 PROBE_TIMEOUT,
633 options,
634 );
635 const nodePath = parseNodePath(probe.stdout);
636 const resolvedPath = parseResolvedPath(probe.stdout);
637
638 const transportFailureReason = formatWslShellTransportFailureReason(probe.transportFailure);
639 if (transportFailureReason !== null) {
640 return {
641 ok: false,
642 reason: transportFailureReason,
643 fatal: false,
644 } as const;
645 }
646
647 if (nodePath === null) {
648 const toolchainCheck = yield* runWslShell(
649 distro,
650 TOOLCHAIN_CHECK_SCRIPT,
651 TOOLCHAIN_TIMEOUT,
652 options,
653 );
654 const toolchainTransportFailure = formatWslShellTransportFailureReason(
655 toolchainCheck.transportFailure,
656 );
657 if (toolchainTransportFailure !== null) {
658 return {
659 ok: false,
660 reason: toolchainTransportFailure,
661 fatal: false,
662 retryLimit: TOOLCHAIN_TRANSPORT_RETRY_LIMIT,
663 } as const;
664 }
665 const report = parseToolchainReport(toolchainCheck.stdout);
666 const reason =
667 formatMissingToolsReason(report, options.nodeEngineRange?.trim() || null) ??
668 "Node.js was not found in the WSL distro. Install it (e.g. via nvm) and restart the desktop app.";
669 return { ok: false, reason, fatal: true } as const;
670 }
671
672 if (resolvedPath === null) {
673 return {
674 ok: false,
675 reason: "WSL login-shell PATH could not be resolved during backend preflight.",
676 fatal: true,
677 } as const;
678 }
679
680 if (probe.exitCode === 3) {
681 return {
682 ok: false,
683 reason:
684 'WSL server dependencies could not be loaded (for example "node-pty"). The native packages the server needs are not unpacked where the WSL distro\'s Node can read them. This is a packaging problem with this build. Please report it.',
685 fatal: true,
686 } as const;
687 }
688
689 if (probe.exitCode === 0) {
690 const rawVersion = parseNodeVersion(probe.stdout);
691 if (
692 rawVersion !== null &&
693 options.nodeEngineRange &&
694 !satisfiesSemverRange(rawVersion, options.nodeEngineRange.trim())
695 ) {
696 const range = options.nodeEngineRange.trim();
697 return {
698 ok: false,
699 reason: `WSL Node.js ${rawVersion} does not satisfy the server's required engine range (${range}). Install a compatible version, and restart the desktop app.`,
700 fatal: true,
701 } as const;
702 }
703 return { ok: true, nodePath, resolvedPath } as const;
704 }
705
706 if (options.allowBuild !== true) {
707 const packagedProbeFailure = formatNodePtyProbeFailureReason(probe.exitCode);
708 if (packagedProbeFailure !== null) {
709 return {
710 ok: false,
711 reason: packagedProbeFailure,
712 fatal: true,
713 } as const;
714 }
715 }
716
717 const toolchainCheck = yield* runWslShell(
718 distro,
719 TOOLCHAIN_CHECK_SCRIPT,
720 TOOLCHAIN_TIMEOUT,
721 options,
722 );
723 const toolchainTransportFailure = formatWslShellTransportFailureReason(
724 toolchainCheck.transportFailure,
725 );
726 if (toolchainTransportFailure !== null) {
727 return {
728 ok: false,
729 reason: toolchainTransportFailure,
730 fatal: false,
731 retryLimit: TOOLCHAIN_TRANSPORT_RETRY_LIMIT,
732 } as const;
733 }
734 const report = parseToolchainReport(toolchainCheck.stdout);
735
736 if (options.allowBuild !== true) {
737 // Packaged builds ship a prebuilt Linux node-pty, so no compiler, node-gyp,
738 // or network is needed — and we must not nag the user to install build
739 // tools they don't need.
740 const nodeOnlyReason = formatMissingToolsReason(
741 {
742 missingTools: report.missingTools.filter((tool) => tool === "node"),
743 nodeVersion: report.nodeVersion,
744 },
745 options.nodeEngineRange?.trim() || null,
746 );
747 return {
748 ok: false,
749 reason:
750 nodeOnlyReason ??
751 "The bundled WSL backend binary (node-pty) could not be loaded in this distro. This usually means an unsupported CPU architecture or incompatible system libraries (glibc). Use a glibc-based x64/arm64 WSL distro such as Ubuntu; if you already are, please report this with your distro and the output of `uname -m`.",
752 fatal: true,
753 } as const;
754 }
755
756 const missingReason = formatMissingToolsReason(report, options.nodeEngineRange?.trim() || null);
757 if (missingReason !== null) {
758 return { ok: false, reason: missingReason, fatal: true } as const;
759 }
760
761 const build = yield* runWslShell(
762 distro,
763 NODE_PTY_BUILD_SCRIPT(linuxServerDir),
764 BUILD_TIMEOUT,
765 options,
766 );
767 const buildTransportFailure = formatWslShellTransportFailureReason(build.transportFailure);
768 if (buildTransportFailure !== null) {
769 return {
770 ok: false,
771 reason: buildTransportFailure,
772 fatal: false,
773 retryLimit: BUILD_TRANSPORT_RETRY_LIMIT,
774 } as const;
775 }
776 if (build.exitCode === 0) return { ok: true, nodePath, resolvedPath } as const;
777 const trimmedTail = `${build.stdout}${build.stderr}`.trim().slice(-500);
778 return {
779 ok: false,
780 reason: `node-pty Linux build failed (exit ${build.exitCode}): ${trimmedTail || "no stderr captured"}`,
781 fatal: true,
782 } as const;
783});
784
785type WslRuntimeInstallOutcome =
786 | { readonly ok: true; readonly stdout: string }
787 | { readonly ok: false; readonly reason: string };
788
789const runWslRuntimeInstall = Effect.fn("desktop.wsl.runRuntimeInstall")(function* (
790 distro: string | null,
791 archive: WslRuntimeArchive,
792 linuxArchivePath: string | null,
793): Effect.fn.Return<WslRuntimeInstallOutcome, never, ChildProcessSpawner.ChildProcessSpawner> {
794 const install = yield* runWslShell(
795 distro,
796 buildWslRuntimeInstallScript(linuxArchivePath, archive.runtimeId, archive.sha256),
797 RUNTIME_INSTALL_TIMEOUT,
798 { resolveNode: false },
799 );
800 if (install.transportFailure !== null) {
801 return {
802 ok: false,
803 reason:
804 install.transportFailure === "timeout"
805 ? "WSL runtime installation timed out. Check that the distro has free disk space, then retry."
806 : "WSL runtime installation lost communication with wsl.exe. Retry, or check that the distro is healthy.",
807 } as const;
808 }
809 if (install.exitCode !== 0) {
810 const trimmedTail = `${install.stdout}${install.stderr}`.trim().slice(-500);
811 return {
812 ok: false,
813 reason: `WSL runtime installation failed (exit ${install.exitCode}): ${trimmedTail || "no stderr captured"}`,
814 } as const;
815 }
816 return { ok: true, stdout: install.stdout } as const;
817});
818
819const toPrepareWslRuntimeResult = (outcome: WslRuntimeInstallOutcome): PrepareWslRuntimeResult => {
820 if (!outcome.ok) return outcome;
821 const linuxAppRoot = parseWslRuntimeRoot(outcome.stdout);
822 return linuxAppRoot === null
823 ? {
824 ok: false,
825 reason: "WSL runtime installation completed without reporting its cache path.",
826 }
827 : { ok: true, linuxAppRoot };
828};
829
830// Without drive mounts (`[automount] enabled=false`) wslpath cannot reach the archive, so copy it in over stdin.
831const installWslRuntimeFromHostCopy = Effect.fn("desktop.wsl.installRuntimeFromHostCopy")(
832 function* (
833 distro: string | null,
834 archive: WslRuntimeArchive,
835 archiveBytes: Stream.Stream<Uint8Array, PlatformError.PlatformError>,
836 ): Effect.fn.Return<PrepareWslRuntimeResult, never, ChildProcessSpawner.ChildProcessSpawner> {
837 const staged = yield* runWslRuntimeInstall(distro, archive, null);
838 if (!staged.ok || parseWslRuntimeRoot(staged.stdout) !== null) {
839 return toPrepareWslRuntimeResult(staged);
840 }
841 const stage = parseAbsolutePathField(staged.stdout, "archiveStage");
842 if (stage === null) {
843 return {
844 ok: false,
845 reason: `wslpath conversion failed for ${archive.windowsPath}, and the distro did not provide a directory to copy the runtime archive into.`,
846 } as const;
847 }
848 const linuxArchivePath = `${stage}/runtime.tar.gz`;
849
850 const copyAndInstall = Effect.gen(function* () {
851 const readError = yield* Ref.make(Option.none<PlatformError.PlatformError>());
852 // dd reads fd 0 itself; reopening /dev/stdin fails when WSL relays stdin as a socket.
853 const copy = yield* runWslProcess(
854 [...buildDistroArgs(distro), "--exec", "dd", `of=${linuxArchivePath}`, "bs=65536"],
855 archiveBytes.pipe(
856 // Ending stdin on a read error lets dd exit instead of waiting out the timeout.
857 Stream.catch((error) =>
858 Stream.drain(Stream.fromEffect(Ref.set(readError, Option.some(error)))),
859 ),
860 ),
861 RUNTIME_INSTALL_TIMEOUT,
862 );
863 const failedRead = yield* Ref.get(readError);
864 if (Option.isSome(failedRead)) {
865 return {
866 ok: false,
867 reason: `Could not read the WSL runtime archive at ${archive.windowsPath}: ${failedRead.value.message}`,
868 } as const;
869 }
870 if (copy.transportFailure !== null || copy.exitCode !== 0) {
871 const trimmedTail = copy.stderr.trim().slice(-500);
872 return {
873 ok: false,
874 reason: `Could not copy the WSL runtime archive into the distro (exit ${copy.exitCode})${trimmedTail ? `: ${trimmedTail}` : ""}`,
875 } as const;
876 }
877 return toPrepareWslRuntimeResult(
878 yield* runWslRuntimeInstall(distro, archive, linuxArchivePath),
879 );
880 });
881
882 return yield* copyAndInstall.pipe(
883 Effect.ensuring(
884 runWslShell(distro, `rm -rf ${shellQuote(stage)}`, RUNTIME_INVALIDATE_TIMEOUT, {
885 resolveNode: false,
886 }),
887 ),
888 );
889 },
890);
891
892const prepareWslRuntimeImpl = Effect.fn("desktop.wsl.prepareRuntimeImpl")(function* (
893 distro: string | null,
894 archive: WslRuntimeArchive,
895 windowsToWslPath: (
896 distro: string | null,
897 windowsPath: string,
898 ) => Effect.Effect<Option.Option<string>>,
899): Effect.fn.Return<
900 PrepareWslRuntimeResult,
901 never,
902 ChildProcessSpawner.ChildProcessSpawner | FileSystem.FileSystem
903> {
904 const linuxArchivePath = yield* windowsToWslPath(distro, archive.windowsPath);
905 if (Option.isNone(linuxArchivePath)) {
906 const fileSystem = yield* FileSystem.FileSystem;
907 return yield* installWslRuntimeFromHostCopy(
908 distro,
909 archive,
910 fileSystem.stream(archive.windowsPath),
911 );
912 }
913 return toPrepareWslRuntimeResult(
914 yield* runWslRuntimeInstall(distro, archive, linuxArchivePath.value),
915 );
916});
917
918const pruneWslRuntimesImpl = Effect.fn("desktop.wsl.pruneRuntimesImpl")(function* (
919 distro: string | null,
920 runtimeId: string,
921): Effect.fn.Return<void, never, ChildProcessSpawner.ChildProcessSpawner> {
922 const result = yield* runWslShell(
923 distro,
924 buildWslRuntimePruneScript(runtimeId),
925 RUNTIME_PRUNE_TIMEOUT,
926 { resolveNode: false },
927 );
928 if (result.transportFailure === null && result.exitCode === 0) return;
929
930 const detail = `${result.stdout}${result.stderr}`.trim().slice(-500);
931 yield* Effect.logWarning("Could not prune old WSL runtime caches.", {
932 distro,
933 runtimeId,
934 detail: detail || `exit ${result.exitCode}`,
935 });
936});
937
938const invalidateWslRuntimeImpl = Effect.fn("desktop.wsl.invalidateRuntimeImpl")(function* (
939 distro: string | null,
940 runtimeId: string,
941): Effect.fn.Return<void, never, ChildProcessSpawner.ChildProcessSpawner> {
942 const result = yield* runWslShell(
943 distro,
944 buildWslRuntimeInvalidateScript(runtimeId),
945 RUNTIME_INVALIDATE_TIMEOUT,
946 { resolveNode: false },
947 );
948 if (result.transportFailure === null && result.exitCode === 0) return;
949
950 const detail = `${result.stdout}${result.stderr}`.trim().slice(-500);
951 yield* Effect.logWarning("Could not invalidate the staged WSL runtime cache.", {
952 distro,
953 runtimeId,
954 detail: detail || `exit ${result.exitCode}`,
955 });
956});
957
958const probeWslDistros: Effect.Effect<
959 readonly WslDistro[],
960 DesktopWslDistroListError,
961 ChildProcessSpawner.ChildProcessSpawner
962> = Effect.scoped(
963 Effect.gen(function* () {
964 const spawner = yield* ChildProcessSpawner.ChildProcessSpawner;
965 const command = ChildProcess.make("wsl.exe", ["--list", "--verbose"], {
966 stdin: "ignore",
967 stdout: "pipe",
968 stderr: "ignore",
969 killSignal: "SIGTERM",
970 forceKillAfter: PROCESS_TERMINATE_GRACE,
971 });
972 const handle = yield* spawner.spawn(command);
973 const stdoutBytes = yield* Stream.runCollect(handle.stdout);
974 const exitCode = yield* handle.exitCode;
975 if ((exitCode as unknown as number) !== 0) {
976 return yield* new DesktopWslDistroListError({
977 reason: `wsl.exe --list --verbose exited with code ${String(exitCode)}`,
978 });
979 }
980 return parseWslDistroList(Buffer.from(concatChunks(stdoutBytes)));
981 }),
982).pipe(
983 Effect.mapError((error) =>
984 isDesktopWslDistroListError(error)
985 ? error
986 : new DesktopWslDistroListError({
987 reason: `Failed to run wsl.exe --list --verbose: ${error.message}`,
988 }),
989 ),
990 Effect.timeoutOption(LIST_TIMEOUT),
991 Effect.flatMap(
992 Option.match({
993 onNone: () =>
994 new DesktopWslDistroListError({
995 reason: "wsl.exe --list --verbose timed out",
996 }),
997 onSome: Effect.succeed,
998 }),
999 ),
1000);
1001
1002const preWarmImpl = (
1003 distro: string | null,
1004): Effect.Effect<void, never, ChildProcessSpawner.ChildProcessSpawner> =>
1005 Effect.scoped(
1006 Effect.gen(function* () {
1007 const spawner = yield* ChildProcessSpawner.ChildProcessSpawner;
1008 const command = ChildProcess.make("wsl.exe", [...buildDistroArgs(distro), "--", "true"], {
1009 stdin: "ignore",
1010 stdout: "ignore",
1011 stderr: "ignore",
1012 killSignal: "SIGTERM",
1013 forceKillAfter: PROCESS_TERMINATE_GRACE,
1014 });
1015 const handle = yield* spawner.spawn(command);
1016 yield* handle.exitCode;
1017 }),
1018 ).pipe(Effect.timeoutOption(PRE_WARM_TIMEOUT), Effect.ignore);
1019
1020export const windowsToWslPathImpl = (
1021 distro: string | null,
1022 windowsPath: string,
1023): Effect.Effect<Option.Option<string>, never, ChildProcessSpawner.ChildProcessSpawner> => {
1024 // wsl.exe interprets backslashes as escape chars; normalize to forward slashes.
1025 const normalized = windowsPath.replaceAll("\\", "/");
1026 return Effect.scoped(
1027 Effect.gen(function* () {
1028 const spawner = yield* ChildProcessSpawner.ChildProcessSpawner;
1029 const command = ChildProcess.make(
1030 "wsl.exe",
1031 // --exec skips the distro shell, which would expand `$`, `&` or `;` in folder names.
1032 [...buildDistroArgs(distro), "--exec", "wslpath", "-u", normalized],
1033 {
1034 stdin: "ignore",
1035 stdout: "pipe",
1036 stderr: "ignore",
1037 killSignal: "SIGTERM",
1038 forceKillAfter: PROCESS_TERMINATE_GRACE,
1039 },
1040 );
1041 const handle = yield* spawner.spawn(command);
1042 const stdoutBytes = yield* Stream.runCollect(handle.stdout);
1043 const exitCode = yield* handle.exitCode;
1044 if ((exitCode as unknown as number) !== 0) return Option.none<string>();
1045 const converted = decodeUtf8(concatChunks(stdoutBytes)).trim();
1046 return converted.length > 0 ? Option.some(converted) : Option.none<string>();
1047 }),
1048 ).pipe(
1049 Effect.timeoutOption(WSLPATH_TIMEOUT),
1050 Effect.map(Option.flatten),
1051 Effect.orElseSucceed(() => Option.none<string>()),
1052 );
1053};
1054
1055const IPV4_PATTERN = /^\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}$/;
1056
1057const getDistroIpImpl = (
1058 distro: string | null,
1059): Effect.Effect<Option.Option<string>, never, ChildProcessSpawner.ChildProcessSpawner> =>
1060 Effect.scoped(
1061 Effect.gen(function* () {
1062 const spawner = yield* ChildProcessSpawner.ChildProcessSpawner;
1063 // WSL2's first hostname -I address is reachable directly from Windows without wslhost forwarding.
1064 const command = ChildProcess.make(
1065 "wsl.exe",
1066 [...buildDistroArgs(distro), "--", "sh", "-c", "hostname -I"],
1067 {
1068 stdin: "ignore",
1069 stdout: "pipe",
1070 stderr: "ignore",
1071 killSignal: "SIGTERM",
1072 forceKillAfter: PROCESS_TERMINATE_GRACE,
1073 },
1074 );
1075 const handle = yield* spawner.spawn(command);
1076 const stdoutBytes = yield* Stream.runCollect(handle.stdout);
1077 const exitCode = yield* handle.exitCode;
1078 if ((exitCode as unknown as number) !== 0) return Option.none<string>();
1079 const raw = decodeUtf8(concatChunks(stdoutBytes)).trim();
1080 const candidate = raw.split(/\s+/).find((part) => IPV4_PATTERN.test(part));
1081 return candidate ? Option.some(candidate) : Option.none<string>();
1082 }),
1083 ).pipe(
1084 Effect.timeoutOption(USER_HOME_TIMEOUT),
1085 Effect.map(Option.flatten),
1086 Effect.orElseSucceed(() => Option.none<string>()),
1087 );
1088
1089const getUserHomeImpl = (
1090 distro: string | null,
1091): Effect.Effect<Option.Option<string>, never, ChildProcessSpawner.ChildProcessSpawner> =>
1092 Effect.scoped(
1093 Effect.gen(function* () {
1094 const spawner = yield* ChildProcessSpawner.ChildProcessSpawner;
1095 const command = ChildProcess.make(
1096 "wsl.exe",
1097 [
1098 ...buildDistroArgs(distro),
1099 "--",
1100 "sh",
1101 "-c",
1102 'printf "%s" "$(getent passwd "$(id -un)" | cut -d: -f6)"',
1103 ],
1104 {
1105 stdin: "ignore",
1106 stdout: "pipe",
1107 stderr: "ignore",
1108 killSignal: "SIGTERM",
1109 forceKillAfter: PROCESS_TERMINATE_GRACE,
1110 },
1111 );
1112 const handle = yield* spawner.spawn(command);
1113 const stdoutBytes = yield* Stream.runCollect(handle.stdout);
1114 const exitCode = yield* handle.exitCode;
1115 if ((exitCode as unknown as number) !== 0) return Option.none<string>();
1116 const home = decodeUtf8(concatChunks(stdoutBytes)).trim();
1117 return home.startsWith("/") ? Option.some(home) : Option.none<string>();
1118 }),
1119 ).pipe(
1120 Effect.timeoutOption(USER_HOME_TIMEOUT),
1121 Effect.map(Option.flatten),
1122 Effect.orElseSucceed(() => Option.none<string>()),
1123 );
1124
1125export const makeIsAvailable = Effect.fn("DesktopWslEnvironment.makeIsAvailable")(function* (
1126 platform: NodeJS.Platform,
1127 windir: string,
1128): Effect.fn.Return<
1129 boolean,
1130 never,
1131 FileSystem.FileSystem | Path.Path | DesktopManagedPolicy.DesktopManagedPolicy
1132> {
1133 if (platform !== "win32") return false;
1134 const path = yield* Path.Path;
1135 const fileSystem = yield* FileSystem.FileSystem;
1136 const wslExePath = path.join(windir, "System32", "wsl.exe");
1137 if (!(yield* fileSystem.exists(wslExePath).pipe(Effect.orElseSucceed(() => false)))) {
1138 return false;
1139 }
1140 // A server in WSL reads Linux's policy, never this machine's, so a managed machine runs none.
1141 return yield* (yield* DesktopManagedPolicy.DesktopManagedPolicy).read.pipe(
1142 Effect.map((found) => found === undefined),
1143 Effect.orElseSucceed(() => false),
1144 );
1145});
1146
1147export interface DesktopWslEnvironmentTestStub {
1148 readonly isAvailable?: boolean;
1149 readonly distros?: ReadonlyArray<WslDistro>;
1150 readonly distroListError?: DesktopWslDistroListError;
1151 readonly windowsToWslPath?: (distro: string | null, windowsPath: string) => Option.Option<string>;
1152 readonly getUserHome?: (distro: string | null) => Option.Option<string>;
1153 readonly getDistroIp?: (distro: string | null) => Option.Option<string>;
1154 readonly prepareRuntime?: (
1155 distro: string | null,
1156 archive: WslRuntimeArchive,
1157 ) => PrepareWslRuntimeResult;
1158 readonly pruneRuntimes?: (distro: string | null, runtimeId: string) => Effect.Effect<void>;
1159 readonly invalidateRuntime?: (distro: string | null, runtimeId: string) => Effect.Effect<void>;
1160 readonly probeRuntime?: (distro: string | null) => ProbeWslRuntimeResult;
1161 readonly ensureNodePty?: (
1162 distro: string | null,
1163 linuxAppRoot: string,
1164 options?: EnsureWslNodePtyOptions,
1165 ) => EnsureWslNodePtyResult;
1166}
1167
1168export const layerTest = (stub: DesktopWslEnvironmentTestStub = {}) => {
1169 const probeDistros: Effect.Effect<readonly WslDistro[], DesktopWslDistroListError> =
1170 stub.distroListError ? Effect.fail(stub.distroListError) : Effect.succeed(stub.distros ?? []);
1171 return Layer.succeed(
1172 DesktopWslEnvironment,
1173 DesktopWslEnvironment.of({
1174 isAvailable: Effect.succeed(stub.isAvailable ?? false),
1175 listDistros: probeDistros.pipe(Effect.orElseSucceed(() => [])),
1176 probeDistros,
1177 preWarm: Effect.fn("DesktopWslEnvironment.preWarm")(function () {
1178 return Effect.void;
1179 }),
1180 windowsToWslPath: Effect.fn("DesktopWslEnvironment.windowsToWslPath")(
1181 function (distro, windowsPath) {
1182 return Effect.succeed(stub.windowsToWslPath?.(distro, windowsPath) ?? Option.none());
1183 },
1184 ),
1185 getUserHome: Effect.fn("DesktopWslEnvironment.getUserHome")(function (distro) {
1186 return Effect.succeed(stub.getUserHome?.(distro) ?? Option.none<string>());
1187 }),
1188 getDistroIp: Effect.fn("DesktopWslEnvironment.getDistroIp")(function (distro) {
1189 return Effect.succeed(stub.getDistroIp?.(distro) ?? Option.none<string>());
1190 }),
1191 prepareRuntime: Effect.fn("DesktopWslEnvironment.prepareRuntime")(function (distro, archive) {
1192 return Effect.succeed(
1193 stub.prepareRuntime?.(distro, archive) ?? {
1194 ok: false,
1195 reason: "prepareRuntime stub not configured",
1196 },
1197 );
1198 }),
1199 pruneRuntimes: Effect.fn("DesktopWslEnvironment.pruneRuntimes")(function (distro, runtimeId) {
1200 return stub.pruneRuntimes?.(distro, runtimeId) ?? Effect.void;
1201 }),
1202 invalidateRuntime: Effect.fn("DesktopWslEnvironment.invalidateRuntime")(
1203 function (distro, runtimeId) {
1204 return stub.invalidateRuntime?.(distro, runtimeId) ?? Effect.void;
1205 },
1206 ),
1207 probeRuntime: Effect.fn("DesktopWslEnvironment.probeRuntime")(function (distro) {
1208 return Effect.succeed(
1209 stub.probeRuntime?.(distro) ?? {
1210 ok: true,
1211 resolvedPath: "/usr/bin:/bin",
1212 },
1213 );
1214 }),
1215 ensureNodePty: Effect.fn("DesktopWslEnvironment.ensureNodePty")(
1216 function (distro, linuxAppRoot, options) {
1217 return Effect.succeed(
1218 stub.ensureNodePty?.(distro, linuxAppRoot, options) ?? {
1219 ok: false,
1220 reason: "ensureNodePty stub not configured",
1221 fatal: true,
1222 },
1223 );
1224 },
1225 ),
1226 }),
1227 );
1228};
1229
1230export const layer = Layer.effect(
1231 DesktopWslEnvironment,
1232 Effect.gen(function* () {
1233 const environment = yield* DesktopEnvironment.DesktopEnvironment;
1234 const spawner = yield* ChildProcessSpawner.ChildProcessSpawner;
1235 const fileSystem = yield* FileSystem.FileSystem;
1236 // Not Config: Windows variable names are case-insensitive only on the live process.env.
1237 const windir = (yield* HostProcess.Environment).WINDIR ?? "C:\\Windows";
1238
1239 const provideSpawner = <A, E>(
1240 effect: Effect.Effect<A, E, ChildProcessSpawner.ChildProcessSpawner>,
1241 ): Effect.Effect<A, E> =>
1242 effect.pipe(Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner));
1243
1244 // Cache availability so synchronous IPC bootstrap can read it without yielding to filesystem I/O.
1245 const wslAvailable = yield* makeIsAvailable(environment.platform, windir).pipe(
1246 Effect.provideService(FileSystem.FileSystem, fileSystem),
1247 Effect.provideService(Path.Path, environment.path),
1248 Effect.provideService(
1249 DesktopManagedPolicy.DesktopManagedPolicy,
1250 yield* DesktopManagedPolicy.DesktopManagedPolicy,
1251 ),
1252 Effect.withSpan("desktop.wsl.isAvailable"),
1253 );
1254 const isAvailable = Effect.succeed(wslAvailable);
1255
1256 const windowsToWslPath = (distro: string | null, windowsPath: string) =>
1257 provideSpawner(windowsToWslPathImpl(distro, windowsPath)).pipe(
1258 Effect.withSpan("desktop.wsl.windowsToWslPath"),
1259 );
1260
1261 const userHomeCache = new Map<string, string>();
1262 const getUserHome = Effect.fn("desktop.wsl.getUserHome")(function* (distro: string | null) {
1263 const key = distro ?? "__default__";
1264 const cached = userHomeCache.get(key);
1265 if (cached !== undefined) return Option.some(cached);
1266 const resolved = yield* provideSpawner(getUserHomeImpl(distro));
1267 if (Option.isSome(resolved)) userHomeCache.set(key, resolved.value);
1268 return resolved;
1269 });
1270
1271 const getDistroIp = (distro: string | null) =>
1272 provideSpawner(getDistroIpImpl(distro)).pipe(Effect.withSpan("desktop.wsl.getDistroIp"));
1273
1274 const probeDistros = provideSpawner(probeWslDistros).pipe(
1275 Effect.withSpan("desktop.wsl.probeDistros"),
1276 );
1277
1278 return DesktopWslEnvironment.of({
1279 isAvailable,
1280 listDistros: probeDistros.pipe(
1281 Effect.orElseSucceed(() => []),
1282 Effect.withSpan("desktop.wsl.listDistros"),
1283 ),
1284 probeDistros,
1285 preWarm: Effect.fn("desktop.wsl.preWarm")(function (distro) {
1286 return provideSpawner(preWarmImpl(distro));
1287 }),
1288 windowsToWslPath,
1289 getUserHome,
1290 getDistroIp,
1291 prepareRuntime: Effect.fn("desktop.wsl.prepareRuntime")(function (distro, archive) {
1292 return provideSpawner(
1293 prepareWslRuntimeImpl(distro, archive, windowsToWslPath).pipe(
1294 Effect.provideService(FileSystem.FileSystem, fileSystem),
1295 ),
1296 );
1297 }),
1298 pruneRuntimes: Effect.fn("desktop.wsl.pruneRuntimes")(function (distro, runtimeId) {
1299 return provideSpawner(pruneWslRuntimesImpl(distro, runtimeId));
1300 }),
1301 invalidateRuntime: Effect.fn("desktop.wsl.invalidateRuntime")(function (distro, runtimeId) {
1302 return provideSpawner(invalidateWslRuntimeImpl(distro, runtimeId));
1303 }),
1304 probeRuntime: Effect.fn("desktop.wsl.probeRuntime")(function (distro) {
1305 return provideSpawner(probeWslRuntimeImpl(distro));
1306 }),
1307 ensureNodePty: Effect.fn("desktop.wsl.ensureNodePty")(
1308 function (distro, linuxAppRoot, options) {
1309 return provideSpawner(ensureNodePtyImpl(distro, linuxAppRoot, options));
1310 },
1311 ),
1312 });
1313 }),
1314);