1import * as NodeServices from "@effect/platform-node/NodeServices";
2import { it as effectIt } from "@effect/vitest";
3import * as HostProcess from "@t3tools/shared/HostProcess";
4import * as Effect from "effect/Effect";
5import * as Exit from "effect/Exit";
6import * as FileSystem from "effect/FileSystem";
7import * as Path from "effect/Path";
8import * as Result from "effect/Result";
9import * as TestClock from "effect/testing/TestClock";
10import * as Tracer from "effect/Tracer";
11import { describe, expect, it, vi } from "vite-plus/test";
12
13import {
14 CommandAvailability,
15 CommandResolutionCache,
16 type CommandAvailabilityChecker,
17 isCommandAvailable,
18 listLoginShellCandidates,
19 mergePathEntries,
20 mergePathValues,
21 preferGitForWindowsBinary,
22 readEnvironmentFromLoginShell,
23 readEnvironmentFromWindowsShell,
24 readPathFromLaunchctl,
25 resolveCommandPath,
26 resolveKnownWindowsCliDirs,
27 resolveSpawnCommand,
28 resolveWindowsEnvironment,
29 SpawnExecutableResolution,
30 type SpawnExecutableResolver,
31 WindowsShellEnvironment,
32 withPathDirectoryListings,
33 type WindowsShellEnvironmentReader,
34} from "./shell.ts";
35
36const withWindowsEnvironmentMocks = <A, E, R>(
37 effect: Effect.Effect<A, E, R>,
38 readEnvironment: WindowsShellEnvironmentReader,
39 commandAvailable: CommandAvailabilityChecker,
40) =>
41 effect.pipe(
42 Effect.provideService(WindowsShellEnvironment, readEnvironment),
43 Effect.provideService(CommandAvailability, commandAvailable),
44 );
45
46const recordCommandTrace = Effect.fnUntraced(function* <A, E, R>(effect: Effect.Effect<A, E, R>) {
47 const spans: Array<Tracer.NativeSpan> = [];
48 const tracer = Tracer.make({
49 span: (options) => {
50 const span = new Tracer.NativeSpan(options);
51 spans.push(span);
52 return span;
53 },
54 });
55 const result = yield* effect.pipe(Effect.withTracer(tracer), Effect.result);
56 return { result, spans };
57});
58
59describe("readEnvironmentFromLoginShell probe", () => {
60 it("uses a shell-agnostic printenv PATH probe", () => {
61 const execFile = vi.fn<
62 (
63 file: string,
64 args: ReadonlyArray<string>,
65 options: { encoding: "utf8"; timeout: number },
66 ) => string
67 >(() => "__T3CODE_ENV_PATH_START__\n/a:/b\n__T3CODE_ENV_PATH_END__\n");
68
69 expect(readEnvironmentFromLoginShell("/opt/homebrew/bin/fish", ["PATH"], execFile).PATH).toBe(
70 "/a:/b",
71 );
72 expect(execFile).toHaveBeenCalledTimes(1);
73
74 const firstCall = execFile.mock.calls[0] as
75 | [string, ReadonlyArray<string>, { encoding: "utf8"; timeout: number }]
76 | undefined;
77 expect(firstCall).toBeDefined();
78 if (!firstCall) {
79 throw new Error("Expected execFile to be called");
80 }
81
82 const [shell, args, options] = firstCall;
83 expect(shell).toBe("/opt/homebrew/bin/fish");
84 expect(args).toHaveLength(2);
85 expect(args?.[0]).toBe("-ilc");
86 expect(args?.[1]).toContain("printenv PATH || true");
87 expect(args?.[1]).toContain("__T3CODE_ENV_PATH_START__");
88 expect(args?.[1]).toContain("__T3CODE_ENV_PATH_END__");
89 expect(options).toEqual({ encoding: "utf8", timeout: 5000 });
90 });
91});
92
93describe("readPathFromLaunchctl", () => {
94 it("returns a trimmed PATH value from launchctl", () => {
95 const execFile = vi.fn<
96 (
97 file: string,
98 args: ReadonlyArray<string>,
99 options: { encoding: "utf8"; timeout: number },
100 ) => string
101 >(() => " /opt/homebrew/bin:/usr/bin \n");
102
103 expect(readPathFromLaunchctl(execFile)).toBe("/opt/homebrew/bin:/usr/bin");
104 expect(execFile).toHaveBeenCalledWith("/bin/launchctl", ["getenv", "PATH"], {
105 encoding: "utf8",
106 timeout: 2000,
107 });
108 });
109
110 it("returns undefined when launchctl is unavailable", () => {
111 const execFile = vi.fn<
112 (
113 file: string,
114 args: ReadonlyArray<string>,
115 options: { encoding: "utf8"; timeout: number },
116 ) => string
117 >(() => {
118 throw new Error("spawn /bin/launchctl ENOENT");
119 });
120
121 expect(readPathFromLaunchctl(execFile)).toBeUndefined();
122 });
123});
124
125describe("readEnvironmentFromLoginShell", () => {
126 it("extracts multiple environment variables from a login shell command", () => {
127 const execFile = vi.fn<
128 (
129 file: string,
130 args: ReadonlyArray<string>,
131 options: { encoding: "utf8"; timeout: number },
132 ) => string
133 >(() =>
134 [
135 "__T3CODE_ENV_PATH_START__",
136 "/a:/b",
137 "__T3CODE_ENV_PATH_END__",
138 "__T3CODE_ENV_SSH_AUTH_SOCK_START__",
139 "/tmp/secretive.sock",
140 "__T3CODE_ENV_SSH_AUTH_SOCK_END__",
141 ].join("\n"),
142 );
143
144 expect(readEnvironmentFromLoginShell("/bin/zsh", ["PATH", "SSH_AUTH_SOCK"], execFile)).toEqual({
145 PATH: "/a:/b",
146 SSH_AUTH_SOCK: "/tmp/secretive.sock",
147 });
148 expect(execFile).toHaveBeenCalledTimes(1);
149 });
150
151 it("omits environment variables that are missing or empty", () => {
152 const execFile = vi.fn<
153 (
154 file: string,
155 args: ReadonlyArray<string>,
156 options: { encoding: "utf8"; timeout: number },
157 ) => string
158 >(() =>
159 [
160 "__T3CODE_ENV_PATH_START__",
161 "/a:/b",
162 "__T3CODE_ENV_PATH_END__",
163 "__T3CODE_ENV_SSH_AUTH_SOCK_START__",
164 "__T3CODE_ENV_SSH_AUTH_SOCK_END__",
165 ].join("\n"),
166 );
167
168 expect(readEnvironmentFromLoginShell("/bin/zsh", ["PATH", "SSH_AUTH_SOCK"], execFile)).toEqual({
169 PATH: "/a:/b",
170 });
171 });
172
173 it("preserves surrounding whitespace in captured values", () => {
174 const execFile = vi.fn<
175 (
176 file: string,
177 args: ReadonlyArray<string>,
178 options: { encoding: "utf8"; timeout: number },
179 ) => string
180 >(() =>
181 ["__T3CODE_ENV_CUSTOM_VAR_START__", " padded value ", "__T3CODE_ENV_CUSTOM_VAR_END__"].join(
182 "\n",
183 ),
184 );
185
186 expect(readEnvironmentFromLoginShell("/bin/zsh", ["CUSTOM_VAR"], execFile)).toEqual({
187 CUSTOM_VAR: " padded value ",
188 });
189 });
190});
191
192describe("listLoginShellCandidates", () => {
193 it("returns env shell, user shell, then the platform fallback without duplicates", () => {
194 expect(listLoginShellCandidates("darwin", " /opt/homebrew/bin/nu ", "/bin/zsh")).toEqual([
195 "/opt/homebrew/bin/nu",
196 "/bin/zsh",
197 ]);
198 });
199
200 it("falls back to the platform default when no shells are available", () => {
201 expect(listLoginShellCandidates("linux", undefined, "")).toEqual(["/bin/bash"]);
202 });
203});
204
205describe("mergePathEntries", () => {
206 it("prefers login-shell PATH entries and keeps inherited extras", () => {
207 expect(
208 mergePathEntries("/opt/homebrew/bin:/usr/bin", "/Users/test/.local/bin:/usr/bin", "darwin"),
209 ).toBe("/opt/homebrew/bin:/usr/bin:/Users/test/.local/bin");
210 });
211
212 it("uses the platform-specific delimiter", () => {
213 expect(mergePathEntries("C:\\Tools;C:\\Windows", "C:\\Windows;C:\\Git", "win32")).toBe(
214 "C:\\Tools;C:\\Windows;C:\\Git",
215 );
216 });
217});
218
219describe("readEnvironmentFromWindowsShell", () => {
220 it("extracts environment variables from a PowerShell command", () => {
221 const execFile = vi.fn<
222 (
223 file: string,
224 args: ReadonlyArray<string>,
225 options: { encoding: "utf8"; timeout: number },
226 ) => string
227 >(
228 () =>
229 "__T3CODE_ENV_PATH_START__\nC:\\Users\\testuser\\AppData\\Roaming\\npm\n__T3CODE_ENV_PATH_END__\n",
230 );
231
232 expect(readEnvironmentFromWindowsShell(["PATH"], execFile)).toEqual({
233 PATH: "C:\\Users\\testuser\\AppData\\Roaming\\npm",
234 });
235 expect(execFile).toHaveBeenCalledWith(
236 "pwsh.exe",
237 expect.arrayContaining(["-NoLogo", "-NoProfile", "-NonInteractive", "-Command"]),
238 { encoding: "utf8", timeout: 5000 },
239 );
240 });
241
242 it("strips CRLF delimiters from captured PowerShell values", () => {
243 const execFile = vi.fn<
244 (
245 file: string,
246 args: ReadonlyArray<string>,
247 options: { encoding: "utf8"; timeout: number },
248 ) => string
249 >(
250 () =>
251 "__T3CODE_ENV_FNM_DIR_START__\r\nC:\\Users\\testuser\\AppData\\Roaming\\fnm\r\n__T3CODE_ENV_FNM_DIR_END__\r\n",
252 );
253
254 expect(readEnvironmentFromWindowsShell(["FNM_DIR"], execFile)).toEqual({
255 FNM_DIR: "C:\\Users\\testuser\\AppData\\Roaming\\fnm",
256 });
257 });
258
259 it("omits -NoProfile when loadProfile is enabled", () => {
260 const execFile = vi.fn<
261 (
262 file: string,
263 args: ReadonlyArray<string>,
264 options: { encoding: "utf8"; timeout: number },
265 ) => string
266 >(() => "__T3CODE_ENV_PATH_START__\nC:\\Tools\n__T3CODE_ENV_PATH_END__\n");
267
268 expect(readEnvironmentFromWindowsShell(["PATH"], { loadProfile: true }, execFile)).toEqual({
269 PATH: "C:\\Tools",
270 });
271 expect(execFile).toHaveBeenCalledWith(
272 "pwsh.exe",
273 expect.arrayContaining(["-NoLogo", "-NonInteractive", "-Command"]),
274 { encoding: "utf8", timeout: 5000 },
275 );
276 expect(execFile.mock.calls[0]?.[1]).not.toContain("-NoProfile");
277 });
278
279 it("falls back to Windows PowerShell when pwsh.exe is unavailable", () => {
280 const execFile = vi.fn<
281 (
282 file: string,
283 args: ReadonlyArray<string>,
284 options: { encoding: "utf8"; timeout: number },
285 ) => string
286 >((file) => {
287 if (file === "pwsh.exe") {
288 throw new Error("spawn pwsh.exe ENOENT");
289 }
290 return "__T3CODE_ENV_PATH_START__\nC:\\Tools\n__T3CODE_ENV_PATH_END__\n";
291 });
292
293 expect(readEnvironmentFromWindowsShell(["PATH"], execFile)).toEqual({
294 PATH: "C:\\Tools",
295 });
296 expect(execFile).toHaveBeenNthCalledWith(1, "pwsh.exe", expect.any(Array), {
297 encoding: "utf8",
298 timeout: 5000,
299 });
300 expect(execFile).toHaveBeenNthCalledWith(2, "powershell.exe", expect.any(Array), {
301 encoding: "utf8",
302 timeout: 5000,
303 });
304 });
305});
306
307describe("mergePathValues", () => {
308 it("sanitizes and dedupes Windows entries while preserving preferred order", () => {
309 expect(
310 mergePathValues(
311 'C:\\Users\\testuser\\AppData\\Roaming\\npm;"C:\\Program Files\\nodejs"',
312 "c:\\users\\testuser\\appdata\\roaming\\npm;C:\\Windows\\System32",
313 "win32",
314 ),
315 ).toBe(
316 "C:\\Users\\testuser\\AppData\\Roaming\\npm;C:\\Program Files\\nodejs;C:\\Windows\\System32",
317 );
318 });
319
320 it("removes stray quotes from Windows entries", () => {
321 expect(
322 mergePathValues(
323 'C:\\Windows\\System32;C:\\cloudflared.exe;C:";C:\\Program Files\\nodejs',
324 undefined,
325 "win32",
326 ),
327 ).toBe("C:\\Windows\\System32;C:\\cloudflared.exe;C:;C:\\Program Files\\nodejs");
328 });
329
330 it("dedupes case-sensitively on POSIX", () => {
331 expect(mergePathValues("/usr/local/bin:/usr/bin", "/usr/bin:/USR/BIN", "linux")).toBe(
332 "/usr/local/bin:/usr/bin:/USR/BIN",
333 );
334 });
335});
336
337describe("resolveKnownWindowsCliDirs", () => {
338 it("returns known Windows CLI install directories in priority order", () => {
339 expect(
340 resolveKnownWindowsCliDirs({
341 APPDATA: "C:\\Users\\testuser\\AppData\\Roaming",
342 LOCALAPPDATA: "C:\\Users\\testuser\\AppData\\Local",
343 USERPROFILE: "C:\\Users\\testuser",
344 }),
345 ).toEqual([
346 "C:\\Users\\testuser\\AppData\\Roaming\\npm",
347 "C:\\Users\\testuser\\AppData\\Local\\Programs\\nodejs",
348 "C:\\Users\\testuser\\AppData\\Local\\Volta\\bin",
349 "C:\\Users\\testuser\\AppData\\Local\\pnpm",
350 "C:\\Users\\testuser\\.local\\bin",
351 "C:\\Users\\testuser\\.bun\\bin",
352 "C:\\Users\\testuser\\scoop\\shims",
353 ]);
354 });
355});
356
357effectIt.layer(NodeServices.layer)("isCommandAvailable", (it) => {
358 it.effect.each(["win32", "linux"] as const)(
359 "records missing optional commands as successful lookups on %s",
360 (platform) =>
361 Effect.gen(function* () {
362 const fs = yield* FileSystem.FileSystem;
363 const path = yield* Path.Path;
364 const cwd = yield* fs.makeTempDirectoryScoped({ prefix: "t3-command-trace-" });
365 const env = { PATH: cwd, PATHEXT: ".CMD" };
366 const checks = [
367 isCommandAvailable("missing", { env }),
368 isCommandAvailable("missing", { env }),
369 isCommandAvailable("missing", { env: { ...env, PATH: "" } }),
370 isCommandAvailable(path.join(cwd, "missing.cmd"), { env }),
371 ];
372 for (const check of checks) {
373 const { result, spans } = yield* recordCommandTrace(check);
374 expect(Result.getOrThrow(result)).toBe(false);
375 expect(spans.length).toBeGreaterThan(0);
376 for (const span of spans) {
377 expect(span.status._tag).toBe("Ended");
378 if (span.status._tag === "Ended") {
379 expect(Exit.isSuccess(span.status.exit), span.name).toBe(true);
380 }
381 }
382 }
383 }).pipe(
384 Effect.provideService(HostProcess.Platform, platform),
385 Effect.provideService(CommandResolutionCache, new Map()),
386 ),
387 );
388
389 it.effect.each(["win32", "linux"] as const)(
390 "records available commands as successful lookups on %s",
391 (platform) =>
392 Effect.gen(function* () {
393 const fs = yield* FileSystem.FileSystem;
394 const path = yield* Path.Path;
395 const cwd = yield* fs.makeTempDirectoryScoped({ prefix: "t3-command-trace-" });
396 const executable = path.join(cwd, platform === "win32" ? "available.cmd" : "available");
397 yield* fs.writeFileString(executable, "");
398 yield* fs.chmod(executable, 0o700);
399 const env = { PATH: cwd, PATHEXT: ".CMD" };
400 for (const command of ["available", executable]) {
401 const { result, spans } = yield* recordCommandTrace(isCommandAvailable(command, { env }));
402 expect(Result.getOrThrow(result)).toBe(true);
403 expect(spans.length).toBeGreaterThan(0);
404 for (const span of spans) {
405 expect(span.status._tag).toBe("Ended");
406 if (span.status._tag === "Ended") {
407 expect(Exit.isSuccess(span.status.exit), span.name).toBe(true);
408 }
409 }
410 }
411 }).pipe(
412 Effect.provideService(HostProcess.Platform, platform),
413 Effect.provideService(CommandResolutionCache, new Map()),
414 ),
415 );
416
417 it.effect("returns false when PATH is empty", () =>
418 Effect.gen(function* () {
419 expect(
420 yield* isCommandAvailable("definitely-not-installed", {
421 env: { PATH: "", PATHEXT: ".COM;.EXE;.BAT;.CMD" },
422 }).pipe(Effect.provideService(HostProcess.Platform, "win32")),
423 ).toBe(false);
424 }),
425 );
426});
427
428effectIt.layer(NodeServices.layer)("resolveCommandPath", (it) => {
429 it.effect("records a successful lookup when a command is missing", () =>
430 Effect.gen(function* () {
431 const { result, spans } = yield* recordCommandTrace(
432 resolveCommandPath("missing", { env: { PATH: "" } }),
433 );
434 expect(Result.getOrThrow(result)).toBeNull();
435 expect(spans.length).toBeGreaterThan(0);
436 for (const span of spans) {
437 expect(span.status._tag).toBe("Ended");
438 if (span.status._tag === "Ended") {
439 expect(Exit.isSuccess(span.status.exit), span.name).toBe(true);
440 }
441 }
442 }),
443 );
444
445 it.effect("returns null when PATH is empty", () =>
446 Effect.gen(function* () {
447 const result = yield* resolveCommandPath("definitely-not-installed", {
448 env: { PATH: "", PATHEXT: ".COM;.EXE;.BAT;.CMD" },
449 }).pipe(Effect.provideService(HostProcess.Platform, "win32"));
450
451 expect(result).toBeNull();
452 }),
453 );
454
455 // Records every path the scan stats, without ever reporting a match, so the
456 // walk runs to exhaustion and the probe set can be inspected. Assertions
457 // below count probes rather than naming paths: `Path` is the host's, so the
458 // separator differs between a Windows and a Linux CI runner.
459 const recordProbes = (env: NodeJS.ProcessEnv) =>
460 Effect.gen(function* () {
461 const probed: Array<string> = [];
462 const result = yield* resolveCommandPath("definitely-not-installed", { env }).pipe(
463 Effect.provideService(HostProcess.Platform, "win32"),
464 Effect.provideService(CommandResolutionCache, new Map()),
465 Effect.provide(
466 FileSystem.layerNoop({
467 stat: (filePath) =>
468 Effect.sync(() => {
469 probed.push(filePath);
470 return { type: "Directory" } as FileSystem.File.Info;
471 }),
472 }),
473 ),
474 );
475
476 expect(result).toBeNull();
477 return probed;
478 });
479
480 it.effect("visits a repeated PATH directory only once", () =>
481 Effect.gen(function* () {
482 const probed = yield* recordProbes({
483 PATH: "C:\\bin;C:\\other;C:\\bin;C:\\other",
484 PATHEXT: ".COM;.EXE",
485 });
486
487 // Two directories, two extensions, upper and lowercase spellings.
488 expect(probed).toHaveLength(8);
489 expect(new Set(probed).size).toBe(probed.length);
490 }),
491 );
492
493 it.effect("still visits a PATH entry that differs only in case", () =>
494 Effect.gen(function* () {
495 const probed = yield* recordProbes({
496 PATH: "C:\\bin;C:\\BIN",
497 PATHEXT: ".COM;.EXE",
498 });
499
500 // Deliberately not folded together. Windows 10+ can mark a directory
501 // case-sensitive, so the two spellings are not provably one directory and
502 // skipping the second could hide a command that is really there.
503 expect(probed).toHaveLength(8);
504 }),
505 );
506
507 it.effect.each(["audit-command", "audit-command.CMD"])(
508 "resolves lowercase executable files for %s in a case-sensitive Windows PATH directory",
509 (command) =>
510 Effect.gen(function* () {
511 const fs = yield* FileSystem.FileSystem;
512 const path = yield* Path.Path;
513 const cwd = yield* fs.makeTempDirectoryScoped({ prefix: "t3-case-sensitive-path-" });
514 const executable = path.join(cwd, "audit-command.cmd");
515 yield* fs.writeFileString(executable, "@echo off\n");
516
517 const resolved = yield* resolveCommandPath(command, {
518 env: { PATH: cwd, PATHEXT: ".CMD" },
519 }).pipe(
520 Effect.provideService(HostProcess.Platform, "win32"),
521 Effect.provideService(CommandResolutionCache, new Map()),
522 Effect.provideService(FileSystem.FileSystem, {
523 ...fs,
524 // Keep this case-sensitive fixture portable to case-insensitive hosts.
525 stat: (filePath) =>
526 fs.stat(filePath === executable ? filePath : path.join(cwd, "missing")),
527 }),
528 );
529
530 expect(resolved).toBe(executable);
531 }),
532 );
533
534 it.effect("keeps cached misses until expiry while allowing explicit paths", () =>
535 Effect.gen(function* () {
536 const fs = yield* FileSystem.FileSystem;
537 const path = yield* Path.Path;
538 const cwd = yield* fs.makeTempDirectoryScoped({ prefix: "t3-path-cache-" });
539 const executable = path.join(cwd, "appeared.CMD");
540 const options = { env: { PATH: `${cwd};${cwd}`, PATHEXT: ".CMD" } };
541
542 expect(yield* resolveCommandPath("appeared", options)).toBeNull();
543 yield* fs.writeFileString(executable, "@echo off\n");
544 expect(yield* resolveCommandPath("appeared", options)).toBeNull();
545 expect(yield* resolveCommandPath(executable, options)).toBe(executable);
546 yield* TestClock.adjust("30 seconds");
547 expect(yield* resolveCommandPath("appeared", options)).toBe(executable);
548 }).pipe(
549 Effect.provideService(HostProcess.Platform, "win32"),
550 Effect.provideService(CommandResolutionCache, new Map()),
551 ),
552 );
553
554 it.effect("keeps upper and lowercase PATHEXT candidates", () =>
555 Effect.gen(function* () {
556 const probed = yield* recordProbes({
557 PATH: "C:\\bin",
558 PATHEXT: ".COM;.EXE;.BAT;.CMD",
559 });
560
561 expect(probed).toHaveLength(8);
562 expect(probed.filter((filePath) => /\.(COM|EXE|BAT|CMD)$/.test(filePath))).toHaveLength(4);
563 expect(probed.filter((filePath) => /\.(com|exe|bat|cmd)$/.test(filePath))).toHaveLength(4);
564 }),
565 );
566
567 it.effect("probes only listed PATH names and relists a directory that changes", () =>
568 Effect.gen(function* () {
569 const fs = yield* FileSystem.FileSystem;
570 const path = yield* Path.Path;
571 const first = yield* fs.makeTempDirectoryScoped();
572 const second = yield* fs.makeTempDirectoryScoped();
573 yield* fs.writeFileString(path.join(first, "cursor.CMD"), "");
574 yield* fs.writeFileString(path.join(second, "cursor.EXE"), "");
575 const env = { PATH: `${first};${second}`, PATHEXT: ".EXE;.CMD" };
576 const probed: Array<string> = [];
577 yield* Effect.gen(function* () {
578 expect(yield* resolveCommandPath("cursor", { env })).toBe(path.join(first, "cursor.CMD"));
579 expect(yield* isCommandAvailable("absent", { env })).toBe(false);
580 yield* fs.writeFileString(path.join(second, "late.EXE"), "");
581 yield* fs.utimes(second, 4_102_444_800, 4_102_444_800); // seconds: 2100-01-01
582 expect(yield* resolveCommandPath("late", { env })).toBe(path.join(second, "late.EXE"));
583 }).pipe(
584 withPathDirectoryListings,
585 Effect.provideService(FileSystem.FileSystem, {
586 ...fs,
587 stat: (file) => {
588 // Record candidate probes, not the per-lookup directory mtime checks.
589 if (file !== first && file !== second) probed.push(file);
590 return fs.stat(file);
591 },
592 }),
593 );
594 expect(probed).toEqual([path.join(first, "cursor.CMD"), path.join(second, "late.EXE")]);
595 }).pipe(
596 Effect.provideService(HostProcess.Platform, "win32"),
597 Effect.provideService(CommandResolutionCache, new Map()),
598 ),
599 );
600});
601
602describe("preferGitForWindowsBinary", () => {
603 const files =
604 (...paths: Array<string>) =>
605 (filePath: string) =>
606 paths.includes(filePath);
607
608 it("runs the git.exe Git for Windows' launcher would start", () => {
609 // 2.56+ on x64. The PATHEXT scan returns the extension in PATHEXT's case.
610 expect(
611 preferGitForWindowsBinary(
612 "C:\\Program Files\\Git\\cmd\\git.EXE",
613 {},
614 files(
615 "C:\\Program Files\\Git\\ucrt64\\bin\\git.exe",
616 "C:\\Program Files\\Git\\mingw64\\bin\\git.exe",
617 ),
618 ),
619 ).toBe("C:\\Program Files\\Git\\ucrt64\\bin\\git.exe");
620 // Before 2.56, and the portable build's bin launcher.
621 expect(
622 preferGitForWindowsBinary(
623 "D:\\PortableGit\\bin\\git.exe",
624 {},
625 files("D:\\PortableGit\\mingw64\\bin\\git.exe"),
626 ),
627 ).toBe("D:\\PortableGit\\mingw64\\bin\\git.exe");
628 expect(
629 preferGitForWindowsBinary(
630 "C:\\Program Files\\Git\\cmd\\git.exe",
631 {},
632 files("C:\\Program Files\\Git\\clangarm64\\bin\\git.exe"),
633 ),
634 ).toBe("C:\\Program Files\\Git\\clangarm64\\bin\\git.exe");
635 });
636
637 it("keeps the launcher when MSYSTEM is set", () => {
638 // The real git.exe only adds its own folders to PATH when MSYSTEM is unset;
639 // without them a `#!/bin/sh` hook cannot start.
640 expect(
641 preferGitForWindowsBinary(
642 "C:\\Program Files\\Git\\cmd\\git.exe",
643 { MSYSTEM: "MINGW64" },
644 files("C:\\Program Files\\Git\\mingw64\\bin\\git.exe"),
645 ),
646 ).toBe("C:\\Program Files\\Git\\cmd\\git.exe");
647 });
648
649 it("keeps the launcher when no git.exe sits beside it", () => {
650 expect(preferGitForWindowsBinary("C:\\Program Files\\Git\\cmd\\git.exe", {}, () => false)).toBe(
651 "C:\\Program Files\\Git\\cmd\\git.exe",
652 );
653 });
654
655 it("leaves other gits and other launchers alone", () => {
656 const everything = () => true;
657 expect(preferGitForWindowsBinary("C:\\Users\\me\\scoop\\shims\\git.exe", {}, everything)).toBe(
658 "C:\\Users\\me\\scoop\\shims\\git.exe",
659 );
660 expect(preferGitForWindowsBinary("C:\\Program Files\\Git\\cmd\\gitk.exe", {}, everything)).toBe(
661 "C:\\Program Files\\Git\\cmd\\gitk.exe",
662 );
663 });
664});
665
666effectIt.layer(NodeServices.layer)("resolveSpawnCommand", (it) => {
667 it.effect("runs Windows executables directly without a shell", () =>
668 Effect.gen(function* () {
669 const command = yield* resolveSpawnCommand("node.exe", ["script.js", "hello & goodbye"], {
670 env: { PATH: "", PATHEXT: ".COM;.EXE;.BAT;.CMD" },
671 }).pipe(
672 Effect.provideService(HostProcess.Platform, "win32"),
673 Effect.provideService(CommandResolutionCache, new Map()),
674 );
675
676 expect(command).toEqual({
677 command: "node.exe",
678 args: ["script.js", "hello & goodbye"],
679 shell: false,
680 });
681 }),
682 );
683
684 it.effect("builds one escaped cmd.exe line for Windows command shims", () =>
685 Effect.gen(function* () {
686 const command = yield* resolveSpawnCommand(
687 "vp",
688 [
689 "run",
690 "value & calc",
691 "%PATH%",
692 'quote"value',
693 "C:\\dir with space\\",
694 'slash\\"quote',
695 "x|y<z>^",
696 ],
697 { env: { PATH: "", PATHEXT: ".COM;.EXE;.BAT;.CMD" } },
698 ).pipe(
699 Effect.provideService(HostProcess.Platform, "win32"),
700 Effect.provideService(CommandResolutionCache, new Map()),
701 Effect.provideService(
702 SpawnExecutableResolution,
703 () => "C:\\Program Files\\npm & tools\\vp.cmd",
704 ),
705 );
706
707 // No args alongside `shell: true`: Node prints DEP0190 for that pairing.
708 expect(command).toEqual({
709 command: [
710 String.raw`^"C:\Program^ Files\npm^ ^&^ tools\vp.cmd^"`,
711 String.raw`^"run^"`,
712 String.raw`^"value^ ^&^ calc^"`,
713 String.raw`^"^%PATH^%^"`,
714 String.raw`^"quote\^"value^"`,
715 String.raw`^"C:\dir^ with^ space\\^"`,
716 String.raw`^"slash\\\^"quote^"`,
717 String.raw`^"x^|y^<z^>^^^"`,
718 ].join(" "),
719 args: [],
720 shell: true,
721 });
722 }),
723 );
724
725 it.effect("resolves against the effective environment when extending host env", () =>
726 Effect.gen(function* () {
727 let resolvedEnvironment: NodeJS.ProcessEnv | undefined;
728 yield* resolveSpawnCommand("codex", ["app-server"], {
729 env: { CODEX_HOME: "C:\\Users\\tester\\.codex" },
730 extendEnv: true,
731 }).pipe(
732 Effect.provideService(HostProcess.Platform, "win32"),
733 Effect.provideService(CommandResolutionCache, new Map()),
734 Effect.provideService(HostProcess.Environment, {
735 PATH: "C:\\Users\\tester\\AppData\\Roaming\\npm",
736 PATHEXT: ".COM;.EXE;.BAT;.CMD",
737 }),
738 Effect.provideService(SpawnExecutableResolution, (_command, _platform, env) => {
739 resolvedEnvironment = env;
740 return "C:\\Users\\tester\\AppData\\Roaming\\npm\\codex.cmd";
741 }),
742 );
743
744 expect(resolvedEnvironment).toEqual({
745 PATH: "C:\\Users\\tester\\AppData\\Roaming\\npm",
746 PATHEXT: ".COM;.EXE;.BAT;.CMD",
747 CODEX_HOME: "C:\\Users\\tester\\.codex",
748 });
749 }),
750 );
751
752 it.effect("scans PATH once per command until the search environment changes", () =>
753 Effect.gen(function* () {
754 const scans: Array<string> = [];
755 const scan: SpawnExecutableResolver = (name, _platform, env) => {
756 scans.push(`${name}@${env.PATH}`);
757 return name === "missing" ? undefined : `${env.PATH}\\${name}.exe`;
758 };
759 const resolve = (command: string, path: string, resolver = scan) =>
760 resolveSpawnCommand(command, [], { env: { PATH: path, PATHEXT: ".EXE" } }).pipe(
761 Effect.provideService(HostProcess.Platform, "win32"),
762 Effect.provideService(SpawnExecutableResolution, resolver),
763 );
764
765 expect((yield* resolve("git", "C:\\one")).command).toBe("C:\\one\\git.exe");
766 expect((yield* resolve("git", "C:\\one")).command).toBe("C:\\one\\git.exe");
767 // A failed spawn is how a provider reports "not installed", so a miss
768 // must clear the moment the binary appears.
769 yield* resolve("missing", "C:\\one");
770 yield* resolve("missing", "C:\\one");
771 expect((yield* resolve("git", "C:\\two")).command).toBe("C:\\two\\git.exe");
772 // Callers probe explicit paths they may have just written.
773 yield* resolve("C:\\tools\\git.exe", "C:\\one");
774 yield* resolve("C:\\tools\\git.exe", "C:\\one");
775 expect(scans).toEqual([
776 "git@C:\\one",
777 "missing@C:\\one",
778 "missing@C:\\one",
779 "git@C:\\two",
780 "C:\\tools\\git.exe@C:\\one",
781 "C:\\tools\\git.exe@C:\\one",
782 ]);
783
784 yield* TestClock.adjust("31 seconds");
785 yield* resolve("git", "C:\\one");
786 expect(scans).toHaveLength(7);
787
788 // Another resolver sharing the cache gets its own answer, not the cached one.
789 const elsewhere = yield* resolve("git", "C:\\one", () => "D:\\elsewhere\\git.exe");
790 expect(elsewhere.command).toBe("D:\\elsewhere\\git.exe");
791 expect((yield* resolve("git", "C:\\one")).command).toBe("C:\\one\\git.exe");
792 expect(scans).toHaveLength(7);
793 }).pipe(Effect.provideService(CommandResolutionCache, new Map())),
794 );
795
796 it.effect("keeps launcher swaps apart for environments with and without MSYSTEM", () =>
797 Effect.gen(function* () {
798 let scans = 0;
799 const scan: SpawnExecutableResolver = () => {
800 scans++;
801 return "C:\\Git\\cmd\\git.exe";
802 };
803 const resolve = (env: NodeJS.ProcessEnv) =>
804 resolveSpawnCommand("git", [], {
805 env: { PATH: "C:\\Git\\cmd", PATHEXT: ".EXE", ...env },
806 }).pipe(
807 Effect.provideService(HostProcess.Platform, "win32"),
808 Effect.provideService(SpawnExecutableResolution, scan),
809 );
810
811 yield* resolve({});
812 yield* resolve({});
813 expect(scans).toBe(1);
814 // A swap made without MSYSTEM must not reach a child that has it set.
815 yield* resolve({ MSYSTEM: "MINGW64" });
816 expect(scans).toBe(2);
817 }).pipe(Effect.provideService(CommandResolutionCache, new Map())),
818 );
819
820 it.effect("does not fall back to a shell for unresolved Windows commands", () =>
821 Effect.gen(function* () {
822 const command = yield* resolveSpawnCommand("missing & calc", ["unsafe & value"], {
823 env: { PATH: "", PATHEXT: ".COM;.EXE;.BAT;.CMD" },
824 }).pipe(
825 Effect.provideService(HostProcess.Platform, "win32"),
826 Effect.provideService(CommandResolutionCache, new Map()),
827 );
828
829 expect(command).toEqual({
830 command: "missing & calc",
831 args: ["unsafe & value"],
832 shell: false,
833 });
834 }),
835 );
836});
837
838effectIt.layer(NodeServices.layer)("resolveWindowsEnvironment", (it) => {
839 it.effect("uses known CLI directories as a fallback without changing shell PATH priority", () =>
840 Effect.gen(function* () {
841 const readEnvironment = vi.fn(
842 (_names: ReadonlyArray<string>, options?: { loadProfile?: boolean }) =>
843 options?.loadProfile
844 ? { PATH: "C:\\Profile\\Bin" }
845 : { PATH: "C:\\Shell\\Bin;C:\\Windows\\System32" },
846 );
847 const commandAvailable = vi.fn(() => Effect.succeed(true));
848
849 expect(
850 yield* withWindowsEnvironmentMocks(
851 resolveWindowsEnvironment({
852 PATH: "C:\\Windows\\System32",
853 APPDATA: "C:\\Users\\testuser\\AppData\\Roaming",
854 LOCALAPPDATA: "C:\\Users\\testuser\\AppData\\Local",
855 USERPROFILE: "C:\\Users\\testuser",
856 }),
857 readEnvironment,
858 commandAvailable,
859 ),
860 ).toEqual({
861 PATH: [
862 "C:\\Shell\\Bin",
863 "C:\\Windows\\System32",
864 "C:\\Users\\testuser\\AppData\\Roaming\\npm",
865 "C:\\Users\\testuser\\AppData\\Local\\Programs\\nodejs",
866 "C:\\Users\\testuser\\AppData\\Local\\Volta\\bin",
867 "C:\\Users\\testuser\\AppData\\Local\\pnpm",
868 "C:\\Users\\testuser\\.local\\bin",
869 "C:\\Users\\testuser\\.bun\\bin",
870 "C:\\Users\\testuser\\scoop\\shims",
871 ].join(";"),
872 });
873 expect(readEnvironment).toHaveBeenCalledTimes(1);
874 expect(readEnvironment).toHaveBeenCalledWith(["PATH"], { loadProfile: false });
875 expect(commandAvailable).toHaveBeenCalledWith(
876 "node",
877 expect.objectContaining({ env: expect.any(Object) }),
878 );
879 }),
880 );
881
882 it.effect("loads the PowerShell profile when baseline env cannot resolve node", () =>
883 Effect.gen(function* () {
884 const readEnvironment = vi.fn(
885 (_names: ReadonlyArray<string>, options?: { loadProfile?: boolean }) =>
886 options?.loadProfile
887 ? {
888 PATH: "C:\\Profile\\Node;C:\\Windows\\System32",
889 FNM_DIR: "C:\\Users\\testuser\\AppData\\Roaming\\fnm",
890 FNM_MULTISHELL_PATH: "C:\\Users\\testuser\\AppData\\Local\\fnm_multishells\\123",
891 }
892 : { PATH: "C:\\Shell\\Bin;C:\\Windows\\System32" },
893 );
894 const commandAvailable = vi.fn(() => Effect.succeed(false));
895
896 expect(
897 yield* withWindowsEnvironmentMocks(
898 resolveWindowsEnvironment({
899 PATH: "C:\\Windows\\System32",
900 APPDATA: "C:\\Users\\testuser\\AppData\\Roaming",
901 LOCALAPPDATA: "C:\\Users\\testuser\\AppData\\Local",
902 USERPROFILE: "C:\\Users\\testuser",
903 }),
904 readEnvironment,
905 commandAvailable,
906 ),
907 ).toEqual({
908 PATH: [
909 "C:\\Profile\\Node",
910 "C:\\Windows\\System32",
911 "C:\\Shell\\Bin",
912 "C:\\Users\\testuser\\AppData\\Roaming\\npm",
913 "C:\\Users\\testuser\\AppData\\Local\\Programs\\nodejs",
914 "C:\\Users\\testuser\\AppData\\Local\\Volta\\bin",
915 "C:\\Users\\testuser\\AppData\\Local\\pnpm",
916 "C:\\Users\\testuser\\.local\\bin",
917 "C:\\Users\\testuser\\.bun\\bin",
918 "C:\\Users\\testuser\\scoop\\shims",
919 ].join(";"),
920 FNM_DIR: "C:\\Users\\testuser\\AppData\\Roaming\\fnm",
921 FNM_MULTISHELL_PATH: "C:\\Users\\testuser\\AppData\\Local\\fnm_multishells\\123",
922 });
923 expect(readEnvironment).toHaveBeenNthCalledWith(1, ["PATH"], { loadProfile: false });
924 expect(readEnvironment).toHaveBeenNthCalledWith(
925 2,
926 ["PATH", "FNM_DIR", "FNM_MULTISHELL_PATH"],
927 {
928 loadProfile: true,
929 },
930 );
931 expect(commandAvailable).toHaveBeenCalledTimes(1);
932 }),
933 );
934
935 it.effect("keeps the baseline env when profiled probe still does not resolve node", () =>
936 Effect.gen(function* () {
937 const readEnvironment = vi.fn(
938 (_names: ReadonlyArray<string>, options?: { loadProfile?: boolean }) =>
939 options?.loadProfile ? { FNM_DIR: "C:\\Users\\testuser\\AppData\\Roaming\\fnm" } : {},
940 );
941 const commandAvailable = vi.fn(() => Effect.succeed(false));
942
943 expect(
944 yield* withWindowsEnvironmentMocks(
945 resolveWindowsEnvironment({
946 PATH: "C:\\Windows\\System32",
947 APPDATA: "C:\\Users\\testuser\\AppData\\Roaming",
948 USERPROFILE: "C:\\Users\\testuser",
949 }),
950 readEnvironment,
951 commandAvailable,
952 ),
953 ).toEqual({
954 PATH: [
955 "C:\\Windows\\System32",
956 "C:\\Users\\testuser\\AppData\\Roaming\\npm",
957 "C:\\Users\\testuser\\.local\\bin",
958 "C:\\Users\\testuser\\.bun\\bin",
959 "C:\\Users\\testuser\\scoop\\shims",
960 ].join(";"),
961 FNM_DIR: "C:\\Users\\testuser\\AppData\\Roaming\\fnm",
962 });
963 expect(commandAvailable).toHaveBeenCalledTimes(1);
964 }),
965 );
966});