Open History to see the last commit
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.scoped(
811 Effect.gen(function* () {
812 const fs = yield* FileSystem.FileSystem;
813 const path = yield* Path.Path;
814 const prefix = yield* fs.makeTempDirectoryScoped({ prefix: "t3-npm-shim-" });
815 const shim = path.join(prefix, "opencode.cmd");
816 const executable = path.join(
817 prefix,
818 "node_modules",
819 "opencode-ai",
820 "bin",
821 "opencode.exe",
822 );
823 yield* fs.makeDirectory(path.dirname(executable), { recursive: true });
824 yield* fs.writeFileString(executable, "");
825 yield* fs.writeFileString(shim, NPM_OPENCODE_SHIM);
826 const resolve = (bypassNpmShim: boolean) =>
827 resolveSpawnCommand("opencode", ["serve", "--port=4096"], {
828 env: { PATH: "", PATHEXT: ".COM;.EXE;.BAT;.CMD" },
829 bypassNpmShim,
830 }).pipe(
831 Effect.provideService(HostProcess.Platform, "win32"),
832 Effect.provideService(CommandResolutionCache, new Map()),
833 Effect.provideService(SpawnExecutableResolution, () => shim),
834 );
835
836 expect(yield* resolve(true)).toEqual({
837 command: executable,
838 args: ["serve", "--port=4096"],
839 shell: false,
840 });
841 expect((yield* resolve(false)).shell).toBe(true);
842 }),
843 ),
844 );
845
846 it.effect("builds one escaped cmd.exe line for Windows command shims", () =>
847 Effect.gen(function* () {
848 const command = yield* resolveSpawnCommand(
849 "vp",
850 [
851 "run",
852 "value & calc",
853 "%PATH%",
854 'quote"value',
855 "C:\\dir with space\\",
856 'slash\\"quote',
857 "x|y<z>^",
858 ],
859 { env: { PATH: "", PATHEXT: ".COM;.EXE;.BAT;.CMD" } },
860 ).pipe(
861 Effect.provideService(HostProcess.Platform, "win32"),
862 Effect.provideService(CommandResolutionCache, new Map()),
863 Effect.provideService(
864 SpawnExecutableResolution,
865 () => "C:\\Program Files\\npm & tools\\vp.cmd",
866 ),
867 );
868
869 // No args alongside `shell: true`: Node prints DEP0190 for that pairing.
870 expect(command).toEqual({
871 command: [
872 String.raw`^"C:\Program^ Files\npm^ ^&^ tools\vp.cmd^"`,
873 String.raw`^"run^"`,
874 String.raw`^"value^ ^&^ calc^"`,
875 String.raw`^"^%PATH^%^"`,
876 String.raw`^"quote\^"value^"`,
877 String.raw`^"C:\dir^ with^ space\\^"`,
878 String.raw`^"slash\\\^"quote^"`,
879 String.raw`^"x^|y^<z^>^^^"`,
880 ].join(" "),
881 args: [],
882 shell: true,
883 });
884 }),
885 );
886
887 it.effect("resolves against the effective environment when extending host env", () =>
888 Effect.gen(function* () {
889 let resolvedEnvironment: NodeJS.ProcessEnv | undefined;
890 yield* resolveSpawnCommand("codex", ["app-server"], {
891 env: { CODEX_HOME: "C:\\Users\\tester\\.codex" },
892 extendEnv: true,
893 }).pipe(
894 Effect.provideService(HostProcess.Platform, "win32"),
895 Effect.provideService(CommandResolutionCache, new Map()),
896 Effect.provideService(HostProcess.Environment, {
897 PATH: "C:\\Users\\tester\\AppData\\Roaming\\npm",
898 PATHEXT: ".COM;.EXE;.BAT;.CMD",
899 }),
900 Effect.provideService(SpawnExecutableResolution, (_command, _platform, env) => {
901 resolvedEnvironment = env;
902 return "C:\\Users\\tester\\AppData\\Roaming\\npm\\codex.cmd";
903 }),
904 );
905
906 expect(resolvedEnvironment).toEqual({
907 PATH: "C:\\Users\\tester\\AppData\\Roaming\\npm",
908 PATHEXT: ".COM;.EXE;.BAT;.CMD",
909 CODEX_HOME: "C:\\Users\\tester\\.codex",
910 });
911 }),
912 );
913
914 describe("macOS git", () => {
915 // Files that exist, the git first on PATH, and what `xcrun --find git` prints.
916 const setup = (options: {
917 readonly platform?: NodeJS.Platform;
918 readonly gitOnPath: string;
919 readonly xcrunGit: string | undefined;
920 }) => {
921 const files = new Set([options.gitOnPath, ...(options.xcrunGit ? [options.xcrunGit] : [])]);
922 const host = { xcrunGit: options.xcrunGit, xcrunCalls: 0 };
923 const resolve = resolveSpawnCommand("git", ["status"], { env: { PATH: "/fake/bin" } }).pipe(
924 Effect.map((resolved) => resolved.command),
925 Effect.provideService(HostProcess.Platform, options.platform ?? "darwin"),
926 Effect.provideService(SpawnExecutableResolution, (command) =>
927 command === "git" ? options.gitOnPath : files.has(command) ? command : undefined,
928 ),
929 Effect.provideService(XcodeGitLocation, () => {
930 host.xcrunCalls += 1;
931 return host.xcrunGit;
932 }),
933 );
934 return { files, host, resolve };
935 };
936
937 const XCODE_GIT = "/Applications/Xcode.app/Contents/Developer/usr/bin/git";
938
939 it.effect("runs the git behind the xcrun shim and looks it up once", () =>
940 Effect.gen(function* () {
941 const { host, resolve } = setup({ gitOnPath: "/usr/bin/git", xcrunGit: XCODE_GIT });
942 expect(yield* resolve).toBe(XCODE_GIT);
943 expect(yield* resolve).toBe(XCODE_GIT);
944 expect(host.xcrunCalls).toBe(1);
945 }).pipe(Effect.provideService(CommandResolutionCache, new Map())),
946 );
947
948 it.effect("keeps git when PATH has another git or the host is not macOS", () =>
949 Effect.gen(function* () {
950 const homebrew = setup({ gitOnPath: "/opt/homebrew/bin/git", xcrunGit: XCODE_GIT });
951 expect(yield* homebrew.resolve).toBe("git");
952 expect(homebrew.host.xcrunCalls).toBe(0);
953
954 const linux = setup({ platform: "linux", gitOnPath: "/usr/bin/git", xcrunGit: XCODE_GIT });
955 expect(yield* linux.resolve).toBe("git");
956 }).pipe(Effect.provideService(CommandResolutionCache, new Map())),
957 );
958
959 it.effect("keeps git when xcrun fails or names a missing file", () =>
960 Effect.gen(function* () {
961 const failed = setup({ gitOnPath: "/usr/bin/git", xcrunGit: undefined });
962 expect(yield* failed.resolve).toBe("git");
963
964 const missing = setup({ gitOnPath: "/usr/bin/git", xcrunGit: XCODE_GIT });
965 missing.files.delete(XCODE_GIT);
966 expect(yield* missing.resolve).toBe("git");
967 }).pipe(Effect.provideService(CommandResolutionCache, new Map())),
968 );
969
970 it.effect("looks again when the resolved git disappears", () =>
971 Effect.gen(function* () {
972 const betaGit = "/Applications/Xcode-beta.app/Contents/Developer/usr/bin/git";
973 const { files, host, resolve } = setup({ gitOnPath: "/usr/bin/git", xcrunGit: XCODE_GIT });
974 expect(yield* resolve).toBe(XCODE_GIT);
975
976 files.delete(XCODE_GIT);
977 files.add(betaGit);
978 host.xcrunGit = betaGit;
979 expect(yield* resolve).toBe(betaGit);
980 expect(host.xcrunCalls).toBe(2);
981 }).pipe(Effect.provideService(CommandResolutionCache, new Map())),
982 );
983 });
984
985 it.effect("does not fall back to a shell for unresolved Windows commands", () =>
986 Effect.gen(function* () {
987 const command = yield* resolveSpawnCommand("missing & calc", ["unsafe & value"], {
988 env: { PATH: "", PATHEXT: ".COM;.EXE;.BAT;.CMD" },
989 }).pipe(
990 Effect.provideService(HostProcess.Platform, "win32"),
991 Effect.provideService(CommandResolutionCache, new Map()),
992 );
993
994 expect(command).toEqual({
995 command: "missing & calc",
996 args: ["unsafe & value"],
997 shell: false,
998 });
999 }),
1000 );
1001});
1002
1003effectIt.layer(NodeServices.layer)("resolveWindowsEnvironment", (it) => {
1004 it.effect("uses known CLI directories as a fallback without changing shell PATH priority", () =>
1005 Effect.gen(function* () {
1006 const readEnvironment = vi.fn(
1007 (_names: ReadonlyArray<string>, options?: { loadProfile?: boolean }) =>
1008 options?.loadProfile
1009 ? { PATH: "C:\\Profile\\Bin" }
1010 : { PATH: "C:\\Shell\\Bin;C:\\Windows\\System32" },
1011 );
1012 const commandAvailable = vi.fn(() => Effect.succeed(true));
1013
1014 expect(
1015 yield* withWindowsEnvironmentMocks(
1016 resolveWindowsEnvironment({
1017 PATH: "C:\\Windows\\System32",
1018 APPDATA: "C:\\Users\\testuser\\AppData\\Roaming",
1019 LOCALAPPDATA: "C:\\Users\\testuser\\AppData\\Local",
1020 USERPROFILE: "C:\\Users\\testuser",
1021 }),
1022 readEnvironment,
1023 commandAvailable,
1024 ),
1025 ).toEqual({
1026 PATH: [
1027 "C:\\Shell\\Bin",
1028 "C:\\Windows\\System32",
1029 "C:\\Users\\testuser\\AppData\\Roaming\\npm",
1030 "C:\\Users\\testuser\\AppData\\Local\\Programs\\nodejs",
1031 "C:\\Users\\testuser\\AppData\\Local\\Volta\\bin",
1032 "C:\\Users\\testuser\\AppData\\Local\\pnpm",
1033 "C:\\Users\\testuser\\.local\\bin",
1034 "C:\\Users\\testuser\\.bun\\bin",
1035 "C:\\Users\\testuser\\scoop\\shims",
1036 ].join(";"),
1037 });
1038 expect(readEnvironment).toHaveBeenCalledTimes(1);
1039 expect(readEnvironment).toHaveBeenCalledWith(["PATH"], { loadProfile: false });
1040 expect(commandAvailable).toHaveBeenCalledWith(
1041 "node",
1042 expect.objectContaining({ env: expect.any(Object) }),
1043 );
1044 }),
1045 );
1046
1047 it.effect(
1048 "probes node with one PATH key when inherited environment records have mixed casing",
1049 () =>
1050 Effect.gen(function* () {
1051 const probedEnvironments: Array<NodeJS.ProcessEnv | undefined> = [];
1052
1053 yield* withWindowsEnvironmentMocks(
1054 resolveWindowsEnvironment({ Path: "C:\\Stale", PATH: "C:\\Windows\\System32" }),
1055 () => ({ PATH: "C:\\Shell\\Bin" }),
1056 (_command, options) =>
1057 Effect.sync(() => {
1058 probedEnvironments.push(options?.env);
1059 return true;
1060 }),
1061 );
1062
1063 const pathEntries = Object.entries(probedEnvironments[0] ?? {}).filter(
1064 ([key]) => key.toUpperCase() === "PATH",
1065 );
1066 expect(pathEntries).toEqual([["PATH", "C:\\Shell\\Bin;C:\\Windows\\System32"]]);
1067 }),
1068 );
1069
1070 it.effect("loads the PowerShell profile when baseline env cannot resolve node", () =>
1071 Effect.gen(function* () {
1072 const readEnvironment = vi.fn(
1073 (_names: ReadonlyArray<string>, options?: { loadProfile?: boolean }) =>
1074 options?.loadProfile
1075 ? {
1076 PATH: "C:\\Profile\\Node;C:\\Windows\\System32",
1077 FNM_DIR: "C:\\Users\\testuser\\AppData\\Roaming\\fnm",
1078 FNM_MULTISHELL_PATH: "C:\\Users\\testuser\\AppData\\Local\\fnm_multishells\\123",
1079 }
1080 : { PATH: "C:\\Shell\\Bin;C:\\Windows\\System32" },
1081 );
1082 const commandAvailable = vi.fn(() => Effect.succeed(false));
1083
1084 expect(
1085 yield* withWindowsEnvironmentMocks(
1086 resolveWindowsEnvironment({
1087 PATH: "C:\\Windows\\System32",
1088 APPDATA: "C:\\Users\\testuser\\AppData\\Roaming",
1089 LOCALAPPDATA: "C:\\Users\\testuser\\AppData\\Local",
1090 USERPROFILE: "C:\\Users\\testuser",
1091 }),
1092 readEnvironment,
1093 commandAvailable,
1094 ),
1095 ).toEqual({
1096 PATH: [
1097 "C:\\Profile\\Node",
1098 "C:\\Windows\\System32",
1099 "C:\\Shell\\Bin",
1100 "C:\\Users\\testuser\\AppData\\Roaming\\npm",
1101 "C:\\Users\\testuser\\AppData\\Local\\Programs\\nodejs",
1102 "C:\\Users\\testuser\\AppData\\Local\\Volta\\bin",
1103 "C:\\Users\\testuser\\AppData\\Local\\pnpm",
1104 "C:\\Users\\testuser\\.local\\bin",
1105 "C:\\Users\\testuser\\.bun\\bin",
1106 "C:\\Users\\testuser\\scoop\\shims",
1107 ].join(";"),
1108 FNM_DIR: "C:\\Users\\testuser\\AppData\\Roaming\\fnm",
1109 FNM_MULTISHELL_PATH: "C:\\Users\\testuser\\AppData\\Local\\fnm_multishells\\123",
1110 });
1111 expect(readEnvironment).toHaveBeenNthCalledWith(1, ["PATH"], { loadProfile: false });
1112 expect(readEnvironment).toHaveBeenNthCalledWith(
1113 2,
1114 ["PATH", "FNM_DIR", "FNM_MULTISHELL_PATH"],
1115 {
1116 loadProfile: true,
1117 },
1118 );
1119 expect(commandAvailable).toHaveBeenCalledTimes(1);
1120 }),
1121 );
1122
1123 it.effect("keeps the baseline env when profiled probe still does not resolve node", () =>
1124 Effect.gen(function* () {
1125 const readEnvironment = vi.fn(
1126 (_names: ReadonlyArray<string>, options?: { loadProfile?: boolean }) =>
1127 options?.loadProfile ? { FNM_DIR: "C:\\Users\\testuser\\AppData\\Roaming\\fnm" } : {},
1128 );
1129 const commandAvailable = vi.fn(() => Effect.succeed(false));
1130
1131 expect(
1132 yield* withWindowsEnvironmentMocks(
1133 resolveWindowsEnvironment({
1134 PATH: "C:\\Windows\\System32",
1135 APPDATA: "C:\\Users\\testuser\\AppData\\Roaming",
1136 USERPROFILE: "C:\\Users\\testuser",
1137 }),
1138 readEnvironment,
1139 commandAvailable,
1140 ),
1141 ).toEqual({
1142 PATH: [
1143 "C:\\Windows\\System32",
1144 "C:\\Users\\testuser\\AppData\\Roaming\\npm",
1145 "C:\\Users\\testuser\\.local\\bin",
1146 "C:\\Users\\testuser\\.bun\\bin",
1147 "C:\\Users\\testuser\\scoop\\shims",
1148 ].join(";"),
1149 FNM_DIR: "C:\\Users\\testuser\\AppData\\Roaming\\fnm",
1150 });
1151 expect(commandAvailable).toHaveBeenCalledTimes(1);
1152 }),
1153 );
1154});
1155
1156describe("desktop shell environment updates", () => {
1157 it.each(["win32", "linux", "darwin"] as const)(
1158 "applies shell updates with %s PATH semantics",
1159 (platform) => {
1160 const message = decodeDesktopHostMessage({
1161 version: 1,
1162 type: "shellEnvironment",
1163 discovered: true,
1164 platform,
1165 environment: {
1166 PATH: "C:\\fresh",
1167 FNM_DIR: null,
1168 __MISE_DIFF: null,
1169 SSH_AUTH_SOCK: "/current.sock",
1170 NODE_OPTIONS: "--bad",
1171 },
1172 });
1173 expect(message.type).toBe("shellEnvironment");
1174 if (message.type !== "shellEnvironment") throw new Error("Expected shell environment update");
1175 const env: NodeJS.ProcessEnv = {
1176 Path: "C:\\stale",
1177 FNM_DIR: "stale",
1178 __MISE_DIFF: "stale",
1179 UNRELATED: "keep",
1180 };
1181 applyShellEnvironment(env, message.environment, platform);
1182 expect(env).toEqual({
1183 PATH: "C:\\fresh",
1184 ...(platform === "win32" ? {} : { Path: "C:\\stale" }),
1185 SSH_AUTH_SOCK: "/current.sock",
1186 UNRELATED: "keep",
1187 });
1188 env.Path = "C:\\stale";
1189 env.path = "C:\\other";
1190 applyShellEnvironment(env, { PATH: null, SSH_AUTH_SOCK: null }, platform);
1191 expect(env).toEqual({
1192 ...(platform === "win32" ? {} : { Path: "C:\\stale", path: "C:\\other" }),
1193 UNRELATED: "keep",
1194 });
1195 },
1196 );
1197});
1198
1199describe("pathSetupCommand", () => {
1200 // Undefined where a pasted line could break things: Windows (it would flatten %VAR% entries in
1201 // the registry PATH), macOS bash (several startup files), other shells, and unsafe paths.
1202 it.each([
1203 [
1204 "/home/theo/.local/bin",
1205 "/bin/bash",
1206 "linux",
1207 `printf '\\n%s\\n' 'export PATH="$PATH:/home/theo/.local/bin"' >> ~/.bashrc`,
1208 ],
1209 [
1210 "/Users/theo/.local/bin",
1211 "/usr/bin/fish",
1212 "linux",
1213 `mkdir -p ~/.config/fish && printf '\\n%s\\n' 'fish_add_path --append --path $HOME/.local/bin' >> ~/.config/fish/config.fish`,
1214 ],
1215 [
1216 "/opt/t3/bin",
1217 "/bin/zsh",
1218 "linux",
1219 `printf '\\n%s\\n' 'export PATH="$PATH:/opt/t3/bin"' >> ~/.zshrc`,
1220 ],
1221 ["/Users/theo/.local/bin", "/bin/tcsh", "darwin", undefined],
1222 ["/Users/theo/.local/bin", "/bin/bash", "darwin", undefined],
1223 ["C:\\Users\\theo\\.local\\bin", "pwsh", "win32", undefined],
1224 ["/Users/theo/it's here", "/bin/zsh", "darwin", undefined],
1225 ["/opt/a$PWD/bin", "/bin/zsh", "linux", undefined],
1226 // Relative: PATH would follow the working directory into any checkout's `bin`.
1227 ["bin", "/bin/zsh", "linux", undefined],
1228 ] as const)("%s with %s on %s", (directory, shell, platform, expected) => {
1229 expect(pathSetupCommand({ directory, home: "/Users/theo", shell, platform })).toBe(expected);
1230 });
1231
1232 it("writes the startup file fish and zsh read, under XDG_CONFIG_HOME or ZDOTDIR", () => {
1233 const setup = { directory: "/opt/t3/bin", home: "/home/theo", shell: "/usr/bin/fish" } as const;
1234 expect(pathSetup({ ...setup, platform: "linux", configHome: "/home/theo/cfg" })?.file).toBe(
1235 "/home/theo/cfg/fish/config.fish",
1236 );
1237 expect(pathSetup({ ...setup, platform: "linux" })?.file).toBe("~/.config/fish/config.fish");
1238 // zsh reads .zshrc from ZDOTDIR when it is set.
1239 expect(
1240 pathSetup({ ...setup, shell: "/bin/zsh", platform: "darwin", zdotdir: "/Users/theo/.zsh" })
1241 ?.file,
1242 ).toBe("/Users/theo/.zsh/.zshrc");
1243 // A set folder that cannot be written safely means no setup, never the unread default.
1244 expect(
1245 pathSetup({ ...setup, shell: "/bin/zsh", platform: "darwin", zdotdir: "/Users/theo/my zsh" }),
1246 ).toBeUndefined();
1247 expect(
1248 pathSetup({ ...setup, platform: "linux", configHome: "/home/theo/my cfg" }),
1249 ).toBeUndefined();
1250 });
1251});