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",
1071 }
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"],
1107 {
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([
1195 [
1196 "/home/theo/.local/bin",
1197 "/bin/bash",
1198 "linux",
1199 `printf '\\n%s\\n' 'export PATH="$PATH:/home/theo/.local/bin"' >> ~/.bashrc`,
1200 ],
1201 [
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 ],
1207 [
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});