import * as Context from "effect/Context";import * as Duration from "effect/Duration";import * as Effect from "effect/Effect";import * as FileSystem from "effect/FileSystem";import * as Layer from "effect/Layer";import * as Option from "effect/Option";import * as Path from "effect/Path";import * as Schema from "effect/Schema";import * as Stream from "effect/Stream";import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; import { buildRemoteNodeEnvScript } from "@t3tools/ssh/tunnel";import { satisfiesSemverRange } from "@t3tools/shared/semver"; import * as DesktopEnvironment from "../app/DesktopEnvironment.ts";import { parseWslDistroList, type WslDistro } from "./wslPathParsing.ts"; const PROCESS_TERMINATE_GRACE = Duration.seconds(1);const LIST_TIMEOUT = Duration.seconds(8);const PRE_WARM_TIMEOUT = Duration.seconds(10);const WSLPATH_TIMEOUT = Duration.seconds(10);const PROBE_TIMEOUT = Duration.seconds(10);const TOOLCHAIN_TIMEOUT = Duration.seconds(10);const BUILD_TIMEOUT = Duration.minutes(5);const USER_HOME_TIMEOUT = Duration.seconds(5);const TOOLCHAIN_TRANSPORT_RETRY_LIMIT = 12;const BUILD_TRANSPORT_RETRY_LIMIT = 2; export interface EnsureWslNodePtyOptions { readonly allowBuild?: boolean; readonly nodeEngineRange?: string | null;} export type EnsureWslNodePtyResult = | { readonly ok: true; readonly nodePath: string; readonly resolvedPath: string; } | { readonly ok: false; readonly reason: string; readonly fatal: boolean; readonly retryLimit?: number; }; export class DesktopWslDistroListError extends Schema.TaggedErrorClass<DesktopWslDistroListError>()( "DesktopWslDistroListError", { reason: Schema.String },) { override get message(): string { return this.reason; }} const isDesktopWslDistroListError = Schema.is(DesktopWslDistroListError); export class DesktopWslEnvironment extends Context.Service< DesktopWslEnvironment, { readonly isAvailable: Effect.Effect<boolean>; // Best-effort enumeration for renderer UX. Backend health checks must use // probeDistros so a transient command failure is not mistaken for a // successful empty installation. readonly listDistros: Effect.Effect<readonly WslDistro[]>; readonly probeDistros: Effect.Effect<readonly WslDistro[], DesktopWslDistroListError>; readonly preWarm: (distro: string | null) => Effect.Effect<void>; readonly windowsToWslPath: ( distro: string | null, windowsPath: string, ) => Effect.Effect<Option.Option<string>>; // Resolves the user's Linux home dir inside the chosen distro (e.g. // "/home/josh"). Used by the folder picker to expand `~` correctly. readonly getUserHome: (distro: string | null) => Effect.Effect<Option.Option<string>>; // Resolves the WSL distro's IPv4 address on the WSL vEthernet adapter // (e.g. "172.x.x.x"). The orchestrator uses this for the WSL backend's // httpBaseUrl so the renderer can reach it without relying on wslhost's // localhost→WSL automatic forwarding, which is flaky in practice // (the backend can be listening for 30+ seconds before wslhost starts // forwarding 127.0.0.1:port to WSL-side localhost). readonly getDistroIp: (distro: string | null) => Effect.Effect<Option.Option<string>>; readonly ensureNodePty: ( distro: string | null, windowsRepoRoot: string, options?: EnsureWslNodePtyOptions, ) => Effect.Effect<EnsureWslNodePtyResult>; }>()("@t3tools/desktop/wsl/DesktopWslEnvironment") {} const buildDistroArgs = (distro: string | null): ReadonlyArray<string> => distro ? ["-d", distro] : []; const concatChunks = (arrays: ReadonlyArray<Uint8Array>): Uint8Array => { let totalLength = 0; for (const arr of arrays) totalLength += arr.byteLength; const out = new Uint8Array(totalLength); let offset = 0; for (const arr of arrays) { out.set(arr, offset); offset += arr.byteLength; } return out;}; const decodeUtf8 = (bytes: Uint8Array): string => new TextDecoder("utf-8").decode(bytes); interface ShellResult { readonly exitCode: number; readonly stdout: string; readonly stderr: string; readonly transportFailure: "timeout" | "spawn" | "process" | null;} const TIMEOUT_RESULT: ShellResult = { exitCode: 124, stdout: "", stderr: "\n[timeout]", transportFailure: "timeout",}; export const formatWslShellTransportFailureReason = ( failure: ShellResult["transportFailure"],): string | null => { switch (failure) { case "timeout": return "WSL backend preflight timed out while probing for Node.js. WSL may be slow to start; retry, or check that the distro is healthy."; case "spawn": return "WSL backend preflight could not start wsl.exe to probe for Node.js. Check that WSL is installed and the distro is accessible."; case "process": return "WSL backend preflight lost communication with wsl.exe while probing for Node.js. Retry, or check that the distro is healthy."; case null: return null; }}; // Reuse the SSH remote resolver so WSL and SSH discover version-managed Node// the same way. Passing the engine range lets the resolver fall through to// version managers like nvm when a system node exists but is too old.export const buildWslNodeEnvPreamble = ( nodeEngineRange?: string | null,): string => `${buildRemoteNodeEnvScript({ nodeEngineRange: nodeEngineRange ?? null })}ensure_remote_node_path || true`; // wsl.exe re-escapes args before forwarding them to the Linux side, which// mangles quotes inside `bash -lc "<script>"`. Pipe the script via stdin to// avoid passing it on the command line at all.const runWslShell = ( distro: string | null, bashScript: string, timeout: Duration.Duration, options: EnsureWslNodePtyOptions = {},): Effect.Effect<ShellResult, never, ChildProcessSpawner.ChildProcessSpawner> => { const spawner = ChildProcessSpawner.ChildProcessSpawner; // -l picks up profile-managed PATH; the shared resolver covers supported // version managers that non-interactive login shells can miss. -s so bash // reads the script from stdin. const command = ChildProcess.make( "wsl.exe", [...buildDistroArgs(distro), "--", "bash", "-l", "-s"], { stdin: Stream.encodeText( Stream.make(`${buildWslNodeEnvPreamble(options.nodeEngineRange)}${bashScript}`), ), stdout: "pipe", stderr: "pipe", killSignal: "SIGTERM", forceKillAfter: PROCESS_TERMINATE_GRACE, }, ); return Effect.scoped( Effect.gen(function* () { const spawnerService = yield* spawner; const spawnResult = yield* spawnerService.spawn(command).pipe( Effect.match({ onFailure: (error) => ({ _tag: "Failure", error }) as const, onSuccess: (handle) => ({ _tag: "Success", handle }) as const, }), ); if (spawnResult._tag === "Failure") { return { exitCode: 127, stdout: "", stderr: `\n${spawnResult.error.message}`, transportFailure: "spawn", } satisfies ShellResult; } const handle = spawnResult.handle; // Drain stdout and stderr concurrently so neither pipe buffer can fill // and stall the child (node-gyp rebuild emits large output on both). const [stdoutBytes, stderrBytes, exitCode] = yield* Effect.all( [Stream.runCollect(handle.stdout), Stream.runCollect(handle.stderr), handle.exitCode], { concurrency: "unbounded" }, ); return { exitCode: exitCode as unknown as number, stdout: decodeUtf8(concatChunks(stdoutBytes)), stderr: decodeUtf8(concatChunks(stderrBytes)), transportFailure: null, } satisfies ShellResult; }), ).pipe( Effect.timeoutOption(timeout), Effect.map(Option.getOrElse((): ShellResult => TIMEOUT_RESULT)), Effect.catch((error) => Effect.succeed<ShellResult>({ exitCode: 127, stdout: "", stderr: `\n${error.message}`, transportFailure: "process", }), ), );}; const shellQuote = (value: string): string => `'${value.replaceAll("'", "'\\''")}'`; const NODE_PTY_PREBUILD_MISSING_EXIT_CODE = 4; export const formatNodePtyProbeFailureReason = (exitCode: number): string | null => exitCode === NODE_PTY_PREBUILD_MISSING_EXIT_CODE ? "WSL support is missing from this T3 Code build: the packaged Linux node-pty binary was not included. Rebuild the Windows artifact with `--wsl-prebuild <path-to-linux-pty.node>` or install a build that includes WSL support." : null; const NODE_PTY_PROBE_SCRIPT = ( linuxServerDir: string,) => `printf 'nodePath:%s\\n' "$(command -v node 2>/dev/null)"printf 'nodeVersion:%s\\n' "$(node -p 'process.versions.node' 2>/dev/null)"printf 'resolvedPath:%s\\n' "$PATH"cd ${shellQuote(linuxServerDir)} && node <<'NODE' >/dev/null 2>&1// The WSL Node can't read inside app.asar, so confirm what the server needs is// unpacked on the real filesystem before reporting the backend healthy. Exit 3// marks this distinct from a node-pty prebuild problem so the caller can report// it accurately instead of letting the server crash on ERR_MODULE_NOT_FOUND at// launch (which, in wsl-only mode, would just fail to launch with no fallback).//// The sentinel must be a package the CLI bundle leaves external. It used to be// "effect", back when the bundle externalized its runtime deps and the whole// node_modules tree was unpacked. The bundle now inlines its JS dependencies,// so "effect" no longer exists on disk and only the native packages do —// resolving node-pty is what actually validates the unpacked tree.try { require.resolve("node-pty/package.json"); } catch (_e) { process.exit(3); }const fs = require("node:fs");const path = require("node:path");const pkgDir = path.dirname(require.resolve("node-pty/package.json"));// node-pty 1.x is N-API based, so a single Linux pty.node is ABI-stable across// Node versions — require() succeeding IS the real compatibility test. Compare// only arch and node-pty version (a stale binary from a different node-pty),// NOT process.versions.modules: that would reject a perfectly loadable prebuilt// whenever the user's WSL Node ABI differs from the build's, defeating the// whole point of shipping one prebuilt for all Node versions.const expected = { arch: process.arch, nodePtyVersion: require("node-pty/package.json").version,};const prebuildDir = path.join(pkgDir, "prebuilds", "linux-" + process.arch);const marker = path.join(prebuildDir, "t3code-wsl-node-pty.json");const binary = path.join(prebuildDir, "pty.node");if (!fs.existsSync(marker) || !fs.existsSync(binary)) process.exit(${NODE_PTY_PREBUILD_MISSING_EXIT_CODE});require("node-pty");const actual = JSON.parse(fs.readFileSync(marker, "utf8"));for (const key of Object.keys(expected)) { if (actual[key] !== expected[key]) process.exit(2);}NODE`; const TOOLCHAIN_CHECK_SCRIPT = [ "for tool in node make g++ python3; do", ' command -v "$tool" >/dev/null 2>&1 || echo "missing:$tool"', "done", "if command -v node >/dev/null 2>&1; then", ` ver="$(node -p 'process.versions.node' 2>/dev/null)"`, ' if [ -n "$ver" ]; then printf "nodeVersion:%s\\n" "$ver"; fi', "fi",].join("\n"); const NODE_PTY_BUILD_SCRIPT = (linuxServerDir: string) => [ "set -e", `cd ${shellQuote(linuxServerDir)}`, `pkg_dir=$(node -p "require('node:path').dirname(require.resolve('node-pty/package.json'))")`, `arch=$(node -p "process.arch")`, `modules=$(node -p "process.versions.modules")`, `node_pty_version=$(node -p "require('node-pty/package.json').version")`, `cd "$pkg_dir"`, "npx --yes node-gyp rebuild", `prebuild_dir="prebuilds/linux-$arch"`, `mkdir -p "$prebuild_dir"`, `cp build/Release/pty.node "$prebuild_dir/pty.node"`, `printf '{"arch":"%s","modules":"%s","nodePtyVersion":"%s"}\\n' "$arch" "$modules" "$node_pty_version" > "$prebuild_dir/t3code-wsl-node-pty.json"`, `node -e 'require("node-pty")'`, ].join("\n"); export interface ToolchainReport { readonly missingTools: ReadonlyArray<string>; readonly nodeVersion: string | null;} export const parseToolchainReport = (stdout: string): ToolchainReport => { const lines = stdout .split("\n") .map((line) => line.trim()) .filter((line) => line.length > 0); const missingTools = lines .filter((line) => line.startsWith("missing:")) .map((line) => line.slice("missing:".length)); const nodeVersionLine = lines.find((line) => line.startsWith("nodeVersion:")); const nodeVersion = nodeVersionLine ? nodeVersionLine.slice("nodeVersion:".length).trim() || null : null; return { missingTools, nodeVersion };}; // Pulls the absolute node path the WSL distro resolved after the shared remote// resolver repaired PATH. Returns null when no node was found, which the caller// turns into an actionable "install Node" message instead of a confusing// node-pty error.export const parseNodePath = (stdout: string): string | null => { const path = stdout .split("\n") .map((line) => line.trim()) .filter((line) => line.startsWith("nodePath:")) .map((line) => line.slice("nodePath:".length).trim()) .find((value) => value.length > 0); return path ?? null;}; export const parseNodeVersion = (stdout: string): string | null => { const version = stdout .split("\n") .map((line) => line.trim()) .filter((line) => line.startsWith("nodeVersion:")) .map((line) => line.slice("nodeVersion:".length).trim()) .find((value) => value.length > 0); return version ?? null;}; // Captures the login-shell PATH after the shared resolver has loaded version// managers. Preserve the value byte-for-byte apart from a Windows-style CR so// paths containing spaces or apostrophes can be forwarded as one env argv.export const parseResolvedPath = (stdout: string): string | null => { const prefix = "resolvedPath:"; const line = stdout.split("\n").find((candidate) => candidate.startsWith(prefix)); if (line === undefined) return null; const resolvedPath = line.slice(prefix.length).replace(/\r$/, ""); return resolvedPath.length > 0 ? resolvedPath : null;}; export const formatMissingToolsReason = ( report: ToolchainReport, requiredRange: string | null,): string | null => { const nodeMissing = report.missingTools.includes("node"); const nodeOutOfRange = !nodeMissing && requiredRange !== null && report.nodeVersion !== null && !satisfiesSemverRange(report.nodeVersion, requiredRange); const buildToolsMissing = report.missingTools.filter((tool) => tool !== "node"); if (!nodeMissing && !nodeOutOfRange && buildToolsMissing.length === 0) { return null; } const issues: string[] = []; const remediations: string[] = []; if (nodeMissing) { issues.push("node"); remediations.push( `Node.js${requiredRange ? ` satisfying \`${requiredRange}\`` : " 18+"} (e.g. via nvm)`, ); } else if (nodeOutOfRange) { issues.push(`node ${report.nodeVersion} (requires ${requiredRange})`); remediations.push( `a newer Node.js satisfying \`${requiredRange}\` (e.g. \`nvm install 24 && nvm alias default 24\`)`, ); } if (buildToolsMissing.length > 0) { issues.push(...buildToolsMissing); remediations.push( "the build toolchain (e.g. `sudo apt install -y build-essential python3` on Ubuntu/Debian)", ); } return `WSL distro is missing required tools: ${issues.join(", ")}. Install ${remediations.join(" and ")}, then retry.`;}; const ensureNodePtyImpl = ( distro: string | null, windowsRepoRoot: string, windowsToWslPath: ( distro: string | null, windowsPath: string, ) => Effect.Effect<Option.Option<string>>, options: EnsureWslNodePtyOptions = {},): Effect.Effect<EnsureWslNodePtyResult, never, ChildProcessSpawner.ChildProcessSpawner> => Effect.gen(function* () { const linuxRepoRootOption = yield* windowsToWslPath(distro, windowsRepoRoot); if (Option.isNone(linuxRepoRootOption)) { return { ok: false, reason: `wslpath conversion failed for ${windowsRepoRoot}`, fatal: false, } as const; } const linuxRepoRoot = linuxRepoRootOption.value; // node-pty lives in the apps/server workspace's node_modules; resolve from // there rather than the monorepo root, where Bun's hoist layout omits it. const linuxServerDir = `${linuxRepoRoot}/apps/server`; const probe = yield* runWslShell( distro, NODE_PTY_PROBE_SCRIPT(linuxServerDir), PROBE_TIMEOUT, options, ); const nodePath = parseNodePath(probe.stdout); const resolvedPath = parseResolvedPath(probe.stdout); const transportFailureReason = formatWslShellTransportFailureReason(probe.transportFailure); if (transportFailureReason !== null) { return { ok: false, reason: transportFailureReason, fatal: false, } as const; } // No node at all, even after the shared resolver repaired PATH. Surface // the specific, actionable toolchain message rather than a confusing // node-pty error, and don't try to build. if (nodePath === null) { const toolchainCheck = yield* runWslShell( distro, TOOLCHAIN_CHECK_SCRIPT, TOOLCHAIN_TIMEOUT, options, ); const toolchainTransportFailure = formatWslShellTransportFailureReason( toolchainCheck.transportFailure, ); if (toolchainTransportFailure !== null) { return { ok: false, reason: toolchainTransportFailure, fatal: false, retryLimit: TOOLCHAIN_TRANSPORT_RETRY_LIMIT, } as const; } const report = parseToolchainReport(toolchainCheck.stdout); const reason = formatMissingToolsReason(report, options.nodeEngineRange?.trim() || null) ?? "Node.js was not found in the WSL distro. Install it (e.g. via nvm) and restart the desktop app."; return { ok: false, reason, fatal: true } as const; } if (resolvedPath === null) { return { ok: false, reason: "WSL login-shell PATH could not be resolved during backend preflight.", fatal: true, } as const; } // The packages the server bundle leaves external (node-pty and the other // native addons) couldn't be resolved on the WSL filesystem — a packaging // regression, since those must be unpacked from the asar. Fatal so wsl-only // mode falls back to Windows and dual mode surfaces the reason inline, // instead of the server crash-looping on ERR_MODULE_NOT_FOUND once it // actually launches. if (probe.exitCode === 3) { return { ok: false, reason: '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.', fatal: true, } as const; } if (probe.exitCode === 0) { const rawVersion = parseNodeVersion(probe.stdout); if ( rawVersion !== null && options.nodeEngineRange && !satisfiesSemverRange(rawVersion, options.nodeEngineRange.trim()) ) { const range = options.nodeEngineRange.trim(); return { ok: false, reason: `WSL Node.js ${rawVersion} does not satisfy the server's required engine range (${range}). Install a compatible version, and restart the desktop app.`, fatal: true, } as const; } return { ok: true, nodePath, resolvedPath } as const; } if (options.allowBuild !== true) {Lines 1–500 of 891. Load more
import * as Context from "effect/Context"; import * as Duration from "effect/Duration"; import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; import * as Path from "effect/Path"; import * as Schema from "effect/Schema"; import * as Stream from "effect/Stream"; import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; import { buildRemoteNodeEnvScript } from "@t3tools/ssh/tunnel"; import { satisfiesSemverRange } from "@t3tools/shared/semver"; import * as DesktopEnvironment from "../app/DesktopEnvironment.ts"; import { parseWslDistroList, type WslDistro } from "./wslPathParsing.ts"; const PROCESS_TERMINATE_GRACE = Duration.seconds(1); const LIST_TIMEOUT = Duration.seconds(8); const PRE_WARM_TIMEOUT = Duration.seconds(10); const WSLPATH_TIMEOUT = Duration.seconds(10); const PROBE_TIMEOUT = Duration.seconds(10); const TOOLCHAIN_TIMEOUT = Duration.seconds(10); const BUILD_TIMEOUT = Duration.minutes(5); const USER_HOME_TIMEOUT = Duration.seconds(5); const TOOLCHAIN_TRANSPORT_RETRY_LIMIT = 12; const BUILD_TRANSPORT_RETRY_LIMIT = 2; export interface EnsureWslNodePtyOptions { readonly allowBuild?: boolean; readonly nodeEngineRange?: string | null; } export type EnsureWslNodePtyResult = | { readonly ok: true; readonly nodePath: string; readonly resolvedPath: string; } | { readonly ok: false; readonly reason: string; readonly fatal: boolean; readonly retryLimit?: number; }; export class DesktopWslDistroListError extends Schema.TaggedErrorClass<DesktopWslDistroListError>()( "DesktopWslDistroListError", { reason: Schema.String }, ) { override get message(): string { return this.reason; } } const isDesktopWslDistroListError = Schema.is(DesktopWslDistroListError); export class DesktopWslEnvironment extends Context.Service< DesktopWslEnvironment, { readonly isAvailable: Effect.Effect<boolean>; // Best-effort enumeration for renderer UX. Backend health checks must use // probeDistros so a transient command failure is not mistaken for a // successful empty installation. readonly listDistros: Effect.Effect<readonly WslDistro[]>; readonly probeDistros: Effect.Effect<readonly WslDistro[], DesktopWslDistroListError>; readonly preWarm: (distro: string | null) => Effect.Effect<void>; readonly windowsToWslPath: ( distro: string | null, windowsPath: string, ) => Effect.Effect<Option.Option<string>>; // Resolves the user's Linux home dir inside the chosen distro (e.g. // "/home/josh"). Used by the folder picker to expand `~` correctly. readonly getUserHome: (distro: string | null) => Effect.Effect<Option.Option<string>>; // Resolves the WSL distro's IPv4 address on the WSL vEthernet adapter // (e.g. "172.x.x.x"). The orchestrator uses this for the WSL backend's // httpBaseUrl so the renderer can reach it without relying on wslhost's // localhost→WSL automatic forwarding, which is flaky in practice // (the backend can be listening for 30+ seconds before wslhost starts // forwarding 127.0.0.1:port to WSL-side localhost). readonly getDistroIp: (distro: string | null) => Effect.Effect<Option.Option<string>>; readonly ensureNodePty: ( distro: string | null, windowsRepoRoot: string, options?: EnsureWslNodePtyOptions, ) => Effect.Effect<EnsureWslNodePtyResult>; } >()("@t3tools/desktop/wsl/DesktopWslEnvironment") {} const buildDistroArgs = (distro: string | null): ReadonlyArray<string> => distro ? ["-d", distro] : []; const concatChunks = (arrays: ReadonlyArray<Uint8Array>): Uint8Array => { let totalLength = 0; for (const arr of arrays) totalLength += arr.byteLength; const out = new Uint8Array(totalLength); let offset = 0; for (const arr of arrays) { out.set(arr, offset); offset += arr.byteLength; } return out; }; const decodeUtf8 = (bytes: Uint8Array): string => new TextDecoder("utf-8").decode(bytes); interface ShellResult { readonly exitCode: number; readonly stdout: string; readonly stderr: string; readonly transportFailure: "timeout" | "spawn" | "process" | null; } const TIMEOUT_RESULT: ShellResult = { exitCode: 124, stdout: "", stderr: "\n[timeout]", transportFailure: "timeout", }; export const formatWslShellTransportFailureReason = ( failure: ShellResult["transportFailure"], ): string | null => { switch (failure) { case "timeout": return "WSL backend preflight timed out while probing for Node.js. WSL may be slow to start; retry, or check that the distro is healthy."; case "spawn": return "WSL backend preflight could not start wsl.exe to probe for Node.js. Check that WSL is installed and the distro is accessible."; case "process": return "WSL backend preflight lost communication with wsl.exe while probing for Node.js. Retry, or check that the distro is healthy."; case null: return null; } }; // Reuse the SSH remote resolver so WSL and SSH discover version-managed Node // the same way. Passing the engine range lets the resolver fall through to // version managers like nvm when a system node exists but is too old. export const buildWslNodeEnvPreamble = ( nodeEngineRange?: string | null, ): string => `${buildRemoteNodeEnvScript({ nodeEngineRange: nodeEngineRange ?? null })} ensure_remote_node_path || true `; // wsl.exe re-escapes args before forwarding them to the Linux side, which // mangles quotes inside `bash -lc "<script>"`. Pipe the script via stdin to // avoid passing it on the command line at all. const runWslShell = ( distro: string | null, bashScript: string, timeout: Duration.Duration, options: EnsureWslNodePtyOptions = {}, ): Effect.Effect<ShellResult, never, ChildProcessSpawner.ChildProcessSpawner> => { const spawner = ChildProcessSpawner.ChildProcessSpawner; // -l picks up profile-managed PATH; the shared resolver covers supported // version managers that non-interactive login shells can miss. -s so bash // reads the script from stdin. const command = ChildProcess.make( "wsl.exe", [...buildDistroArgs(distro), "--", "bash", "-l", "-s"], { stdin: Stream.encodeText( Stream.make(`${buildWslNodeEnvPreamble(options.nodeEngineRange)}${bashScript}`), ), stdout: "pipe", stderr: "pipe", killSignal: "SIGTERM", forceKillAfter: PROCESS_TERMINATE_GRACE, }, ); return Effect.scoped( Effect.gen(function* () { const spawnerService = yield* spawner; const spawnResult = yield* spawnerService.spawn(command).pipe( Effect.match({ onFailure: (error) => ({ _tag: "Failure", error }) as const, onSuccess: (handle) => ({ _tag: "Success", handle }) as const, }), ); if (spawnResult._tag === "Failure") { return { exitCode: 127, stdout: "", stderr: `\n${spawnResult.error.message}`, transportFailure: "spawn", } satisfies ShellResult; } const handle = spawnResult.handle; // Drain stdout and stderr concurrently so neither pipe buffer can fill // and stall the child (node-gyp rebuild emits large output on both). const [stdoutBytes, stderrBytes, exitCode] = yield* Effect.all( [Stream.runCollect(handle.stdout), Stream.runCollect(handle.stderr), handle.exitCode], { concurrency: "unbounded" }, ); return { exitCode: exitCode as unknown as number, stdout: decodeUtf8(concatChunks(stdoutBytes)), stderr: decodeUtf8(concatChunks(stderrBytes)), transportFailure: null, } satisfies ShellResult; }), ).pipe( Effect.timeoutOption(timeout), Effect.map(Option.getOrElse((): ShellResult => TIMEOUT_RESULT)), Effect.catch((error) => Effect.succeed<ShellResult>({ exitCode: 127, stdout: "", stderr: `\n${error.message}`, transportFailure: "process", }), ), ); }; const shellQuote = (value: string): string => `'${value.replaceAll("'", "'\\''")}'`; const NODE_PTY_PREBUILD_MISSING_EXIT_CODE = 4; export const formatNodePtyProbeFailureReason = (exitCode: number): string | null => exitCode === NODE_PTY_PREBUILD_MISSING_EXIT_CODE ? "WSL support is missing from this T3 Code build: the packaged Linux node-pty binary was not included. Rebuild the Windows artifact with `--wsl-prebuild <path-to-linux-pty.node>` or install a build that includes WSL support." : null; const NODE_PTY_PROBE_SCRIPT = ( linuxServerDir: string, ) => `printf 'nodePath:%s\\n' "$(command -v node 2>/dev/null)" printf 'nodeVersion:%s\\n' "$(node -p 'process.versions.node' 2>/dev/null)" printf 'resolvedPath:%s\\n' "$PATH" cd ${shellQuote(linuxServerDir)} && node <<'NODE' >/dev/null 2>&1 // The WSL Node can't read inside app.asar, so confirm what the server needs is // unpacked on the real filesystem before reporting the backend healthy. Exit 3 // marks this distinct from a node-pty prebuild problem so the caller can report // it accurately instead of letting the server crash on ERR_MODULE_NOT_FOUND at // launch (which, in wsl-only mode, would just fail to launch with no fallback). // // The sentinel must be a package the CLI bundle leaves external. It used to be // "effect", back when the bundle externalized its runtime deps and the whole // node_modules tree was unpacked. The bundle now inlines its JS dependencies, // so "effect" no longer exists on disk and only the native packages do — // resolving node-pty is what actually validates the unpacked tree. try { require.resolve("node-pty/package.json"); } catch (_e) { process.exit(3); } const fs = require("node:fs"); const path = require("node:path"); const pkgDir = path.dirname(require.resolve("node-pty/package.json")); // node-pty 1.x is N-API based, so a single Linux pty.node is ABI-stable across // Node versions — require() succeeding IS the real compatibility test. Compare // only arch and node-pty version (a stale binary from a different node-pty), // NOT process.versions.modules: that would reject a perfectly loadable prebuilt // whenever the user's WSL Node ABI differs from the build's, defeating the // whole point of shipping one prebuilt for all Node versions. const expected = { arch: process.arch, nodePtyVersion: require("node-pty/package.json").version, }; const prebuildDir = path.join(pkgDir, "prebuilds", "linux-" + process.arch); const marker = path.join(prebuildDir, "t3code-wsl-node-pty.json"); const binary = path.join(prebuildDir, "pty.node"); if (!fs.existsSync(marker) || !fs.existsSync(binary)) process.exit(${NODE_PTY_PREBUILD_MISSING_EXIT_CODE}); require("node-pty"); const actual = JSON.parse(fs.readFileSync(marker, "utf8")); for (const key of Object.keys(expected)) { if (actual[key] !== expected[key]) process.exit(2); } NODE`; const TOOLCHAIN_CHECK_SCRIPT = [ "for tool in node make g++ python3; do", ' command -v "$tool" >/dev/null 2>&1 || echo "missing:$tool"', "done", "if command -v node >/dev/null 2>&1; then", ` ver="$(node -p 'process.versions.node' 2>/dev/null)"`, ' if [ -n "$ver" ]; then printf "nodeVersion:%s\\n" "$ver"; fi', "fi", ].join("\n"); const NODE_PTY_BUILD_SCRIPT = (linuxServerDir: string) => [ "set -e", `cd ${shellQuote(linuxServerDir)}`, `pkg_dir=$(node -p "require('node:path').dirname(require.resolve('node-pty/package.json'))")`, `arch=$(node -p "process.arch")`, `modules=$(node -p "process.versions.modules")`, `node_pty_version=$(node -p "require('node-pty/package.json').version")`, `cd "$pkg_dir"`, "npx --yes node-gyp rebuild", `prebuild_dir="prebuilds/linux-$arch"`, `mkdir -p "$prebuild_dir"`, `cp build/Release/pty.node "$prebuild_dir/pty.node"`, `printf '{"arch":"%s","modules":"%s","nodePtyVersion":"%s"}\\n' "$arch" "$modules" "$node_pty_version" > "$prebuild_dir/t3code-wsl-node-pty.json"`, `node -e 'require("node-pty")'`, ].join("\n"); export interface ToolchainReport { readonly missingTools: ReadonlyArray<string>; readonly nodeVersion: string | null; } export const parseToolchainReport = (stdout: string): ToolchainReport => { const lines = stdout .split("\n") .map((line) => line.trim()) .filter((line) => line.length > 0); const missingTools = lines .filter((line) => line.startsWith("missing:")) .map((line) => line.slice("missing:".length)); const nodeVersionLine = lines.find((line) => line.startsWith("nodeVersion:")); const nodeVersion = nodeVersionLine ? nodeVersionLine.slice("nodeVersion:".length).trim() || null : null; return { missingTools, nodeVersion }; }; // Pulls the absolute node path the WSL distro resolved after the shared remote // resolver repaired PATH. Returns null when no node was found, which the caller // turns into an actionable "install Node" message instead of a confusing // node-pty error. export const parseNodePath = (stdout: string): string | null => { const path = stdout .split("\n") .map((line) => line.trim()) .filter((line) => line.startsWith("nodePath:")) .map((line) => line.slice("nodePath:".length).trim()) .find((value) => value.length > 0); return path ?? null; }; export const parseNodeVersion = (stdout: string): string | null => { const version = stdout .split("\n") .map((line) => line.trim()) .filter((line) => line.startsWith("nodeVersion:")) .map((line) => line.slice("nodeVersion:".length).trim()) .find((value) => value.length > 0); return version ?? null; }; // Captures the login-shell PATH after the shared resolver has loaded version // managers. Preserve the value byte-for-byte apart from a Windows-style CR so // paths containing spaces or apostrophes can be forwarded as one env argv. export const parseResolvedPath = (stdout: string): string | null => { const prefix = "resolvedPath:"; const line = stdout.split("\n").find((candidate) => candidate.startsWith(prefix)); if (line === undefined) return null; const resolvedPath = line.slice(prefix.length).replace(/\r$/, ""); return resolvedPath.length > 0 ? resolvedPath : null; }; export const formatMissingToolsReason = ( report: ToolchainReport, requiredRange: string | null, ): string | null => { const nodeMissing = report.missingTools.includes("node"); const nodeOutOfRange = !nodeMissing && requiredRange !== null && report.nodeVersion !== null && !satisfiesSemverRange(report.nodeVersion, requiredRange); const buildToolsMissing = report.missingTools.filter((tool) => tool !== "node"); if (!nodeMissing && !nodeOutOfRange && buildToolsMissing.length === 0) { return null; } const issues: string[] = []; const remediations: string[] = []; if (nodeMissing) { issues.push("node"); remediations.push( `Node.js${requiredRange ? ` satisfying \`${requiredRange}\`` : " 18+"} (e.g. via nvm)`, ); } else if (nodeOutOfRange) { issues.push(`node ${report.nodeVersion} (requires ${requiredRange})`); remediations.push( `a newer Node.js satisfying \`${requiredRange}\` (e.g. \`nvm install 24 && nvm alias default 24\`)`, ); } if (buildToolsMissing.length > 0) { issues.push(...buildToolsMissing); remediations.push( "the build toolchain (e.g. `sudo apt install -y build-essential python3` on Ubuntu/Debian)", ); } return `WSL distro is missing required tools: ${issues.join(", ")}. Install ${remediations.join(" and ")}, then retry.`; }; const ensureNodePtyImpl = ( distro: string | null, windowsRepoRoot: string, windowsToWslPath: ( distro: string | null, windowsPath: string, ) => Effect.Effect<Option.Option<string>>, options: EnsureWslNodePtyOptions = {}, ): Effect.Effect<EnsureWslNodePtyResult, never, ChildProcessSpawner.ChildProcessSpawner> => Effect.gen(function* () { const linuxRepoRootOption = yield* windowsToWslPath(distro, windowsRepoRoot); if (Option.isNone(linuxRepoRootOption)) { return { ok: false, reason: `wslpath conversion failed for ${windowsRepoRoot}`, fatal: false, } as const; } const linuxRepoRoot = linuxRepoRootOption.value; // node-pty lives in the apps/server workspace's node_modules; resolve from // there rather than the monorepo root, where Bun's hoist layout omits it. const linuxServerDir = `${linuxRepoRoot}/apps/server`; const probe = yield* runWslShell( distro, NODE_PTY_PROBE_SCRIPT(linuxServerDir), PROBE_TIMEOUT, options, ); const nodePath = parseNodePath(probe.stdout); const resolvedPath = parseResolvedPath(probe.stdout); const transportFailureReason = formatWslShellTransportFailureReason(probe.transportFailure); if (transportFailureReason !== null) { return { ok: false, reason: transportFailureReason, fatal: false, } as const; } // No node at all, even after the shared resolver repaired PATH. Surface // the specific, actionable toolchain message rather than a confusing // node-pty error, and don't try to build. if (nodePath === null) { const toolchainCheck = yield* runWslShell( distro, TOOLCHAIN_CHECK_SCRIPT, TOOLCHAIN_TIMEOUT, options, ); const toolchainTransportFailure = formatWslShellTransportFailureReason( toolchainCheck.transportFailure, ); if (toolchainTransportFailure !== null) { return { ok: false, reason: toolchainTransportFailure, fatal: false, retryLimit: TOOLCHAIN_TRANSPORT_RETRY_LIMIT, } as const; } const report = parseToolchainReport(toolchainCheck.stdout); const reason = formatMissingToolsReason(report, options.nodeEngineRange?.trim() || null) ?? "Node.js was not found in the WSL distro. Install it (e.g. via nvm) and restart the desktop app."; return { ok: false, reason, fatal: true } as const; } if (resolvedPath === null) { return { ok: false, reason: "WSL login-shell PATH could not be resolved during backend preflight.", fatal: true, } as const; } // The packages the server bundle leaves external (node-pty and the other // native addons) couldn't be resolved on the WSL filesystem — a packaging // regression, since those must be unpacked from the asar. Fatal so wsl-only // mode falls back to Windows and dual mode surfaces the reason inline, // instead of the server crash-looping on ERR_MODULE_NOT_FOUND once it // actually launches. if (probe.exitCode === 3) { return { ok: false, reason: '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.', fatal: true, } as const; } if (probe.exitCode === 0) { const rawVersion = parseNodeVersion(probe.stdout); if ( rawVersion !== null && options.nodeEngineRange && !satisfiesSemverRange(rawVersion, options.nodeEngineRange.trim()) ) { const range = options.nodeEngineRange.trim(); return { ok: false, reason: `WSL Node.js ${rawVersion} does not satisfy the server's required engine range (${range}). Install a compatible version, and restart the desktop app.`, fatal: true, } as const; } return { ok: true, nodePath, resolvedPath } as const; } if (options.allowBuild !== true) {