diff --git a/apps/desktop/src/backend/DesktopBackendConfiguration.test.ts b/apps/desktop/src/backend/DesktopBackendConfiguration.test.ts index 642a1bc82f4218ebf3d1c6eceeb743272b9d8ffc..edc719734912b310b283b49f24a8102a13a49f2f 100644 --- a/apps/desktop/src/backend/DesktopBackendConfiguration.test.ts +++ b/apps/desktop/src/backend/DesktopBackendConfiguration.test.ts @@ -399,10 +399,10 @@ describe("DesktopBackendConfiguration", () => { observedProbeRoots.push(root); return { ok: true, resolvedPath }; }, - // The staged runtime carries its own Node, so the preflight must not - // go looking for one in the distro. + // The staged runtime carries its own Node and node-pty, so it must + // not require the mounted server tree's native dependency check. ensureNodePty: () => { - throw new Error("the staged runtime must not probe for Node"); + throw new Error("the staged runtime must not probe for node-pty"); }, }), }, diff --git a/apps/desktop/src/backend/DesktopBackendConfiguration.ts b/apps/desktop/src/backend/DesktopBackendConfiguration.ts index 3486daebf79e2ed3e871bf92619efb7188b30f87..09014add865c71edb12b463f1d765ad48f7f79c7 100644 --- a/apps/desktop/src/backend/DesktopBackendConfiguration.ts +++ b/apps/desktop/src/backend/DesktopBackendConfiguration.ts @@ -384,8 +384,8 @@ const runWslPreflight = Effect.fn("desktop.backendConfiguration.wslPreflight")(f if (input.runtimeArchive !== null) { const runtime = yield* wslEnv.prepareRuntime(runningDistro, input.runtimeArchive); if (runtime.ok) { - // The staged runtime is self-contained, so the only question is whether - // it runs here; there is no Node to find or node-pty to load. + // The staged runtime supplies its own Node and node-pty. Provider PATH + // discovery must not require either dependency for runtime readiness. const stagedProbe = yield* wslEnv.probeRuntime(runningDistro, runtime.linuxAppRoot); if (stagedProbe.ok) { yield* wslServerTree.cleanupLegacy; diff --git a/apps/desktop/src/wsl/DesktopWslEnvironment.test.ts b/apps/desktop/src/wsl/DesktopWslEnvironment.test.ts index 366bdfce9946c4ddebf925310a4aa244778db41a..e28a9c6c78005326e52943fbffdd1d135268467b 100644 --- a/apps/desktop/src/wsl/DesktopWslEnvironment.test.ts +++ b/apps/desktop/src/wsl/DesktopWslEnvironment.test.ts @@ -15,6 +15,7 @@ import { buildWslRuntimeInstallScript, buildWslRuntimeInvalidateScript, buildWslRuntimePruneScript, + buildWslRuntimeProbeScript, DesktopWslDistroListError, formatMissingToolsReason, parseNodePath, @@ -446,6 +447,75 @@ describe.skipIf(posixShellRunner === null)("WSL runtime install script (executed }; }; + const probeFixture = (fixture: ReturnType) => + runShell( + [ + `export HOME=${sh(`${fixture.work}/home`)}`, + 'export NVM_DIR="$HOME/.nvm" FNM_DIR="$HOME/.fnm" VOLTA_HOME="$HOME/.volta"', + // Isolate login profiles and hide the host's Node/version managers. + // The resolver must discover the fixture's installation itself. + "bash() { (", + " command() {", + ' case "$*" in', + ' "-v node"|"-v mise"|"-v fnm"|"-v nodenv") return 1 ;;', + ' *) builtin command "$@" ;;', + " esac", + " }", + ' eval "$2"', + "); }", + buildWslRuntimeProbeScript(fixture.runtimeRoot), + ].join("\n"), + ); + + it("discovers version-managed Node for providers with a standalone runtime", () => { + const fixture = createFixture(); + expect(fixture.install().status).toBe(0); + const nodeBin = `${fixture.work}/home/.nvm/versions/node/v24.15.0/bin`; + const setup = runShell( + [ + "set -eu", + `mkdir -p ${sh(nodeBin)}`, + `printf '%s' ${sh('#!/bin/sh\nprintf "linux-node-provider\\n"\n')} > ${sh(`${nodeBin}/node`)}`, + `chmod +x ${sh(`${nodeBin}/node`)}`, + ].join("\n"), + ); + expect(setup.status, setup.stderr).toBe(0); + + const probe = probeFixture(fixture); + + expect(probe.status, probe.stderr).toBe(0); + const resolvedPath = parseResolvedPath(probe.stdout); + expect(resolvedPath?.split(":")).toContain(nodeBin); + const provider = runShell(`export PATH=${sh(resolvedPath ?? "")}\nnode provider.js`); + expect(provider.status, provider.stderr).toBe(0); + expect(provider.stdout).toBe("linux-node-provider\n"); + }); + + it("keeps standalone runtime readiness independent of Node availability", () => { + const fixture = createFixture(); + expect(fixture.install().status).toBe(0); + + const probe = probeFixture(fixture); + + expect(probe.status, probe.stderr).toBe(0); + expect(parseResolvedPath(probe.stdout)).not.toBeNull(); + }); + + it("keeps the inherited PATH when bash is unavailable", () => { + const fixture = createFixture(); + expect(fixture.install().status).toBe(0); + const probe = runShell( + [ + "bash() { return 127; }", + 'export PATH="/fixture/bin:/usr/bin:/bin"', + buildWslRuntimeProbeScript(fixture.runtimeRoot), + ].join("\n"), + ); + + expect(probe.status, probe.stderr).toBe(0); + expect(parseResolvedPath(probe.stdout)).toBe("/fixture/bin:/usr/bin:/bin"); + }); + it("reuses a warm cache without touching the archive", () => { const fixture = createFixture(); expect(fixture.install().status).toBe(0); diff --git a/apps/desktop/src/wsl/DesktopWslEnvironment.ts b/apps/desktop/src/wsl/DesktopWslEnvironment.ts index ebd4f853da608e9f00b4a4c793b4c2af13ab7598..95b217c622b1c3e9373f649d73be3329c81db2b8 100644 --- a/apps/desktop/src/wsl/DesktopWslEnvironment.ts +++ b/apps/desktop/src/wsl/DesktopWslEnvironment.ts @@ -122,8 +122,8 @@ export class DesktopWslEnvironment extends Context.Service< // Marks a staged runtime as unusable so the next launch reinstalls it. readonly invalidateRuntime: (distro: string | null, runtimeId: string) => Effect.Effect; // Proves a staged self-contained runtime can run (`/t3 --version`) - // and captures the user's login-shell PATH for the launch. Needs no Node - // in the distro; the mounted server tree still goes through ensureNodePty. + // and resolves the user's PATH, including version-managed Node for provider + // CLIs. Node is optional; the mounted tree still requires ensureNodePty. readonly probeRuntime: ( distro: string | null, linuxAppRoot: string, @@ -545,13 +545,12 @@ require("node-pty"); NODE`; // Readiness proof for a staged self-contained runtime: the executable runs and -// reports its version, and the login shell's PATH is captured for the launch. -// This runs under plain `sh` (no Node resolver preamble, since the runtime -// needs no Node), so the login shell is entered explicitly for the PATH -// capture; a distro without bash falls back to the PATH sh was started with. -const RUNTIME_PROBE_SCRIPT = (linuxAppRoot: string) => +// reports its version. Provider CLIs may still need version-managed Node, so +// resolve it before capturing PATH without requiring it for runtime readiness. +// A distro without bash falls back to the PATH sh was started with. +export const buildWslRuntimeProbeScript = (linuxAppRoot: string) => [ - `bash -lc ${shellQuote(RESOLVED_PATH_LINE)} 2>/dev/null || ${RESOLVED_PATH_LINE}`, + `bash -lc ${shellQuote(`${buildWslNodeEnvPreamble()}${RESOLVED_PATH_LINE}`)} 2>/dev/null || ${RESOLVED_PATH_LINE}`, `${shellQuote(`${linuxAppRoot}/t3`)} --version >/dev/null 2>&1`, ].join("\n"); @@ -676,9 +675,14 @@ const probeWslRuntimeImpl = ( linuxAppRoot: string, ): Effect.Effect => Effect.gen(function* () { - const probe = yield* runWslShell(distro, RUNTIME_PROBE_SCRIPT(linuxAppRoot), PROBE_TIMEOUT, { - resolveNode: false, - }); + const probe = yield* runWslShell( + distro, + buildWslRuntimeProbeScript(linuxAppRoot), + PROBE_TIMEOUT, + { + resolveNode: false, + }, + ); const transportFailureReason = formatWslShellTransportFailureReason( probe.transportFailure, "the staged runtime",