Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
75 changes: 75 additions & 0 deletions apps/desktop/src/shell/DesktopShellEnvironment.test.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -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";
Expand Down Expand Up @@ -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" };
Expand Down
19 changes: 6 additions & 13 deletions apps/desktop/src/shell/DesktopShellEnvironment.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -231,17 +235,6 @@ const logShellEnvironmentCommandError = (
}),
);

const capturePosixEnvironmentCommand = (names: ReadonlyArray<string>) =>
names
.map((name) => {
return [
`printf '%s\\n' '${startMarker(name)}'`,
`printenv ${name} || true`,
`printf '%s\\n' '${endMarker(name)}'`,
].join("; ");
})
.join("; ");

const captureWindowsEnvironmentCommand = (names: ReadonlyArray<string>) =>
[
"$ErrorActionPreference = 'Stop'",
Expand Down Expand Up @@ -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)));

Expand Down
65 changes: 60 additions & 5 deletions packages/shared/src/shell.test.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -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 {
Expand Down Expand Up @@ -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<
(
Expand Down Expand Up @@ -176,21 +229,23 @@ describe("readEnvironmentFromLoginShell", () => {
});
});

it("preserves surrounding whitespace in captured values", () => {
it("preserves multiline values and surrounding whitespace", () => {
const execFile = vi.fn<
(
file: string,
args: ReadonlyArray<string>,
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 ",
});
});
});
Expand Down
31 changes: 29 additions & 2 deletions packages/shared/src/shell.ts
Original file line number Diff line number Diff line change
Expand Up @@ -206,13 +206,40 @@ function envCaptureEnd(name: string): string {
return `__T3CODE_ENV_${name}_END__`;
}

function buildEnvironmentCaptureCommand(names: ReadonlyArray<string>): string {
export function buildEnvironmentCaptureCommand(
shell: string,
names: ReadonlyArray<string>,
): 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`,
Expand Down Expand Up @@ -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,
Expand Down
Loading