891 lines · 34.6 KB · TypeScript
RawDownload
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.