Skip to content
Source
Sign inSign up

shell.test.ts

1,243 lines43.8 KB

1// @effect-diagnostics nodeBuiltinImport:off -- The synchronous shell probe runs real commands with isolated config files.
2import * as NodeServices from "@effect/platform-node/NodeServices";
3import { it as effectIt } from "@effect/vitest";
4import * as HostProcess from "@t3tools/shared/HostProcess";
5import { DesktopHostTelemetryMessage } from "@t3tools/contracts";
6import * as Schema from "effect/Schema";
7import * as Effect from "effect/Effect";
8import * as Fiber from "effect/Fiber";
9import * as FileSystem from "effect/FileSystem";
10import * as Path from "effect/Path";
11import * as Option from "effect/Option";
12import * as TestClock from "effect/testing/TestClock";
13import * as NodeChildProcess from "node:child_process";
14import * as NodeFS from "node:fs";
15import * as NodeOS from "node:os";
16import * as NodePath from "node:path";
17import { describe, expect, it, vi } from "vite-plus/test";
18​
19import {
20 applyShellEnvironment,
21 pathSetup,
22 pathSetupCommand,
23 CommandAvailability,
24 CommandResolutionCache,
25 type CommandAvailabilityChecker,
26 isCommandAvailable,
27 listLoginShellCandidates,
28 mergePathEntries,
29 mergePathValues,
30 npmShimExecutable,
31 preferGitForWindowsBinary,
32 readEnvironmentFromLoginShell,
33 readEnvironmentFromWindowsShell,
34 resolveCommandPath,
35 resolveSpawnCommand,
36 resolveWindowsEnvironment,
37 runsGitWithoutConsole,
38 SpawnExecutableResolution,
39 WindowsShellEnvironment,
40 withPathDirectoryListings,
41 type WindowsShellEnvironmentReader,
42 XcodeGitLocation,
43} from "./shell.ts";
44​
45const decodeDesktopHostMessage = Schema.decodeUnknownSync(DesktopHostTelemetryMessage);
46​
47const nativeProbe = vi.hoisted(() => ({
48 execFile: vi.fn<typeof import("node:child_process").execFile>(),
49 original: undefined as unknown as typeof import("node:child_process").execFile,
50}));
51​
52vi.mock("node:child_process", async (importOriginal) => {
53 const original = await importOriginal<typeof import("node:child_process")>();
54 nativeProbe.original = original.execFile;
55 return { ...original, execFile: nativeProbe.execFile.mockImplementation(original.execFile) };
56});
57​
58const withWindowsEnvironmentMocks = <A, E, R>(
59 effect: Effect.Effect<A, E, R>,
60 readEnvironment: (
61 ...args: Parameters<WindowsShellEnvironmentReader>
62 ) => Partial<Record<string, string>>,
63 commandAvailable: CommandAvailabilityChecker,
64) =>
65 effect.pipe(
66 Effect.provideService(WindowsShellEnvironment, (...args) =>
67 Effect.sync(() => readEnvironment(...args)),
68 ),
69 Effect.provideService(CommandAvailability, commandAvailable),
70 );
71​
72describe("readEnvironmentFromLoginShell", () => {
73 // Opt in with an explicit binary, like the provider live probes; CI need not install Nu.
74 it.runIf(process.env.T3_TEST_NU_PATH !== undefined)(
75 "captures Nushell PATH and multiline values without external tools",
76 () => {
77 const shell = process.env.T3_TEST_NU_PATH!;
78 const home = NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "t3-nu-env-"));
79 try {
80 const toolPath = NodePath.join(home, "nu tools", "bin");
81 const extraToolPath = NodePath.join(home, "extra-tools", "bin");
82 NodeFS.mkdirSync(toolPath, { recursive: true });
83 NodeFS.mkdirSync(extraToolPath, { recursive: true });
84 const config = NodePath.join(home, "config.nu");
85 const envConfig = NodePath.join(home, "env.nu");
86 NodeFS.writeFileSync(envConfig, "");
87 NodeFS.writeFileSync(
88 config,
89 [
90 `$env.PATH = [${JSON.stringify(toolPath)}, "~/extra-tools/bin", "rel/dir", ""]`,
91 '$env.CUSTOM_VAR = " first\\nsecond "',
92 '$env.EMPTY_VAR = ""',
93 'print "startup noise"',
94 ].join("\n"),
95 );
96​
97 expect(
98 readEnvironmentFromLoginShell(
99 shell,
100 ["MISSING_VAR", "PATH", "EMPTY_VAR", "CUSTOM_VAR"],
101 (file, args, options) =>
102 NodeChildProcess.execFileSync(
103 file,
104 ["--no-history", "--config", config, "--env-config", envConfig, ...args],
105 {
106 ...options,
107 env: { HOME: home, XDG_CONFIG_HOME: home, PATH: toolPath },
108 },
109 ),
110 ),
111 ).toEqual({
112 PATH: `${toolPath}:${extraToolPath}:rel/dir:`,
113 CUSTOM_VAR: " first\nsecond ",
114 });
115 } finally {
116 NodeFS.rmSync(home, { recursive: true, force: true });
117 }
118 },
119 );
120​
121 it.runIf(NodeFS.existsSync("/bin/bash"))(
122 "ignores probe markers a bash DEBUG trap echoes into the terminal title",
123 () => {
124 const home = NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "t3-bash-env-"));
125 try {
126 const shellPath = `${NodePath.join(home, "bin")}:/usr/bin:/bin`;
127 const bashEnv = NodePath.join(home, "env.bash");
128 NodeFS.writeFileSync(
129 bashEnv,
130 [
131 `export PATH=${JSON.stringify(shellPath)}`,
132 `trap 'echo -ne "\\033]0;\${BASH_COMMAND}\\007"' DEBUG`,
133 ].join("\n"),
134 );
135​
136 expect(
137 readEnvironmentFromLoginShell(
138 "/bin/bash",
139 ["XDG_DATA_HOME", "PATH"],
140 // Non-interactive bash reads only BASH_ENV, not the host's /etc startup files that -il runs.
141 (file, [, command = ""], options) =>
142 NodeChildProcess.execFileSync(file, ["-c", command], {
143 ...options,
144 env: { HOME: home, PATH: "/usr/bin:/bin", TERM: "dumb", BASH_ENV: bashEnv },
145 }),
146 ),
147 ).toEqual({ PATH: shellPath });
148 } finally {
149 NodeFS.rmSync(home, { recursive: true, force: true });
150 }
151 },
152 );
153​
154 it("extracts multiple environment variables from a login shell command", () => {
155 const execFile = vi.fn<
156 (
157 file: string,
158 args: ReadonlyArray<string>,
159 options: { encoding: "utf8"; timeout: number },
160 ) => string
161 >(() =>
162 [
163 "__T3CODE_ENV_PATH_START__",
164 "/a:/b",
165 "__T3CODE_ENV_PATH_END__",
166 "__T3CODE_ENV_SSH_AUTH_SOCK_START__",
167 "/tmp/secretive.sock",
168 "__T3CODE_ENV_SSH_AUTH_SOCK_END__",
169 ].join("\n"),
170 );
171​
172 expect(readEnvironmentFromLoginShell("/bin/zsh", ["PATH", "SSH_AUTH_SOCK"], execFile)).toEqual({
173 PATH: "/a:/b",
174 SSH_AUTH_SOCK: "/tmp/secretive.sock",
175 });
176 expect(execFile).toHaveBeenCalledTimes(1);
177 expect(execFile).toHaveBeenCalledWith("/bin/zsh", expect.any(Array), {
178 encoding: "utf8",
179 timeout: 5000,
180 killSignal: "SIGKILL",
181 detached: true,
182 stdio: ["ignore", "pipe", "ignore"],
183 });
184 });
185​
186 it("omits environment variables that are missing or empty", () => {
187 const execFile = vi.fn<
188 (
189 file: string,
190 args: ReadonlyArray<string>,
191 options: { encoding: "utf8"; timeout: number },
192 ) => string
193 >(() =>
194 [
195 "__T3CODE_ENV_PATH_START__",
196 "/a:/b",
197 "__T3CODE_ENV_PATH_END__",
198 "__T3CODE_ENV_SSH_AUTH_SOCK_START__",
199 "__T3CODE_ENV_SSH_AUTH_SOCK_END__",
200 ].join("\n"),
201 );
202​
203 expect(readEnvironmentFromLoginShell("/bin/zsh", ["PATH", "SSH_AUTH_SOCK"], execFile)).toEqual({
204 PATH: "/a:/b",
205 });
206 });
207​
208 it("preserves multiline values and surrounding whitespace", () => {
209 const execFile = vi.fn<
210 (
211 file: string,
212 args: ReadonlyArray<string>,
213 options: { encoding: "utf8"; timeout: number },
214 ) => string
215 >(() =>
216 [
217 "__T3CODE_ENV_CUSTOM_VAR_START__",
218 " first\nsecond ",
219 "__T3CODE_ENV_CUSTOM_VAR_END__",
220 ].join("\n"),
221 );
222​
223 expect(readEnvironmentFromLoginShell("/bin/zsh", ["CUSTOM_VAR"], execFile)).toEqual({
224 CUSTOM_VAR: " first\nsecond ",
225 });
226 });
227});
228​
229describe("listLoginShellCandidates", () => {
230 it("returns env shell, user shell, then the platform fallback without duplicates", () => {
231 expect(listLoginShellCandidates("darwin", " /opt/homebrew/bin/nu ", "/bin/zsh")).toEqual([
232 "/opt/homebrew/bin/nu",
233 "/bin/zsh",
234 ]);
235 });
236});
237​
238describe("mergePathEntries", () => {
239 it("prefers login-shell PATH entries and keeps inherited extras", () => {
240 expect(
241 mergePathEntries("/opt/homebrew/bin:/usr/bin", "/Users/test/.local/bin:/usr/bin", "darwin"),
242 ).toBe("/opt/homebrew/bin:/usr/bin:/Users/test/.local/bin");
243 });
244});
245​
246describe("readEnvironmentFromWindowsShell", () => {
247 effectIt.effect("yields while the native shell probe is pending", () =>
248 Effect.gen(function* () {
249 let completed = false;
250 const [environment, completedBeforeProbeReturned] = yield* Effect.all(
251 [
252 readEnvironmentFromWindowsShell(["PATH"]).pipe(
253 Effect.tap(() =>
254 Effect.sync(() => {
255 completed = true;
256 }),
257 ),
258 ),
259 Effect.sync(() => completed),
260 ],
261 { concurrency: 2 },
262 );
263 expect(completedBeforeProbeReturned).toBe(false);
264 expect(environment).toBeTypeOf("object");
265 }),
266 );
267​
268 effectIt.layer(NodeServices.layer)("synchronous spawn failures", (it) => {
269 it.effect("tries both shells and preserves baseline PATH repair", () =>
270 Effect.gen(function* () {
271 const execFile = nativeProbe.execFile;
272 execFile.mockImplementationOnce(() => {
273 throw new Error("spawn EPERM");
274 });
275 execFile.mockImplementationOnce(() => {
276 throw new Error("spawn EINVAL");
277 });
278 try {
279 const patch = yield* resolveWindowsEnvironment({
280 PATH: "C:\\Windows",
281 APPDATA: "C:\\Roaming",
282 }).pipe(Effect.provideService(CommandAvailability, () => Effect.succeed(true)));
283 expect(patch).toEqual({ PATH: "C:\\Windows;C:\\Roaming\\npm" });
284 expect(execFile.mock.calls.slice(-2).map(([file]) => file)).toEqual([
285 "pwsh.exe",
286 "powershell.exe",
287 ]);
288 } finally {
289 execFile.mockClear();
290 }
291 }),
292 );
293 });
294​
295 effectIt.effect("returns an empty environment after both shell candidates fail", () =>
296 Effect.gen(function* () {
297 const execFile = vi.fn(() => Effect.succeed(Option.none<string>()));
298 expect(yield* readEnvironmentFromWindowsShell(["PATH"], {}, execFile)).toEqual({});
299 expect(execFile).toHaveBeenCalledTimes(2);
300 }),
301 );
302​
303 effectIt.effect("does not launch a shell when no variables are requested", () =>
304 Effect.gen(function* () {
305 const execFile = vi.fn(() => Effect.succeed(Option.none<string>()));
306 expect(yield* readEnvironmentFromWindowsShell([], {}, execFile)).toEqual({});
307 expect(execFile).not.toHaveBeenCalled();
308 }),
309 );
310​
311 effectIt.effect(
312 "kills the native probe child on interruption without trying the next shell",
313 () =>
314 Effect.gen(function* () {
315 const started = Promise.withResolvers<void>();
316 const kill = vi.fn(() => true);
317 const child = { kill, unref: vi.fn() } as unknown as ReturnType<
318 typeof import("node:child_process").execFile
319 >;
320 const execFile = nativeProbe.execFile;
321 execFile.mockClear();
322 execFile.mockImplementationOnce(() => {
323 started.resolve();
324 return child;
325 });
326 try {
327 const fiber = yield* readEnvironmentFromWindowsShell(["PATH"]).pipe(Effect.forkChild);
328 yield* Effect.promise(() => started.promise);
329 yield* Fiber.interrupt(fiber);
330 expect(kill).toHaveBeenCalledTimes(1);
331 expect(execFile).toHaveBeenCalledTimes(1);
332 expect(execFile.mock.calls[0]?.[0]).toBe("pwsh.exe");
333 } finally {
334 execFile.mockClear();
335 }
336 }),
337 );
338​
339 effectIt.effect("gives up on a shell that survives the kill and holds stdout open", () =>
340 Effect.gen(function* () {
341 const stdout = { destroy: vi.fn() };
342 const unref = vi.fn();
343 const child = { kill: vi.fn(() => true), stdout, unref } as unknown as ReturnType<
344 typeof import("node:child_process").execFile
345 >;
346 const execFile = nativeProbe.execFile;
347 execFile.mockClear();
348 execFile.mockImplementation(() => child);
349 try {
350 const fiber = yield* readEnvironmentFromWindowsShell(["PATH"]).pipe(Effect.forkChild);
351 yield* Effect.yieldNow;
352 yield* TestClock.adjust("6 seconds");
353 expect(execFile.mock.calls.map(([file]) => file)).toEqual(["pwsh.exe", "powershell.exe"]);
354 yield* TestClock.adjust("6 seconds");
355 expect(yield* Fiber.join(fiber)).toEqual({});
356 expect(stdout.destroy).toHaveBeenCalledTimes(2);
357 expect(unref).toHaveBeenCalledTimes(2);
358 } finally {
359 execFile.mockReset();
360 execFile.mockImplementation(nativeProbe.original);
361 }
362 }),
363 );
364​
365 effectIt.effect("extracts environment variables from a PowerShell command", () =>
366 Effect.gen(function* () {
367 const execFile = vi.fn<
368 (
369 file: string,
370 args: ReadonlyArray<string>,
371 options: { encoding: "utf8"; timeout: number },
372 ) => string
373 >(
374 () =>
375 "__T3CODE_ENV_PATH_START__\nC:\\Users\\testuser\\AppData\\Roaming\\npm\n__T3CODE_ENV_PATH_END__\n",
376 );
377​
378 expect(
379 yield* readEnvironmentFromWindowsShell(["PATH"], {}, (...args) =>
380 Effect.sync(() => Option.liftThrowable(execFile)(...args)),
381 ),
382 ).toEqual({
383 PATH: "C:\\Users\\testuser\\AppData\\Roaming\\npm",
384 });
385 expect(execFile).toHaveBeenCalledWith(
386 "pwsh.exe",
387 expect.arrayContaining(["-NoLogo", "-NoProfile", "-NonInteractive", "-Command"]),
388 { encoding: "utf8", timeout: 5000 },
389 );
390 }),
391 );
392​
393 effectIt.effect("strips CRLF delimiters from captured PowerShell values", () =>
394 Effect.gen(function* () {
395 const execFile = vi.fn<
396 (
397 file: string,
398 args: ReadonlyArray<string>,
399 options: { encoding: "utf8"; timeout: number },
400 ) => string
401 >(
402 () =>
403 "__T3CODE_ENV_FNM_DIR_START__\r\nC:\\Users\\testuser\\AppData\\Roaming\\fnm\r\n__T3CODE_ENV_FNM_DIR_END__\r\n",
404 );
405​
406 expect(
407 yield* readEnvironmentFromWindowsShell(["FNM_DIR"], {}, (...args) =>
408 Effect.sync(() => Option.liftThrowable(execFile)(...args)),
409 ),
410 ).toEqual({
411 FNM_DIR: "C:\\Users\\testuser\\AppData\\Roaming\\fnm",
412 });
413 }),
414 );
415​
416 effectIt.effect("omits -NoProfile when loadProfile is enabled", () =>
417 Effect.gen(function* () {
418 const execFile = vi.fn<
419 (
420 file: string,
421 args: ReadonlyArray<string>,
422 options: { encoding: "utf8"; timeout: number },
423 ) => string
424 >(() => "__T3CODE_ENV_PATH_START__\nC:\\Tools\n__T3CODE_ENV_PATH_END__\n");
425​
426 expect(
427 yield* readEnvironmentFromWindowsShell(["PATH"], { loadProfile: true }, (...args) =>
428 Effect.sync(() => Option.liftThrowable(execFile)(...args)),
429 ),
430 ).toEqual({
431 PATH: "C:\\Tools",
432 });
433 expect(execFile).toHaveBeenCalledWith(
434 "pwsh.exe",
435 expect.arrayContaining(["-NoLogo", "-NonInteractive", "-Command"]),
436 { encoding: "utf8", timeout: 5000 },
437 );
438 expect(execFile.mock.calls[0]?.[1]).not.toContain("-NoProfile");
439 }),
440 );
441​
442 effectIt.effect("falls back to Windows PowerShell when pwsh.exe is unavailable", () =>
443 Effect.gen(function* () {
444 const execFile = vi.fn<
445 (
446 file: string,
447 args: ReadonlyArray<string>,
448 options: { encoding: "utf8"; timeout: number },
449 ) => string
450 >((file) => {
451 if (file === "pwsh.exe") {
452 throw new Error("spawn pwsh.exe ENOENT");
453 }
454 return "__T3CODE_ENV_PATH_START__\nC:\\Tools\n__T3CODE_ENV_PATH_END__\n";
455 });
456​
457 expect(
458 yield* readEnvironmentFromWindowsShell(["PATH"], {}, (...args) =>
459 Effect.sync(() => Option.liftThrowable(execFile)(...args)),
460 ),
461 ).toEqual({
462 PATH: "C:\\Tools",
463 });
464 expect(execFile).toHaveBeenNthCalledWith(1, "pwsh.exe", expect.any(Array), {
465 encoding: "utf8",
466 timeout: 5000,
467 });
468 expect(execFile).toHaveBeenNthCalledWith(2, "powershell.exe", expect.any(Array), {
469 encoding: "utf8",
470 timeout: 5000,
471 });
472 }),
473 );
474});
475​
476describe("mergePathValues", () => {
477 it("sanitizes and dedupes Windows entries while preserving preferred order", () => {
478 expect(
479 mergePathValues(
480 'C:\\Users\\testuser\\AppData\\Roaming\\npm;"C:\\Program Files\\nodejs"',
481 "c:\\users\\testuser\\appdata\\roaming\\npm;C:\\Windows\\System32",
482 "win32",
483 ),
484 ).toBe(
485 "C:\\Users\\testuser\\AppData\\Roaming\\npm;C:\\Program Files\\nodejs;C:\\Windows\\System32",
486 );
487 });
488​
489 it("removes stray quotes from Windows entries", () => {
490 expect(
491 mergePathValues(
492 'C:\\Windows\\System32;C:\\cloudflared.exe;C:";C:\\Program Files\\nodejs',
493 undefined,
494 "win32",
495 ),
496 ).toBe("C:\\Windows\\System32;C:\\cloudflared.exe;C:;C:\\Program Files\\nodejs");
497 });
498​
499 it("dedupes case-sensitively on POSIX", () => {
500 expect(mergePathValues("/usr/local/bin:/usr/bin", "/usr/bin:/USR/BIN", "linux")).toBe(
501 "/usr/local/bin:/usr/bin:/USR/BIN",
502 );
503 });
504});
505​
506effectIt.layer(NodeServices.layer)("resolveCommandPath", (it) => {
507 const recordProbes = (env: NodeJS.ProcessEnv) =>
508 Effect.gen(function* () {
509 const probed: Array<string> = [];
510 const result = yield* resolveCommandPath("definitely-not-installed", { env }).pipe(
511 Effect.provideService(HostProcess.Platform, "win32"),
512 Effect.provideService(CommandResolutionCache, new Map()),
513 Effect.provide(
514 FileSystem.layerNoop({
515 stat: (filePath) =>
516 Effect.sync(() => {
517 probed.push(filePath);
518 return { type: "Directory" } as FileSystem.File.Info;
519 }),
520 }),
521 ),
522 );
523​
524 expect(result).toBeNull();
525 return probed;
526 });
527​
528 it.effect("still visits a PATH entry that differs only in case", () =>
529 Effect.gen(function* () {
530 const probed = yield* recordProbes({
531 PATH: "C:\\bin;C:\\BIN",
532 PATHEXT: ".COM;.EXE",
533 });
534​
535 // Windows PATH directories can be case-sensitive; preserve both spellings.
536 expect(probed).toHaveLength(8);
537 }),
538 );
539​
540 it.effect.each(["audit-command", "audit-command.CMD"])(
541 "resolves lowercase executable files for %s in a case-sensitive Windows PATH directory",
542 (command) =>
543 Effect.gen(function* () {
544 const fs = yield* FileSystem.FileSystem;
545 const path = yield* Path.Path;
546 const cwd = yield* fs.makeTempDirectoryScoped({ prefix: "t3-case-sensitive-path-" });
547 const executable = path.join(cwd, "audit-command.cmd");
548 yield* fs.writeFileString(executable, "@echo off\n");
549​
550 const resolved = yield* resolveCommandPath(command, {
551 env: { PATH: cwd, PATHEXT: ".CMD" },
552 }).pipe(
553 Effect.provideService(HostProcess.Platform, "win32"),
554 Effect.provideService(CommandResolutionCache, new Map()),
555 Effect.provideService(FileSystem.FileSystem, {
556 ...fs,
557 stat: (filePath) =>
558 fs.stat(filePath === executable ? filePath : path.join(cwd, "missing")),
559 }),
560 );
561​
562 expect(resolved).toBe(executable);
563 }),
564 );
565​
566 it.effect("probes only listed PATH names and relists a directory that changes", () =>
567 Effect.gen(function* () {
568 const fs = yield* FileSystem.FileSystem;
569 const path = yield* Path.Path;
570 const first = yield* fs.makeTempDirectoryScoped();
571 const second = yield* fs.makeTempDirectoryScoped();
572 yield* fs.writeFileString(path.join(first, "cursor.CMD"), "");
573 yield* fs.writeFileString(path.join(second, "cursor.EXE"), "");
574 const env = { PATH: `${first};${second}`, PATHEXT: ".EXE;.CMD" };
575 const probed: Array<string> = [];
576 yield* Effect.gen(function* () {
577 expect(yield* resolveCommandPath("cursor", { env })).toBe(path.join(first, "cursor.CMD"));
578 expect(yield* isCommandAvailable("absent", { env })).toBe(false);
579 yield* fs.writeFileString(path.join(second, "late.EXE"), "");
580 yield* fs.utimes(second, 4_102_444_800, 4_102_444_800);
581 expect(yield* resolveCommandPath("late", { env })).toBe(path.join(second, "late.EXE"));
582 }).pipe(
583 withPathDirectoryListings,
584 Effect.provideService(FileSystem.FileSystem, {
585 ...fs,
586 stat: (file) => {
587 if (file !== first && file !== second) probed.push(file);
588 return fs.stat(file);
589 },
590 }),
591 );
592 expect(probed).toEqual([path.join(first, "cursor.CMD"), path.join(second, "late.EXE")]);
593 }).pipe(
594 Effect.provideService(HostProcess.Platform, "win32"),
595 Effect.provideService(CommandResolutionCache, new Map()),
596 ),
597 );
598});
599​
600describe("runsGitWithoutConsole", () => {
601 const git = "C:\\Program Files\\Git\\ucrt64\\bin\\git.exe";
602​
603 it.each([
604 [["rev-parse", "--git-path", "index"], true],
605 [["--git-dir", "C:\\repo\\.git", "symbolic-ref", "refs/remotes/origin/HEAD"], true],
606 [["remote", "get-url", "--", "origin"], true],
607 [["config", "--get", "branch.main.pushRemote"], true],
608 [["symbolic-ref", "--quiet", "HEAD"], true],
609 [["rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{upstream}"], true],
610 [["rev-parse", "HEAD"], false],
611 [["symbolic-ref", "HEAD", "refs/heads/x"], false],
612 [["symbolic-ref", "-qd", "refs/remotes/origin/HEAD"], false],
613 [["config", "--get", "--edit"], false],
614 [["-c", "core.pager=x", "config", "--get", "user.name"], false],
615 ])("Git for Windows %j without a console: %s", (args, expected) => {
616 expect(runsGitWithoutConsole("win32", git, args)).toBe(expected);
617 });
618​
619 it.each([
620 ["win32", "C:\\Program Files\\Git\\cmd\\git.exe"],
621 ["win32", "C:\\Users\\x\\scoop\\shims\\git.exe"],
622 ["linux", git],
623 ] as const)("keeps the console on %s for %s", (platform, executable) => {
624 expect(runsGitWithoutConsole(platform, executable, ["rev-parse", "--show-toplevel"])).toBe(
625 false,
626 );
627 });
628});
629​
630describe("preferGitForWindowsBinary", () => {
631 const files =
632 (...paths: Array<string>) =>
633 (filePath: string) =>
634 paths.includes(filePath);
635​
636 it("runs the git.exe Git for Windows' launcher would start", () => {
637 expect(
638 preferGitForWindowsBinary(
639 "C:\\Program Files\\Git\\cmd\\git.EXE",
640 {},
641 files(
642 "C:\\Program Files\\Git\\ucrt64\\bin\\git.exe",
643 "C:\\Program Files\\Git\\mingw64\\bin\\git.exe",
644 ),
645 ),
646 ).toBe("C:\\Program Files\\Git\\ucrt64\\bin\\git.exe");
647 expect(
648 preferGitForWindowsBinary(
649 "D:\\PortableGit\\bin\\git.exe",
650 {},
651 files("D:\\PortableGit\\mingw64\\bin\\git.exe"),
652 ),
653 ).toBe("D:\\PortableGit\\mingw64\\bin\\git.exe");
654 expect(
655 preferGitForWindowsBinary(
656 "C:\\Program Files\\Git\\cmd\\git.exe",
657 {},
658 files("C:\\Program Files\\Git\\clangarm64\\bin\\git.exe"),
659 ),
660 ).toBe("C:\\Program Files\\Git\\clangarm64\\bin\\git.exe");
661 });
662​
663 it("keeps the launcher when MSYSTEM is set", () => {
664 // git.exe adds hook dependencies to PATH only when MSYSTEM is unset.
665 expect(
666 preferGitForWindowsBinary(
667 "C:\\Program Files\\Git\\cmd\\git.exe",
668 { MSYSTEM: "MINGW64" },
669 files("C:\\Program Files\\Git\\mingw64\\bin\\git.exe"),
670 ),
671 ).toBe("C:\\Program Files\\Git\\cmd\\git.exe");
672 });
673​
674 it("leaves other gits and other launchers alone", () => {
675 const everything = () => true;
676 expect(preferGitForWindowsBinary("C:\\Users\\me\\scoop\\shims\\git.exe", {}, everything)).toBe(
677 "C:\\Users\\me\\scoop\\shims\\git.exe",
678 );
679 expect(preferGitForWindowsBinary("C:\\Program Files\\Git\\cmd\\gitk.exe", {}, everything)).toBe(
680 "C:\\Program Files\\Git\\cmd\\gitk.exe",
681 );
682 });
683});
684​
685// Byte for byte what `npm install -g opencode-ai` writes as `opencode.cmd`.
686const NPM_OPENCODE_SHIM = [
687 "@ECHO off",
688 "GOTO start",
689 ":find_dp0",
690 "SET dp0=%~dp0",
691 "EXIT /b",
692 ":start",
693 "SETLOCAL",
694 "CALL :find_dp0",
695 '"%dp0%\\node_modules\\opencode-ai\\bin\\opencode.exe" %*',
696 "",
697].join("\r\n");
698​
699describe("npmShimExecutable", () => {
700 const shimPath = "C:\\Users\\me\\AppData\\Roaming\\npm\\opencode.cmd";
701 const executable =
702 "C:\\Users\\me\\AppData\\Roaming\\npm\\node_modules\\opencode-ai\\bin\\opencode.exe";
703 const exists = (filePath: string) => filePath === executable;
704​
705 it("finds the executable npm's shim runs", () => {
706 expect(npmShimExecutable(shimPath, () => NPM_OPENCODE_SHIM, exists)).toBe(executable);
707 expect(
708 npmShimExecutable(shimPath.replace(".cmd", ".CMD"), () => NPM_OPENCODE_SHIM, exists),
709 ).toBe(executable);
710 });
711​
712 it("keeps a shim that does more than run the executable", () => {
713 const withSetting = NPM_OPENCODE_SHIM.replace(
714 "SETLOCAL",
715 "SETLOCAL\r\nSET OPENCODE_CONFIG=C:\\x",
716 );
717 expect(npmShimExecutable(shimPath, () => withSetting, exists)).toBeUndefined();
718 const nodeScript = NPM_OPENCODE_SHIM.replace(
719 '"%dp0%\\node_modules\\opencode-ai\\bin\\opencode.exe" %*',
720 '"%_prog%" "%dp0%\\node_modules\\opencode-ai\\bin\\opencode.js" %*',
721 );
722 expect(
723 npmShimExecutable(
724 shimPath,
725 () => nodeScript,
726 () => true,
727 ),
728 ).toBeUndefined();
729 });
730​
731 it("keeps a shim CMD would read differently", () => {
732 // CMD preserves trailing SET whitespace, changing the executable path.
733 for (const suffix of ["\u00a0", "\t", " "]) {
734 const altered = NPM_OPENCODE_SHIM.replace("SET dp0=%~dp0", `SET dp0=%~dp0${suffix}`);
735 expect(
736 npmShimExecutable(
737 shimPath,
738 () => altered,
739 () => true,
740 ),
741 ).toBeUndefined();
742 }
743 const nonBreakingSeparator = NPM_OPENCODE_SHIM.replace(
744 'opencode.exe" %*',
745 'opencode.exe"\u00a0%*',
746 );
747 expect(
748 npmShimExecutable(
749 shimPath,
750 () => nonBreakingSeparator,
751 () => true,
752 ),
753 ).toBeUndefined();
754 const indented = NPM_OPENCODE_SHIM.replace("SETLOCAL", " SETLOCAL");
755 expect(
756 npmShimExecutable(
757 shimPath,
758 () => indented,
759 () => true,
760 ),
761 ).toBeUndefined();
762 });
763​
764 it("keeps the shim when its executable is missing or it is not a .cmd", () => {
765 expect(
766 npmShimExecutable(
767 shimPath,
768 () => NPM_OPENCODE_SHIM,
769 () => false,
770 ),
771 ).toBeUndefined();
772 expect(
773 npmShimExecutable(
774 shimPath,
775 () => undefined,
776 () => true,
777 ),
778 ).toBeUndefined();
779 expect(
780 npmShimExecutable(
781 shimPath.replace(".cmd", ".bat"),
782 () => NPM_OPENCODE_SHIM,
783 () => true,
784 ),
785 ).toBeUndefined();
786 });
787});
788​
789effectIt.layer(NodeServices.layer)("resolveSpawnCommand", (it) => {
790 it.effect("runs Windows executables directly without a shell", () =>
791 Effect.gen(function* () {
792 const command = yield* resolveSpawnCommand("node.exe", ["script.js", "hello & goodbye"], {
793 env: { PATH: "", PATHEXT: ".COM;.EXE;.BAT;.CMD" },
794 }).pipe(
795 Effect.provideService(HostProcess.Platform, "win32"),
796 Effect.provideService(CommandResolutionCache, new Map()),
797 );
798​
799 expect(command).toEqual({
800 command: "node.exe",
801 args: ["script.js", "hello & goodbye"],
802 shell: false,
803 });
804 }),
805 );
806​
807 it.effect.runIf(HostProcess.Platform.defaultValue() === "win32")(
808 "runs the executable behind an npm shim directly when asked",
809 () =>
810 Effect.gen(function* () {
811 const fs = yield* FileSystem.FileSystem;
812 const path = yield* Path.Path;
813 const prefix = yield* fs.makeTempDirectoryScoped({ prefix: "t3-npm-shim-" });
814 const shim = path.join(prefix, "opencode.cmd");
815 const executable = path.join(prefix, "node_modules", "opencode-ai", "bin", "opencode.exe");
816 yield* fs.makeDirectory(path.dirname(executable), { recursive: true });
817 yield* fs.writeFileString(executable, "");
818 yield* fs.writeFileString(shim, NPM_OPENCODE_SHIM);
819 const resolve = (bypassNpmShim: boolean) =>
820 resolveSpawnCommand("opencode", ["serve", "--port=4096"], {
821 env: { PATH: "", PATHEXT: ".COM;.EXE;.BAT;.CMD" },
822 bypassNpmShim,
823 }).pipe(
824 Effect.provideService(HostProcess.Platform, "win32"),
825 Effect.provideService(CommandResolutionCache, new Map()),
826 Effect.provideService(SpawnExecutableResolution, () => shim),
827 );
828​
829 expect(yield* resolve(true)).toEqual({
830 command: executable,
831 args: ["serve", "--port=4096"],
832 shell: false,
833 });
834 expect((yield* resolve(false)).shell).toBe(true);
835 }),
836 );
837​
838 it.effect("builds one escaped cmd.exe line for Windows command shims", () =>
839 Effect.gen(function* () {
840 const command = yield* resolveSpawnCommand(
841 "vp",
842 [
843 "run",
844 "value & calc",
845 "%PATH%",
846 'quote"value',
847 "C:\\dir with space\\",
848 'slash\\"quote',
849 "x|y<z>^",
850 ],
851 { env: { PATH: "", PATHEXT: ".COM;.EXE;.BAT;.CMD" } },
852 ).pipe(
853 Effect.provideService(HostProcess.Platform, "win32"),
854 Effect.provideService(CommandResolutionCache, new Map()),
855 Effect.provideService(
856 SpawnExecutableResolution,
857 () => "C:\\Program Files\\npm & tools\\vp.cmd",
858 ),
859 );
860​
861 // No args alongside `shell: true`: Node prints DEP0190 for that pairing.
862 expect(command).toEqual({
863 command: [
864 String.raw`^"C:\Program^ Files\npm^ ^&^ tools\vp.cmd^"`,
865 String.raw`^"run^"`,
866 String.raw`^"value^ ^&^ calc^"`,
867 String.raw`^"^%PATH^%^"`,
868 String.raw`^"quote\^"value^"`,
869 String.raw`^"C:\dir^ with^ space\\^"`,
870 String.raw`^"slash\\\^"quote^"`,
871 String.raw`^"x^|y^<z^>^^^"`,
872 ].join(" "),
873 args: [],
874 shell: true,
875 });
876 }),
877 );
878​
879 it.effect("resolves against the effective environment when extending host env", () =>
880 Effect.gen(function* () {
881 let resolvedEnvironment: NodeJS.ProcessEnv | undefined;
882 yield* resolveSpawnCommand("codex", ["app-server"], {
883 env: { CODEX_HOME: "C:\\Users\\tester\\.codex" },
884 extendEnv: true,
885 }).pipe(
886 Effect.provideService(HostProcess.Platform, "win32"),
887 Effect.provideService(CommandResolutionCache, new Map()),
888 Effect.provideService(HostProcess.Environment, {
889 PATH: "C:\\Users\\tester\\AppData\\Roaming\\npm",
890 PATHEXT: ".COM;.EXE;.BAT;.CMD",
891 }),
892 Effect.provideService(SpawnExecutableResolution, (_command, _platform, env) => {
893 resolvedEnvironment = env;
894 return "C:\\Users\\tester\\AppData\\Roaming\\npm\\codex.cmd";
895 }),
896 );
897​
898 expect(resolvedEnvironment).toEqual({
899 PATH: "C:\\Users\\tester\\AppData\\Roaming\\npm",
900 PATHEXT: ".COM;.EXE;.BAT;.CMD",
901 CODEX_HOME: "C:\\Users\\tester\\.codex",
902 });
903 }),
904 );
905​
906 describe("macOS git", () => {
907 // Files that exist, the git first on PATH, and what `xcrun --find git` prints.
908 const setup = (options: {
909 readonly platform?: NodeJS.Platform;
910 readonly gitOnPath: string;
911 readonly xcrunGit: string | undefined;
912 }) => {
913 const files = new Set([options.gitOnPath, ...(options.xcrunGit ? [options.xcrunGit] : [])]);
914 const host = { xcrunGit: options.xcrunGit, xcrunCalls: 0 };
915 const resolve = resolveSpawnCommand("git", ["status"], { env: { PATH: "/fake/bin" } }).pipe(
916 Effect.map((resolved) => resolved.command),
917 Effect.provideService(HostProcess.Platform, options.platform ?? "darwin"),
918 Effect.provideService(SpawnExecutableResolution, (command) =>
919 command === "git" ? options.gitOnPath : files.has(command) ? command : undefined,
920 ),
921 Effect.provideService(XcodeGitLocation, () => {
922 host.xcrunCalls += 1;
923 return host.xcrunGit;
924 }),
925 );
926 return { files, host, resolve };
927 };
928​
929 const XCODE_GIT = "/Applications/Xcode.app/Contents/Developer/usr/bin/git";
930​
931 it.effect("runs the git behind the xcrun shim and looks it up once", () =>
932 Effect.gen(function* () {
933 const { host, resolve } = setup({ gitOnPath: "/usr/bin/git", xcrunGit: XCODE_GIT });
934 expect(yield* resolve).toBe(XCODE_GIT);
935 expect(yield* resolve).toBe(XCODE_GIT);
936 expect(host.xcrunCalls).toBe(1);
937 }).pipe(Effect.provideService(CommandResolutionCache, new Map())),
938 );
939​
940 it.effect("keeps git when PATH has another git or the host is not macOS", () =>
941 Effect.gen(function* () {
942 const homebrew = setup({ gitOnPath: "/opt/homebrew/bin/git", xcrunGit: XCODE_GIT });
943 expect(yield* homebrew.resolve).toBe("git");
944 expect(homebrew.host.xcrunCalls).toBe(0);
945​
946 const linux = setup({ platform: "linux", gitOnPath: "/usr/bin/git", xcrunGit: XCODE_GIT });
947 expect(yield* linux.resolve).toBe("git");
948 }).pipe(Effect.provideService(CommandResolutionCache, new Map())),
949 );
950​
951 it.effect("keeps git when xcrun fails or names a missing file", () =>
952 Effect.gen(function* () {
953 const failed = setup({ gitOnPath: "/usr/bin/git", xcrunGit: undefined });
954 expect(yield* failed.resolve).toBe("git");
955​
956 const missing = setup({ gitOnPath: "/usr/bin/git", xcrunGit: XCODE_GIT });
957 missing.files.delete(XCODE_GIT);
958 expect(yield* missing.resolve).toBe("git");
959 }).pipe(Effect.provideService(CommandResolutionCache, new Map())),
960 );
961​
962 it.effect("looks again when the resolved git disappears", () =>
963 Effect.gen(function* () {
964 const betaGit = "/Applications/Xcode-beta.app/Contents/Developer/usr/bin/git";
965 const { files, host, resolve } = setup({ gitOnPath: "/usr/bin/git", xcrunGit: XCODE_GIT });
966 expect(yield* resolve).toBe(XCODE_GIT);
967​
968 files.delete(XCODE_GIT);
969 files.add(betaGit);
970 host.xcrunGit = betaGit;
971 expect(yield* resolve).toBe(betaGit);
972 expect(host.xcrunCalls).toBe(2);
973 }).pipe(Effect.provideService(CommandResolutionCache, new Map())),
974 );
975 });
976​
977 it.effect("does not fall back to a shell for unresolved Windows commands", () =>
978 Effect.gen(function* () {
979 const command = yield* resolveSpawnCommand("missing & calc", ["unsafe & value"], {
980 env: { PATH: "", PATHEXT: ".COM;.EXE;.BAT;.CMD" },
981 }).pipe(
982 Effect.provideService(HostProcess.Platform, "win32"),
983 Effect.provideService(CommandResolutionCache, new Map()),
984 );
985​
986 expect(command).toEqual({
987 command: "missing & calc",
988 args: ["unsafe & value"],
989 shell: false,
990 });
991 }),
992 );
993});
994​
995effectIt.layer(NodeServices.layer)("resolveWindowsEnvironment", (it) => {
996 it.effect("uses known CLI directories as a fallback without changing shell PATH priority", () =>
997 Effect.gen(function* () {
998 const readEnvironment = vi.fn(
999 (_names: ReadonlyArray<string>, options?: { loadProfile?: boolean }) =>
1000 options?.loadProfile
1001 ? { PATH: "C:\\Profile\\Bin" }
1002 : { PATH: "C:\\Shell\\Bin;C:\\Windows\\System32" },
1003 );
1004 const commandAvailable = vi.fn(() => Effect.succeed(true));
1005​
1006 expect(
1007 yield* withWindowsEnvironmentMocks(
1008 resolveWindowsEnvironment({
1009 PATH: "C:\\Windows\\System32",
1010 APPDATA: "C:\\Users\\testuser\\AppData\\Roaming",
1011 LOCALAPPDATA: "C:\\Users\\testuser\\AppData\\Local",
1012 USERPROFILE: "C:\\Users\\testuser",
1013 }),
1014 readEnvironment,
1015 commandAvailable,
1016 ),
1017 ).toEqual({
1018 PATH: [
1019 "C:\\Shell\\Bin",
1020 "C:\\Windows\\System32",
1021 "C:\\Users\\testuser\\AppData\\Roaming\\npm",
1022 "C:\\Users\\testuser\\AppData\\Local\\Programs\\nodejs",
1023 "C:\\Users\\testuser\\AppData\\Local\\Volta\\bin",
1024 "C:\\Users\\testuser\\AppData\\Local\\pnpm",
1025 "C:\\Users\\testuser\\.local\\bin",
1026 "C:\\Users\\testuser\\.bun\\bin",
1027 "C:\\Users\\testuser\\scoop\\shims",
1028 ].join(";"),
1029 });
1030 expect(readEnvironment).toHaveBeenCalledTimes(1);
1031 expect(readEnvironment).toHaveBeenCalledWith(["PATH"], { loadProfile: false });
1032 expect(commandAvailable).toHaveBeenCalledWith(
1033 "node",
1034 expect.objectContaining({ env: expect.any(Object) }),
1035 );
1036 }),
1037 );
1038​
1039 it.effect(
1040 "probes node with one PATH key when inherited environment records have mixed casing",
1041 () =>
1042 Effect.gen(function* () {
1043 const probedEnvironments: Array<NodeJS.ProcessEnv | undefined> = [];
1044​
1045 yield* withWindowsEnvironmentMocks(
1046 resolveWindowsEnvironment({ Path: "C:\\Stale", PATH: "C:\\Windows\\System32" }),
1047 () => ({ PATH: "C:\\Shell\\Bin" }),
1048 (_command, options) =>
1049 Effect.sync(() => {
1050 probedEnvironments.push(options?.env);
1051 return true;
1052 }),
1053 );
1054​
1055 const pathEntries = Object.entries(probedEnvironments[0] ?? {}).filter(
1056 ([key]) => key.toUpperCase() === "PATH",
1057 );
1058 expect(pathEntries).toEqual([["PATH", "C:\\Shell\\Bin;C:\\Windows\\System32"]]);
1059 }),
1060 );
1061​
1062 it.effect("loads the PowerShell profile when baseline env cannot resolve node", () =>
1063 Effect.gen(function* () {
1064 const readEnvironment = vi.fn(
1065 (_names: ReadonlyArray<string>, options?: { loadProfile?: boolean }) =>
1066 options?.loadProfile
1067 ? {
1068 PATH: "C:\\Profile\\Node;C:\\Windows\\System32",
1069 FNM_DIR: "C:\\Users\\testuser\\AppData\\Roaming\\fnm",
1070 FNM_MULTISHELL_PATH: "C:\\Users\\testuser\\AppData\\Local\\fnm_multishells\\123",
1072 : { PATH: "C:\\Shell\\Bin;C:\\Windows\\System32" },
1073 );
1074 const commandAvailable = vi.fn(() => Effect.succeed(false));
1075​
1076 expect(
1077 yield* withWindowsEnvironmentMocks(
1078 resolveWindowsEnvironment({
1079 PATH: "C:\\Windows\\System32",
1080 APPDATA: "C:\\Users\\testuser\\AppData\\Roaming",
1081 LOCALAPPDATA: "C:\\Users\\testuser\\AppData\\Local",
1082 USERPROFILE: "C:\\Users\\testuser",
1083 }),
1084 readEnvironment,
1085 commandAvailable,
1086 ),
1087 ).toEqual({
1088 PATH: [
1089 "C:\\Profile\\Node",
1090 "C:\\Windows\\System32",
1091 "C:\\Shell\\Bin",
1092 "C:\\Users\\testuser\\AppData\\Roaming\\npm",
1093 "C:\\Users\\testuser\\AppData\\Local\\Programs\\nodejs",
1094 "C:\\Users\\testuser\\AppData\\Local\\Volta\\bin",
1095 "C:\\Users\\testuser\\AppData\\Local\\pnpm",
1096 "C:\\Users\\testuser\\.local\\bin",
1097 "C:\\Users\\testuser\\.bun\\bin",
1098 "C:\\Users\\testuser\\scoop\\shims",
1099 ].join(";"),
1100 FNM_DIR: "C:\\Users\\testuser\\AppData\\Roaming\\fnm",
1101 FNM_MULTISHELL_PATH: "C:\\Users\\testuser\\AppData\\Local\\fnm_multishells\\123",
1102 });
1103 expect(readEnvironment).toHaveBeenNthCalledWith(1, ["PATH"], { loadProfile: false });
1104 expect(readEnvironment).toHaveBeenNthCalledWith(
1105 2,
1106 ["PATH", "FNM_DIR", "FNM_MULTISHELL_PATH"],
1108 loadProfile: true,
1109 },
1110 );
1111 expect(commandAvailable).toHaveBeenCalledTimes(1);
1112 }),
1113 );
1114​
1115 it.effect("keeps the baseline env when profiled probe still does not resolve node", () =>
1116 Effect.gen(function* () {
1117 const readEnvironment = vi.fn(
1118 (_names: ReadonlyArray<string>, options?: { loadProfile?: boolean }) =>
1119 options?.loadProfile ? { FNM_DIR: "C:\\Users\\testuser\\AppData\\Roaming\\fnm" } : {},
1120 );
1121 const commandAvailable = vi.fn(() => Effect.succeed(false));
1122​
1123 expect(
1124 yield* withWindowsEnvironmentMocks(
1125 resolveWindowsEnvironment({
1126 PATH: "C:\\Windows\\System32",
1127 APPDATA: "C:\\Users\\testuser\\AppData\\Roaming",
1128 USERPROFILE: "C:\\Users\\testuser",
1129 }),
1130 readEnvironment,
1131 commandAvailable,
1132 ),
1133 ).toEqual({
1134 PATH: [
1135 "C:\\Windows\\System32",
1136 "C:\\Users\\testuser\\AppData\\Roaming\\npm",
1137 "C:\\Users\\testuser\\.local\\bin",
1138 "C:\\Users\\testuser\\.bun\\bin",
1139 "C:\\Users\\testuser\\scoop\\shims",
1140 ].join(";"),
1141 FNM_DIR: "C:\\Users\\testuser\\AppData\\Roaming\\fnm",
1142 });
1143 expect(commandAvailable).toHaveBeenCalledTimes(1);
1144 }),
1145 );
1146});
1147​
1148describe("desktop shell environment updates", () => {
1149 it.each(["win32", "linux", "darwin"] as const)(
1150 "applies shell updates with %s PATH semantics",
1151 (platform) => {
1152 const message = decodeDesktopHostMessage({
1153 version: 1,
1154 type: "shellEnvironment",
1155 discovered: true,
1156 platform,
1157 environment: {
1158 PATH: "C:\\fresh",
1159 FNM_DIR: null,
1160 __MISE_DIFF: null,
1161 SSH_AUTH_SOCK: "/current.sock",
1162 NODE_OPTIONS: "--bad",
1163 },
1164 });
1165 expect(message.type).toBe("shellEnvironment");
1166 if (message.type !== "shellEnvironment") throw new Error("Expected shell environment update");
1167 const env: NodeJS.ProcessEnv = {
1168 Path: "C:\\stale",
1169 FNM_DIR: "stale",
1170 __MISE_DIFF: "stale",
1171 UNRELATED: "keep",
1172 };
1173 applyShellEnvironment(env, message.environment, platform);
1174 expect(env).toEqual({
1175 PATH: "C:\\fresh",
1176 ...(platform === "win32" ? {} : { Path: "C:\\stale" }),
1177 SSH_AUTH_SOCK: "/current.sock",
1178 UNRELATED: "keep",
1179 });
1180 env.Path = "C:\\stale";
1181 env.path = "C:\\other";
1182 applyShellEnvironment(env, { PATH: null, SSH_AUTH_SOCK: null }, platform);
1183 expect(env).toEqual({
1184 ...(platform === "win32" ? {} : { Path: "C:\\stale", path: "C:\\other" }),
1185 UNRELATED: "keep",
1186 });
1187 },
1188 );
1189});
1190​
1191describe("pathSetupCommand", () => {
1192 // Undefined where a pasted line could break things: Windows (it would flatten %VAR% entries in
1193 // the registry PATH), macOS bash (several startup files), other shells, and unsafe paths.
1194 it.each([
1196 "/home/theo/.local/bin",
1197 "/bin/bash",
1198 "linux",
1199 `printf '\\n%s\\n' 'export PATH="$PATH:/home/theo/.local/bin"' >> ~/.bashrc`,
1200 ],
1202 "/Users/theo/.local/bin",
1203 "/usr/bin/fish",
1204 "linux",
1205 `mkdir -p ~/.config/fish && printf '\\n%s\\n' 'fish_add_path --append --path $HOME/.local/bin' >> ~/.config/fish/config.fish`,
1206 ],
1208 "/opt/t3/bin",
1209 "/bin/zsh",
1210 "linux",
1211 `printf '\\n%s\\n' 'export PATH="$PATH:/opt/t3/bin"' >> ~/.zshrc`,
1212 ],
1213 ["/Users/theo/.local/bin", "/bin/tcsh", "darwin", undefined],
1214 ["/Users/theo/.local/bin", "/bin/bash", "darwin", undefined],
1215 ["C:\\Users\\theo\\.local\\bin", "pwsh", "win32", undefined],
1216 ["/Users/theo/it's here", "/bin/zsh", "darwin", undefined],
1217 ["/opt/a$PWD/bin", "/bin/zsh", "linux", undefined],
1218 // Relative: PATH would follow the working directory into any checkout's `bin`.
1219 ["bin", "/bin/zsh", "linux", undefined],
1220 ] as const)("%s with %s on %s", (directory, shell, platform, expected) => {
1221 expect(pathSetupCommand({ directory, home: "/Users/theo", shell, platform })).toBe(expected);
1222 });
1223​
1224 it("writes the startup file fish and zsh read, under XDG_CONFIG_HOME or ZDOTDIR", () => {
1225 const setup = { directory: "/opt/t3/bin", home: "/home/theo", shell: "/usr/bin/fish" } as const;
1226 expect(pathSetup({ ...setup, platform: "linux", configHome: "/home/theo/cfg" })?.file).toBe(
1227 "/home/theo/cfg/fish/config.fish",
1228 );
1229 expect(pathSetup({ ...setup, platform: "linux" })?.file).toBe("~/.config/fish/config.fish");
1230 // zsh reads .zshrc from ZDOTDIR when it is set.
1231 expect(
1232 pathSetup({ ...setup, shell: "/bin/zsh", platform: "darwin", zdotdir: "/Users/theo/.zsh" })
1233 ?.file,
1234 ).toBe("/Users/theo/.zsh/.zshrc");
1235 // A set folder that cannot be written safely means no setup, never the unread default.
1236 expect(
1237 pathSetup({ ...setup, shell: "/bin/zsh", platform: "darwin", zdotdir: "/Users/theo/my zsh" }),
1238 ).toBeUndefined();
1239 expect(
1240 pathSetup({ ...setup, platform: "linux", configHome: "/home/theo/my cfg" }),
1241 ).toBeUndefined();
1242 });
1243});