diff --git a/apps/desktop/src/shell/DesktopShellEnvironment.test.ts b/apps/desktop/src/shell/DesktopShellEnvironment.test.ts index 9e50257c9a0c5..6ff500a3eb3e4 100644 --- a/apps/desktop/src/shell/DesktopShellEnvironment.test.ts +++ b/apps/desktop/src/shell/DesktopShellEnvironment.test.ts @@ -1,3 +1,4 @@ +// @effect-diagnostics nodeBuiltinImport:off -- The process fixture runs real shell commands with isolated config files. import * as NodeServices from "@effect/platform-node/NodeServices"; import * as Clock from "effect/Clock"; import { assert, describe, it } from "@effect/vitest"; @@ -12,8 +13,11 @@ import * as Sink from "effect/Sink"; import * as Stream from "effect/Stream"; import * as ChildProcess from "effect/process/ChildProcess"; import * as ChildProcessSpawner from "effect/process/ChildProcessSpawner"; +import * as NodeChildProcess from "node:child_process"; +import * as NodeFS from "node:fs"; import * as TestClock from "effect/testing/TestClock"; import * as NodeOS from "node:os"; +import * as NodePath from "node:path"; import { vi } from "vite-plus/test"; import * as DesktopEnvironment from "../app/DesktopEnvironment.ts"; @@ -130,6 +134,77 @@ function runShellEnvironment(input: { } describe("DesktopShellEnvironment", () => { + // Explicit opt-in keeps real shell coverage independent of globally installed tools. + it.effect.runIf(process.env.T3_TEST_NU_PATH !== undefined)( + "hydrates the desktop through a renamed Nushell symlink with native export conversions", + () => + Effect.gen(function* () { + const home = NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "t3-desktop-nu-")); + try { + const toolPath = NodePath.join(home, "nu tools", "bin"); + const extraToolPath = NodePath.join(home, "extra-tools", "bin"); + const convertedToolPath = NodePath.join(home, "converted-tools", "bin"); + NodeFS.mkdirSync(toolPath, { recursive: true }); + NodeFS.mkdirSync(extraToolPath, { recursive: true }); + NodeFS.mkdirSync(convertedToolPath, { recursive: true }); + const shell = NodePath.join(toolPath, "nushell"); + NodeFS.symlinkSync(process.env.T3_TEST_NU_PATH!, shell); + const config = NodePath.join(home, "config.nu"); + const envConfig = NodePath.join(home, "env.nu"); + NodeFS.writeFileSync(envConfig, ""); + NodeFS.writeFileSync( + config, + [ + `$env.PATH = [${JSON.stringify(toolPath)}, ${JSON.stringify(extraToolPath)}]`, + '$env.SSH_AUTH_SOCK = "/tmp/nu-agent.sock"', + '$env.__MISE_DIFF = " first\\nsecond "', + '$env.HOMEBREW_PREFIX = ""', + "$env.ENV_CONVERSIONS = {", + " PATH: {", + " from_string: {|value| $value | split row (char esep) },", + ' to_string: {|value| $value | str join (char esep) | str replace "nu tools" "converted-tools" }', + " },", + " __MISE_DIFF: { from_string: {|value| $value }, to_string: { str upcase } }", + "}", + 'print "startup noise"', + ].join("\n"), + ); + const env: NodeJS.ProcessEnv = { SHELL: shell, PATH: `/inherited/bin:${extraToolPath}` }; + yield* runShellEnvironment({ + env, + platform: "linux", + handler: (command) => { + if (command._tag !== "StandardCommand" || command.command !== shell) return ""; + const result = NodeChildProcess.spawnSync( + command.command, + ["--no-history", "--config", config, "--env-config", envConfig, ...command.args], + { + encoding: "utf8", + timeout: 5000, + env: { HOME: home, XDG_CONFIG_HOME: home, PATH: toolPath }, + }, + ); + if (result.error) throw result.error; + return { output: result.stdout, exitCode: result.status ?? 1 }; + }, + }); + + assert.deepEqual( + { PATH: env.PATH, __MISE_DIFF: env.__MISE_DIFF }, + { + PATH: `${convertedToolPath}:${extraToolPath}:/inherited/bin`, + __MISE_DIFF: " FIRST\nSECOND ", + }, + ); + assert.equal(env.SSH_AUTH_SOCK, "/tmp/nu-agent.sock"); + assert.isUndefined(env.DISPLAY); + assert.isUndefined(env.HOMEBREW_PREFIX); + } finally { + NodeFS.rmSync(home, { recursive: true, force: true }); + } + }), + ); + it.effect("rejects PATH output from a failed shell and tries the next candidate", () => Effect.gen(function* () { const env: NodeJS.ProcessEnv = { SHELL: "/bin/failing", PATH: "/usr/bin" }; diff --git a/apps/desktop/src/shell/DesktopShellEnvironment.ts b/apps/desktop/src/shell/DesktopShellEnvironment.ts index b033f1cead20f..51b4c1a90863c 100644 --- a/apps/desktop/src/shell/DesktopShellEnvironment.ts +++ b/apps/desktop/src/shell/DesktopShellEnvironment.ts @@ -1,4 +1,8 @@ -import { listLoginShellCandidates, setWindowsPath } from "@t3tools/shared/shell"; +import { + buildEnvironmentCaptureCommand, + listLoginShellCandidates, + setWindowsPath, +} from "@t3tools/shared/shell"; import * as Clock from "effect/Clock"; import * as Context from "effect/Context"; import * as Duration from "effect/Duration"; @@ -231,17 +235,6 @@ const logShellEnvironmentCommandError = ( }), ); -const capturePosixEnvironmentCommand = (names: ReadonlyArray) => - names - .map((name) => { - return [ - `printf '%s\\n' '${startMarker(name)}'`, - `printenv ${name} || true`, - `printf '%s\\n' '${endMarker(name)}'`, - ].join("; "); - }) - .join("; "); - const captureWindowsEnvironmentCommand = (names: ReadonlyArray) => [ "$ErrorActionPreference = 'Stop'", @@ -352,7 +345,7 @@ const readLoginShellEnvironment = ( : runCommandOutput({ probe: "login-shell", command: shell, - args: ["-ilc", capturePosixEnvironmentCommand(names)], + args: ["-ilc", buildEnvironmentCaptureCommand(shell, names)], timeout: LOGIN_SHELL_TIMEOUT, }).pipe(Effect.map((output) => extractEnvironment(output, names))); diff --git a/packages/shared/src/shell.test.ts b/packages/shared/src/shell.test.ts index 8343a58b8aedb..29f4c2794d9e1 100644 --- a/packages/shared/src/shell.test.ts +++ b/packages/shared/src/shell.test.ts @@ -1,3 +1,4 @@ +// @effect-diagnostics nodeBuiltinImport:off -- The synchronous shell probe runs real commands with isolated config files. import * as NodeServices from "@effect/platform-node/NodeServices"; import { it as effectIt } from "@effect/vitest"; import * as HostProcess from "@t3tools/shared/HostProcess"; @@ -8,6 +9,10 @@ import * as Path from "effect/Path"; import * as Result from "effect/Result"; import * as TestClock from "effect/testing/TestClock"; import * as Tracer from "effect/Tracer"; +import * as NodeChildProcess from "node:child_process"; +import * as NodeFS from "node:fs"; +import * as NodeOS from "node:os"; +import * as NodePath from "node:path"; import { describe, expect, it, vi } from "vite-plus/test"; import { @@ -129,6 +134,54 @@ describe("readPathFromLaunchctl", () => { }); describe("readEnvironmentFromLoginShell", () => { + // Opt in with an explicit binary, like the provider live probes; CI need not install Nu. + it.runIf(process.env.T3_TEST_NU_PATH !== undefined)( + "captures Nushell PATH and multiline values without external tools", + () => { + const shell = process.env.T3_TEST_NU_PATH!; + const home = NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "t3-nu-env-")); + try { + const toolPath = NodePath.join(home, "nu tools", "bin"); + const extraToolPath = NodePath.join(home, "extra-tools", "bin"); + NodeFS.mkdirSync(toolPath, { recursive: true }); + NodeFS.mkdirSync(extraToolPath, { recursive: true }); + const config = NodePath.join(home, "config.nu"); + const envConfig = NodePath.join(home, "env.nu"); + NodeFS.writeFileSync(envConfig, ""); + NodeFS.writeFileSync( + config, + [ + `$env.PATH = [${JSON.stringify(toolPath)}, "~/extra-tools/bin", "rel/dir", ""]`, + '$env.CUSTOM_VAR = " first\\nsecond "', + '$env.EMPTY_VAR = ""', + 'print "startup noise"', + ].join("\n"), + ); + + expect( + readEnvironmentFromLoginShell( + shell, + ["MISSING_VAR", "PATH", "EMPTY_VAR", "CUSTOM_VAR"], + (file, args, options) => + NodeChildProcess.execFileSync( + file, + ["--no-history", "--config", config, "--env-config", envConfig, ...args], + { + ...options, + env: { HOME: home, XDG_CONFIG_HOME: home, PATH: toolPath }, + }, + ), + ), + ).toEqual({ + PATH: `${toolPath}:${extraToolPath}:rel/dir:`, + CUSTOM_VAR: " first\nsecond ", + }); + } finally { + NodeFS.rmSync(home, { recursive: true, force: true }); + } + }, + ); + it("extracts multiple environment variables from a login shell command", () => { const execFile = vi.fn< ( @@ -176,7 +229,7 @@ describe("readEnvironmentFromLoginShell", () => { }); }); - it("preserves surrounding whitespace in captured values", () => { + it("preserves multiline values and surrounding whitespace", () => { const execFile = vi.fn< ( file: string, @@ -184,13 +237,15 @@ describe("readEnvironmentFromLoginShell", () => { options: { encoding: "utf8"; timeout: number }, ) => string >(() => - ["__T3CODE_ENV_CUSTOM_VAR_START__", " padded value ", "__T3CODE_ENV_CUSTOM_VAR_END__"].join( - "\n", - ), + [ + "__T3CODE_ENV_CUSTOM_VAR_START__", + " first\nsecond ", + "__T3CODE_ENV_CUSTOM_VAR_END__", + ].join("\n"), ); expect(readEnvironmentFromLoginShell("/bin/zsh", ["CUSTOM_VAR"], execFile)).toEqual({ - CUSTOM_VAR: " padded value ", + CUSTOM_VAR: " first\nsecond ", }); }); }); diff --git a/packages/shared/src/shell.ts b/packages/shared/src/shell.ts index 48155c4337a44..8cac9ce16cc09 100644 --- a/packages/shared/src/shell.ts +++ b/packages/shared/src/shell.ts @@ -206,13 +206,40 @@ function envCaptureEnd(name: string): string { return `__T3CODE_ENV_${name}_END__`; } -function buildEnvironmentCaptureCommand(names: ReadonlyArray): string { +export function buildEnvironmentCaptureCommand( + shell: string, + names: ReadonlyArray, +): string { + const executableName = NodePath.basename(shell).toLowerCase(); + const isNushell = executableName.startsWith("nu"); + return names .map((name) => { if (!SHELL_ENV_NAME_PATTERN.test(name)) { throw new Error(`Unsupported environment variable name: ${name}`); } + if (isNushell) { + // Without a configured conversion, Nu expands home paths only in a list PATH. + const defaultValue = + name === "PATH" + ? "if ($env.PATH? | describe | str starts-with 'list') { " + + "$env.PATH | each {|p| if ($p | str starts-with '~') { " + + "$p | path expand --no-symlink } else { $p } } | str join (char esep) " + + "} else { $env.PATH? | default '' }" + : `$env.${name}? | default ''`; + const value = + `if $env.${name}? == null { '' } else if ` + + `$env.ENV_CONVERSIONS?.${name}?.to_string? != null { ` + + `$env.${name} | do $env.ENV_CONVERSIONS.${name}.to_string $env.${name} ` + + `} else { ${defaultValue} }`; + return [ + `print '${envCaptureStart(name)}'`, + `print (${value})`, + `print '${envCaptureEnd(name)}'`, + ].join("; "); + } + return [ `printf '%s\\n' '${envCaptureStart(name)}'`, `printenv ${name} || true`, @@ -275,7 +302,7 @@ export const readEnvironmentFromLoginShell: ShellEnvironmentReader = ( // Interactive startup loads PATH entries; detaching prevents job control on an // inherited controlling TTY from stopping the probe while the parent waits. - const output = execFile(shell, ["-ilc", buildEnvironmentCaptureCommand(names)], { + const output = execFile(shell, ["-ilc", buildEnvironmentCaptureCommand(shell, names)], { encoding: "utf8", timeout: 5000, detached: true,