1// @effect-diagnostics nodeBuiltinImport:off -- The process fixture runs real shell commands with isolated config files.
2import * as NodeServices from "@effect/platform-node/NodeServices";
3import { assert, describe, it } from "@effect/vitest";
4import * as HostProcess from "@t3tools/shared/HostProcess";
5import * as Effect from "effect/Effect";
6import * as Fiber from "effect/Fiber";
7import * as Deferred from "effect/Deferred";
8import * as FileSystem from "effect/FileSystem";
9import * as Layer from "effect/Layer";
10import * as Logger from "effect/Logger";
11import * as PlatformError from "effect/PlatformError";
12import * as Sink from "effect/Sink";
13import * as Stream from "effect/Stream";
14import * as Schema from "effect/Schema";
15import * as Option from "effect/Option";
16import * as ChildProcess from "effect/process/ChildProcess";
17import * as ChildProcessSpawner from "effect/process/ChildProcessSpawner";
18import * as TestClock from "effect/testing/TestClock";
19import * as NodeOS from "node:os";
20import { vi } from "vite-plus/test";
21
22import * as DesktopEnvironment from "../app/DesktopEnvironment.ts";
23import * as DesktopShellEnvironment from "./DesktopShellEnvironment.ts";
24
25vi.mock("node:os", { spy: true });
26
27const textEncoder = new TextEncoder();
28const cacheCodec = Schema.fromJsonString(
29 Schema.Struct({
30 version: Schema.Number,
31 key: Schema.String,
32 environment: Schema.Record(Schema.String, Schema.NullOr(Schema.String)),
33 }),
34);
35const encodeCache = Schema.encodeSync(cacheCodec);
36const decodeCache = Schema.decodeUnknownEffect(cacheCodec);
37
38function envOutput(values: Readonly<Record<string, string>>): string {
39 return Object.entries(values)
40 .flatMap(([name, value]) => [
41 `__T3CODE_ENV_${name}_START__`,
42 value,
43 `__T3CODE_ENV_${name}_END__`,
44 ])
45 .join("\n");
46}
47
48function makeProcess(output: string, exitCode = 0): ChildProcessSpawner.ChildProcessHandle {
49 const stdout = output.length === 0 ? Stream.empty : Stream.make(textEncoder.encode(output));
50 return ChildProcessSpawner.makeHandle({
51 pid: ChildProcessSpawner.ProcessId(123),
52 stdout,
53 stderr: Stream.empty,
54 all: stdout,
55 exitCode: Effect.succeed(ChildProcessSpawner.ExitCode(exitCode)),
56 isRunning: Effect.succeed(false),
57 kill: () => Effect.void,
58 stdin: Sink.drain,
59 getInputFd: () => Sink.drain,
60 getOutputFd: () => Stream.empty,
61 unref: Effect.succeed(Effect.void),
62 });
63}
64
65function makeHungProcess(): ChildProcessSpawner.ChildProcessHandle {
66 return ChildProcessSpawner.makeHandle({
67 pid: ChildProcessSpawner.ProcessId(124),
68 stdout: Stream.never,
69 stderr: Stream.never,
70 all: Stream.never,
71 exitCode: Effect.never,
72 isRunning: Effect.succeed(true),
73 kill: () => Effect.never,
74 stdin: Sink.drain,
75 getInputFd: () => Sink.drain,
76 getOutputFd: () => Stream.empty,
77 unref: Effect.succeed(Effect.void),
78 });
79}
80
81function withProcessEnv<A, E, R>(
82 env: NodeJS.ProcessEnv,
83 effect: Effect.Effect<A, E, R>,
84): Effect.Effect<A, E, R> {
85 return Effect.acquireUseRelease(
86 Effect.sync(() => {
87 const previous = process.env;
88 process.env = env;
89 return previous;
90 }),
91 () => effect,
92 (previous) =>
93 Effect.sync(() => {
94 process.env = previous;
95 }),
96 );
97}
98
99function runShellEnvironment(input: {
100 readonly env: NodeJS.ProcessEnv;
101 readonly platform: NodeJS.Platform;
102 readonly userShell?: string;
103 readonly stateDir?: string;
104 readonly use?: (
105 service: DesktopShellEnvironment.DesktopShellEnvironment["Service"],
106 ) => Effect.Effect<boolean, never, import("effect/Scope").Scope>;
107 readonly handler: (
108 command: ChildProcess.Command,
109 ) => string | { output: string; exitCode: number };
110 readonly failure?: PlatformError.PlatformError;
111 readonly processSpawner?: ChildProcessSpawner.ChildProcessSpawner["Service"];
112}) {
113 const layerEnvironment = Layer.effect(
114 DesktopEnvironment.DesktopEnvironment,
115 Effect.gen(function* () {
116 const fs = yield* FileSystem.FileSystem;
117 const stateDir = input.stateDir ?? (yield* fs.makeTempDirectoryScoped());
118 return DesktopEnvironment.DesktopEnvironment.of({
119 platform: input.platform,
120 stateDir,
121 homeDirectory: "/test-home",
122 } as DesktopEnvironment.DesktopEnvironment["Service"]);
123 }),
124 ).pipe(Layer.provide(NodeServices.layer));
125 const layerSpawner = Layer.succeed(
126 ChildProcessSpawner.ChildProcessSpawner,
127 ChildProcessSpawner.make((command) => {
128 if (input.processSpawner) return input.processSpawner.spawn(command);
129 if (input.failure !== undefined) return Effect.fail(input.failure);
130 const result = input.handler(command);
131 return Effect.succeed(
132 typeof result === "string"
133 ? makeProcess(result)
134 : makeProcess(result.output, result.exitCode),
135 );
136 }),
137 );
138
139 const program = Effect.gen(function* () {
140 const shellEnvironment = yield* DesktopShellEnvironment.DesktopShellEnvironment;
141 if (input.use) return yield* input.use(shellEnvironment);
142 const prepared = yield* shellEnvironment.awaitDiscovery;
143 assert.equal(yield* shellEnvironment.awaitDiscovery, prepared);
144 return prepared;
145 }).pipe(
146 Effect.provide(
147 DesktopShellEnvironment.layer.pipe(
148 Layer.provide(Layer.mergeAll(layerEnvironment, NodeServices.layer, layerSpawner)),
149 ),
150 ),
151 );
152
153 return Effect.acquireUseRelease(
154 Effect.sync(() =>
155 vi.spyOn(NodeOS, "userInfo").mockReturnValue({
156 ...NodeOS.userInfo(),
157 shell: input.userShell ?? null,
158 }),
159 ),
160 () => withProcessEnv(input.env, program),
161 (spy) => Effect.sync(() => spy.mockRestore()),
162 );
163}
164
165describe("DesktopShellEnvironment", () => {
166 it.effect("does not wait for discovery when the cache is missing", () =>
167 Effect.gen(function* () {
168 const started = yield* Deferred.make<void>();
169 const release = yield* Deferred.make<void>();
170 yield* runShellEnvironment({
171 env: { SHELL: "/test-shell", PATH: "/usr/bin" },
172 platform: "darwin",
173 handler: () => "",
174 processSpawner: ChildProcessSpawner.make(() =>
175 Deferred.succeed(started, undefined).pipe(
176 Effect.andThen(Deferred.await(release)),
177 Effect.as(makeProcess(envOutput({ PATH: "/fresh/bin" }))),
178 ),
179 ),
180 use: (service) =>
181 Effect.gen(function* () {
182 const startup = yield* service.installIntoProcess.pipe(Effect.forkChild);
183 yield* Deferred.await(started);
184 assert.isDefined(startup.pollUnsafe());
185 yield* Deferred.succeed(release, undefined);
186 return yield* service.awaitDiscovery;
187 }),
188 });
189 }),
190 );
191
192 it.effect(
193 "reuses cached paths immediately and streams fresh discovery without keeping removed paths",
194 () =>
195 Effect.gen(function* () {
196 const fs = yield* FileSystem.FileSystem;
197 const stateDir = yield* fs.makeTempDirectoryScoped();
198 const spawner = yield* ChildProcessSpawner.ChildProcessSpawner.pipe(
199 Effect.provide(NodeServices.layer),
200 );
201 const executable = yield* HostProcess.ExecutablePath;
202 const first = { SHELL: "/bin/zsh", PATH: "/usr/bin" };
203 yield* runShellEnvironment({
204 env: first,
205 stateDir,
206 platform: "darwin",
207 handler: () =>
208 envOutput({
209 PATH: "/old/bin:/usr/bin",
210 __MISE_DIFF: "old-mise",
211 SSH_AUTH_SOCK: "/old-session.sock",
212 JAVA_HOME: "/old/java",
213 SSL_CERT_FILE: "/old/cert.pem",
214 }),
215 });
216 const cachePath = `${stateDir}/shell-environment-cache.json`;
217 const cached = yield* fs.readFileString(cachePath).pipe(Effect.flatMap(decodeCache));
218 assert.equal(cached.environment.PATH, "/old/bin:/usr/bin");
219 assert.notProperty(cached.environment, "SSH_AUTH_SOCK");
220 const started = yield* Deferred.make<void>();
221 const release = yield* Deferred.make<void>();
222 const env: NodeJS.ProcessEnv = {
223 SHELL: "/bin/zsh",
224 PATH: "/launcher/bin",
225 SSH_AUTH_SOCK: "/current-session.sock",
226 };
227 const source = [
228 "const values = { PATH: '/fresh/bin:' + process.env.PATH, __MISE_DIFF: 'fresh-mise', JAVA_HOME: process.env.JAVA_HOME, SSL_CERT_FILE: process.env.SSL_CERT_FILE };",
229 "for (const [name, value] of Object.entries(values)) {",
230 " if (value !== undefined) process.stdout.write('__T3CODE_ENV_' + name + '_START__\\n' + value + '\\n__T3CODE_ENV_' + name + '_END__\\n');",
231 "}",
232 ].join("\n");
233 let calls = 0;
234 yield* runShellEnvironment({
235 env,
236 stateDir,
237 platform: "darwin",
238 handler: () => "",
239 processSpawner: ChildProcessSpawner.make((command) => {
240 calls++;
241 return Deferred.succeed(started, undefined).pipe(
242 Effect.andThen(Deferred.await(release)),
243 Effect.andThen(
244 command._tag === "StandardCommand"
245 ? spawner.spawn(ChildProcess.make(executable, ["-e", source], command.options))
246 : Effect.succeed(makeProcess("")),
247 ),
248 );
249 }),
250 use: (service) =>
251 Effect.gen(function* () {
252 yield* service.installIntoProcess;
253 yield* service.installIntoProcess;
254 assert.equal(env.PATH, "/old/bin:/usr/bin:/launcher/bin");
255 assert.equal(env.SSH_AUTH_SOCK, "/current-session.sock");
256 const replay = yield* Stream.runHead(service.updates);
257 assert.isTrue(Option.isSome(replay));
258 if (Option.isSome(replay))
259 assert.equal(replay.value.environment.__MISE_DIFF, "old-mise");
260 yield* Deferred.await(started);
261 assert.equal(calls, 1);
262 yield* Deferred.succeed(release, undefined);
263 yield* service.awaitDiscovery;
264 assert.equal(env.PATH, "/fresh/bin:/launcher/bin");
265 assert.equal(env.__MISE_DIFF, "fresh-mise");
266 assert.isUndefined(env.JAVA_HOME);
267 assert.isUndefined(env.SSL_CERT_FILE);
268 const fresh = yield* Stream.runHead(service.updates);
269 assert.isTrue(Option.isSome(fresh));
270 if (Option.isSome(fresh)) {
271 assert.equal(fresh.value.environment.PATH, env.PATH);
272 assert.isTrue(fresh.value.discovered);
273 }
274 return true;
275 }),
276 });
277 const refreshed = yield* fs.readFileString(cachePath).pipe(Effect.flatMap(decodeCache));
278 assert.equal(refreshed.environment.PATH, "/fresh/bin:/launcher/bin");
279 assert.notProperty(refreshed.environment, "JAVA_HOME");
280 assert.notProperty(refreshed.environment, "SSL_CERT_FILE");
281 }).pipe(Effect.provide(NodeServices.layer)),
282 );
283
284 it.effect.each([
285 "not json",
286 '{"version":2,"key":"old","environment":{"PATH":"/bad/bin"}}',
287 '{"version":1,"key":"other-shell","environment":{"PATH":"/bad/bin"}}',
288 ])("ignores an unusable cache and still starts before discovery: %s", (contents) =>
289 Effect.gen(function* () {
290 const fs = yield* FileSystem.FileSystem;
291 const stateDir = yield* fs.makeTempDirectoryScoped();
292 yield* fs.writeFileString(`${stateDir}/shell-environment-cache.json`, contents);
293 const started = yield* Deferred.make<void>();
294 const release = yield* Deferred.make<void>();
295 const env: NodeJS.ProcessEnv = { SHELL: "/bin/zsh", PATH: "/usr/bin" };
296 yield* runShellEnvironment({
297 env,
298 stateDir,
299 platform: "darwin",
300 handler: () => "",
301 processSpawner: ChildProcessSpawner.make(() =>
302 Deferred.succeed(started, undefined).pipe(
303 Effect.andThen(Deferred.await(release)),
304 Effect.as(makeProcess(envOutput({ PATH: "/fresh/bin" }))),
305 ),
306 ),
307 use: (service) =>
308 Effect.gen(function* () {
309 const startup = yield* service.installIntoProcess.pipe(Effect.forkChild);
310 yield* Deferred.await(started);
311 assert.isDefined(startup.pollUnsafe());
312 assert.equal(env.PATH, "/usr/bin");
313 yield* Deferred.succeed(release, undefined);
314 return yield* service.awaitDiscovery;
315 }),
316 });
317 assert.equal(env.PATH, "/fresh/bin:/usr/bin");
318 }).pipe(Effect.provide(NodeServices.layer)),
319 );
320
321 it.effect(
322 "retains the last successful cache on refresh failure and ignores session values in the cache",
323 () =>
324 Effect.gen(function* () {
325 const fs = yield* FileSystem.FileSystem;
326 const stateDir = yield* fs.makeTempDirectoryScoped();
327 const contents = encodeCache({
328 version: 1,
329 key: '["darwin","/test-home","/bin/zsh",""]',
330 environment: {
331 PATH: "/cached/bin",
332 __MISE_DIFF: "cached-mise",
333 JAVA_HOME: "/cached/java",
334 NODE_EXTRA_CA_CERTS: "/cached/ca.pem",
335 SSL_CERT_FILE: "/cached/cert.pem",
336 SSL_CERT_DIR: "/cached/certs",
337 SSH_AUTH_SOCK: "/stale.sock",
338 NODE_OPTIONS: "--bad",
339 },
340 });
341 const cachePath = `${stateDir}/shell-environment-cache.json`;
342 yield* fs.writeFileString(cachePath, contents);
343 const env: NodeJS.ProcessEnv = {
344 SHELL: "/bin/zsh",
345 PATH: "/usr/bin",
346 __MISE_DIFF: "launcher-mise",
347 JAVA_HOME: "/launcher/java",
348 NODE_EXTRA_CA_CERTS: "/launcher/ca.pem",
349 SSL_CERT_FILE: "",
350 SSH_AUTH_SOCK: "/current.sock",
351 };
352 const prepared = yield* runShellEnvironment({
353 env,
354 stateDir,
355 platform: "darwin",
356 handler: () => "",
357 use: (service) =>
358 Effect.gen(function* () {
359 yield* service.installIntoProcess;
360 const replay = yield* Stream.runHead(service.updates);
361 assert.isTrue(Option.isSome(replay));
362 if (Option.isSome(replay)) {
363 assert.equal(replay.value.environment.JAVA_HOME, "/launcher/java");
364 assert.equal(replay.value.environment.NODE_EXTRA_CA_CERTS, "/launcher/ca.pem");
365 }
366 return yield* service.awaitDiscovery;
367 }),
368 });
369 assert.isFalse(prepared);
370 assert.equal(env.PATH, "/cached/bin:/usr/bin");
371 assert.equal(env.__MISE_DIFF, "cached-mise");
372 assert.equal(env.JAVA_HOME, "/launcher/java");
373 assert.equal(env.NODE_EXTRA_CA_CERTS, "/launcher/ca.pem");
374 assert.equal(env.SSL_CERT_FILE, "");
375 assert.equal(env.SSL_CERT_DIR, "/cached/certs");
376 assert.equal(env.SSH_AUTH_SOCK, "/current.sock");
377 assert.isUndefined(env.NODE_OPTIONS);
378 assert.equal(yield* fs.readFileString(cachePath), contents);
379 }).pipe(Effect.provide(NodeServices.layer)),
380 );
381
382 it.effect("cancels background discovery when the application scope closes", () =>
383 Effect.gen(function* () {
384 const started = yield* Deferred.make<void>();
385 const stopped = yield* Deferred.make<void>();
386 yield* runShellEnvironment({
387 env: { SHELL: "/bin/zsh", PATH: "/usr/bin" },
388 platform: "darwin",
389 handler: () => "",
390 processSpawner: ChildProcessSpawner.make(() =>
391 Deferred.succeed(started, undefined).pipe(
392 Effect.andThen(Effect.never),
393 Effect.ensuring(Deferred.succeed(stopped, undefined)),
394 ),
395 ),
396 use: (service) =>
397 Effect.gen(function* () {
398 yield* service.installIntoProcess;
399 yield* Deferred.await(started);
400 return true;
401 }),
402 });
403 yield* Deferred.await(stopped);
404 }),
405 );
406
407 it.effect(
408 "captures the login-shell PATH when startup writes more than a pipe buffer to stderr",
409 () =>
410 Effect.gen(function* () {
411 const spawner = yield* ChildProcessSpawner.ChildProcessSpawner.pipe(
412 Effect.provide(NodeServices.layer),
413 );
414 const executable = yield* HostProcess.ExecutablePath;
415 const env: NodeJS.ProcessEnv = { SHELL: "/test-shell", PATH: "/usr/bin" };
416 const commands: string[] = [];
417 const source = [
418 "setTimeout(() => process.exit(99), 3000).unref();",
419 "process.stderr.write(Buffer.alloc(2 * 1024 * 1024, 120), () => {",
420 `process.stdout.write(${JSON.stringify(envOutput({ PATH: "/shell-only/bin:/usr/bin" }))});`,
421 "});",
422 ].join("\n");
423
424 yield* runShellEnvironment({
425 env,
426 platform: "linux",
427 handler: () => "",
428 processSpawner: ChildProcessSpawner.make((command) => {
429 if (command._tag !== "StandardCommand") return Effect.succeed(makeProcess(""));
430 commands.push(command.command);
431 return command.command === "/test-shell"
432 ? spawner.spawn(ChildProcess.make(executable, ["-e", source], command.options))
433 : Effect.succeed(makeProcess(""));
434 }),
435 }).pipe(Effect.provide(Logger.layer([])));
436
437 assert.equal(env.PATH, "/shell-only/bin:/usr/bin");
438 assert.deepEqual(commands, ["/test-shell"]);
439 }),
440 );
441
442 it.effect("rejects PATH output from a failed shell and tries the next candidate", () =>
443 Effect.gen(function* () {
444 const env: NodeJS.ProcessEnv = { SHELL: "/bin/failing", PATH: "/usr/bin" };
445 const commands: string[] = [];
446
447 const discovery = (service: DesktopShellEnvironment.DesktopShellEnvironment["Service"]) =>
448 Effect.all([service.awaitDiscovery, service.awaitUserShellDiscovery]);
449 const answered = yield* runShellEnvironment({
450 env,
451 platform: "linux",
452 userShell: "/usr/local/bin/fish",
453 use: (service) => discovery(service).pipe(Effect.map(([, fromUserShell]) => fromUserShell)),
454 handler: (command) => {
455 if (command._tag !== "StandardCommand") return "";
456 commands.push(command.command);
457 return command.command === "/bin/failing"
458 ? { output: envOutput({ PATH: "/failed/bin" }), exitCode: 7 }
459 : envOutput({ PATH: "/home/test/.local/bin:/usr/bin" });
460 },
461 }).pipe(Effect.provide(Logger.layer([])));
462
463 assert.deepEqual(commands, ["/bin/failing", "/usr/local/bin/fish"]);
464 assert.equal(env.PATH, "/home/test/.local/bin:/usr/bin");
465 // The account's own login shell answered.
466 assert.isTrue(answered);
467
468 // Only the platform fallback answered: its PATH can miss folders the user's shell adds.
469 const fallbackOnly = yield* runShellEnvironment({
470 env: { SHELL: "/usr/bin/fish", PATH: "/usr/bin" },
471 platform: "linux",
472 use: (service) =>
473 discovery(service).pipe(
474 Effect.map(([prepared, fromUserShell]) => prepared && !fromUserShell),
475 ),
476 handler: (command) =>
477 command._tag === "StandardCommand" && command.command === "/usr/bin/fish"
478 ? { output: "", exitCode: 7 }
479 : envOutput({ PATH: "/usr/bin" }),
480 }).pipe(Effect.provide(Logger.layer([])));
481 assert.isTrue(fallbackOnly);
482 }),
483 );
484
485 it.effect("hydrates PATH and missing SSH_AUTH_SOCK from the login shell on macOS", () =>
486 Effect.gen(function* () {
487 const env: NodeJS.ProcessEnv = {
488 SHELL: "/bin/zsh",
489 PATH: "/Users/test/.local/bin:/usr/bin",
490 };
491 const commands: ChildProcess.Command[] = [];
492
493 const prepared = yield* runShellEnvironment({
494 env,
495 platform: "darwin",
496 handler: (command) => {
497 commands.push(command);
498 return envOutput({
499 PATH: "/opt/homebrew/bin:/usr/bin",
500 SSH_AUTH_SOCK: "/tmp/secretive.sock",
501 HOMEBREW_PREFIX: "/opt/homebrew",
502 });
503 },
504 });
505
506 assert.equal(commands.length, 1);
507 assert.equal(commands[0]?._tag === "StandardCommand" ? commands[0].command : "", "/bin/zsh");
508 assert.equal(env.PATH, "/opt/homebrew/bin:/usr/bin:/Users/test/.local/bin");
509 assert.equal(env.SSH_AUTH_SOCK, "/tmp/secretive.sock");
510 assert.equal(env.HOMEBREW_PREFIX, "/opt/homebrew");
511 assert.isTrue(prepared);
512 }),
513 );
514
515 it.effect("does not mix login-shell locale categories into an inherited locale", () =>
516 Effect.gen(function* () {
517 const env: NodeJS.ProcessEnv = {
518 SHELL: "/bin/zsh",
519 PATH: "/usr/bin",
520 LANG: "en_US.UTF-8",
521 };
522
523 yield* runShellEnvironment({
524 env,
525 platform: "darwin",
526 handler: () =>
527 envOutput({
528 PATH: "/opt/homebrew/bin:/usr/bin",
529 LC_ALL: "de_DE.UTF-8",
530 }),
531 });
532
533 assert.equal(env.LANG, "en_US.UTF-8");
534 assert.equal(env.LC_ALL, undefined);
535 }),
536 );
537
538 it.effect("falls back to a UTF-8 LC_CTYPE when no locale is available on macOS", () =>
539 Effect.gen(function* () {
540 const env: NodeJS.ProcessEnv = {
541 SHELL: "/bin/zsh",
542 PATH: "/usr/bin",
543 };
544
545 yield* runShellEnvironment({
546 env,
547 platform: "darwin",
548 handler: () => envOutput({ PATH: "/opt/homebrew/bin:/usr/bin" }),
549 });
550
551 assert.equal(env.LANG, undefined);
552 assert.equal(env.LC_ALL, undefined);
553 assert.equal(env.LC_CTYPE, "en_US.UTF-8");
554 }),
555 );
556
557 it.effect("hydrates missing CA trust variables from the login shell", () =>
558 Effect.gen(function* () {
559 const env: NodeJS.ProcessEnv = {
560 SHELL: "/bin/zsh",
561 PATH: "/usr/bin",
562 SSL_CERT_DIR: "/etc/ssl/certs",
563 };
564
565 yield* runShellEnvironment({
566 env,
567 platform: "darwin",
568 handler: () =>
569 envOutput({
570 PATH: "/opt/zerobrew/bin:/usr/bin",
571 SSL_CERT_FILE: "/opt/zerobrew/etc/ca-certificates/cacert.pem",
572 SSL_CERT_DIR: "/opt/zerobrew/etc/ca-certificates",
573 NODE_EXTRA_CA_CERTS: "/Users/me/corp-root.pem",
574 }),
575 });
576
577 assert.equal(env.SSL_CERT_FILE, "/opt/zerobrew/etc/ca-certificates/cacert.pem");
578 assert.equal(env.SSL_CERT_DIR, "/etc/ssl/certs");
579 assert.equal(env.NODE_EXTRA_CA_CERTS, "/Users/me/corp-root.pem");
580 }),
581 );
582
583 it.effect("keeps mise's PATH record with the login shell PATH", () =>
584 Effect.gen(function* () {
585 const env: NodeJS.ProcessEnv = {
586 SHELL: "/bin/bash",
587 PATH: "/usr/bin",
588 };
589
590 yield* runShellEnvironment({
591 env,
592 platform: "linux",
593 handler: () =>
594 envOutput({
595 PATH: "/home/me/.local/bin:/mise/installs/claude/latest:/mise/shims",
596 __MISE_DIFF: "mise-diff",
597 }),
598 });
599
600 assert.equal(env.__MISE_DIFF, "mise-diff");
601 }),
602 );
603
604 it.effect("falls back to launchctl PATH on macOS when shell probing does not return one", () =>
605 Effect.gen(function* () {
606 const env: NodeJS.ProcessEnv = {
607 SHELL: "/opt/homebrew/bin/nu",
608 PATH: "/usr/bin",
609 };
610 const commands: string[] = [];
611
612 const prepared = yield* runShellEnvironment({
613 env,
614 platform: "darwin",
615 handler: (command) => {
616 if (command._tag !== "StandardCommand") return "";
617 commands.push(command.command);
618 return command.command === "/bin/launchctl" ? "/opt/homebrew/bin:/usr/bin" : "";
619 },
620 });
621
622 assert.deepEqual(commands, ["/opt/homebrew/bin/nu", "/bin/zsh", "/bin/launchctl"]);
623 assert.equal(env.PATH, "/opt/homebrew/bin:/usr/bin");
624 assert.isFalse(prepared);
625 }),
626 );
627
628 it.effect("leaves macOS preparation incomplete when no login shell returns a PATH", () =>
629 Effect.gen(function* () {
630 const prepared = yield* runShellEnvironment({
631 env: { SHELL: "/bin/zsh", PATH: "/usr/bin:/bin:/usr/sbin:/sbin" },
632 platform: "darwin",
633 handler: () => "",
634 });
635
636 assert.isFalse(prepared);
637 }),
638 );
639
640 it.effect("recovers the login-shell PATH when the first probe outlasts the timeout", () =>
641 Effect.gen(function* () {
642 const env: NodeJS.ProcessEnv = { SHELL: "/bin/zsh", PATH: "/usr/bin:/bin:/usr/sbin:/sbin" };
643 const output = envOutput({ PATH: "/opt/homebrew/bin:/usr/bin" });
644 let shellProbes = 0;
645 yield* runShellEnvironment({
646 env,
647 platform: "darwin",
648 handler: () => "",
649 processSpawner: ChildProcessSpawner.make((command) => {
650 if (command._tag !== "StandardCommand" || command.command !== "/bin/zsh") {
651 return Effect.succeed(makeProcess(""));
652 }
653 shellProbes += 1;
654 if (shellProbes > 1) return Effect.succeed(makeProcess(output));
655 // A cold start: the first login shell answers, but only after 7s.
656 const slow = Effect.sleep("7 seconds");
657 const stdout = Stream.fromEffect(slow.pipe(Effect.as(textEncoder.encode(output))));
658 return Effect.succeed(
659 ChildProcessSpawner.makeHandle({
660 pid: ChildProcessSpawner.ProcessId(125),
661 stdout,
662 stderr: Stream.empty,
663 all: stdout,
664 exitCode: slow.pipe(Effect.as(ChildProcessSpawner.ExitCode(0))),
665 isRunning: Effect.succeed(true),
666 kill: () => Effect.void,
667 stdin: Sink.drain,
668 getInputFd: () => Sink.drain,
669 getOutputFd: () => Stream.empty,
670 unref: Effect.succeed(Effect.void),
671 }),
672 );
673 }),
674 use: (service) =>
675 Effect.gen(function* () {
676 yield* service.installIntoProcess;
677 yield* TestClock.adjust("10 minutes");
678 return true;
679 }),
680 }).pipe(Effect.provide(Logger.layer([])));
681
682 assert.equal(env.PATH, "/opt/homebrew/bin:/usr/bin:/bin:/usr/sbin:/sbin");
683 }),
684 );
685
686 it.effect("loads PowerShell profile environment on Windows", () =>
687 Effect.gen(function* () {
688 const env: NodeJS.ProcessEnv = {
689 PATH: "C:\\Windows\\System32",
690 APPDATA: "C:\\Users\\testuser\\AppData\\Roaming",
691 LOCALAPPDATA: "C:\\Users\\testuser\\AppData\\Local",
692 USERPROFILE: "C:\\Users\\testuser",
693 };
694
695 const prepared = yield* runShellEnvironment({
696 env,
697 platform: "win32",
698 handler: (command) => {
699 if (command._tag !== "StandardCommand") return "";
700 const loadProfile = !command.args.includes("-NoProfile");
701 return loadProfile
702 ? envOutput({
703 PATH: "C:\\Profile\\Node;C:\\Windows\\System32",
704 FNM_DIR: "C:\\Users\\testuser\\AppData\\Roaming\\fnm",
705 FNM_MULTISHELL_PATH: "C:\\Users\\testuser\\AppData\\Local\\fnm_multishells\\123",
706 })
707 : envOutput({ PATH: 'C:\\Custom\\Bin;C:";C:\\Windows\\System32' });
708 },
709 });
710
711 assert.isTrue(prepared);
712 assert.equal(
713 env.PATH,
714 [
715 "C:\\Profile\\Node",
716 "C:\\Windows\\System32",
717 "C:\\Users\\testuser\\AppData\\Roaming\\npm",
718 "C:\\Users\\testuser\\AppData\\Local\\Programs\\nodejs",
719 "C:\\Users\\testuser\\AppData\\Local\\Volta\\bin",
720 "C:\\Users\\testuser\\AppData\\Local\\pnpm",
721 "C:\\Users\\testuser\\.local\\bin",
722 "C:\\Users\\testuser\\.bun\\bin",
723 "C:\\Users\\testuser\\scoop\\shims",
724 "C:\\Custom\\Bin",
725 "C:",
726 ].join(";"),
727 );
728 assert.equal(env.FNM_DIR, "C:\\Users\\testuser\\AppData\\Roaming\\fnm");
729 assert.equal(
730 env.FNM_MULTISHELL_PATH,
731 "C:\\Users\\testuser\\AppData\\Local\\fnm_multishells\\123",
732 );
733 }),
734 );
735
736 it.effect.each([false, true])(
737 "leaves Windows preparation incomplete when loadProfile=%s fails",
738 (loadProfile) =>
739 Effect.gen(function* () {
740 const env: NodeJS.ProcessEnv = { PATH: "C:\\Windows\\System32" };
741 let calls = 0;
742 const prepared = yield* runShellEnvironment({
743 env,
744 platform: "win32",
745 handler: (command) => {
746 calls++;
747 if (command._tag !== "StandardCommand") return "";
748 return !command.args.includes("-NoProfile") === loadProfile
749 ? { output: "", exitCode: 1 }
750 : envOutput({ PATH: "C:\\Working" });
751 },
752 }).pipe(Effect.provide(Logger.layer([])));
753 assert.isFalse(prepared);
754 assert.equal(calls, 3);
755 assert.include(env.PATH, "C:\\Working");
756 assert.include(env.PATH, "C:\\Windows\\System32");
757 }),
758 );
759
760 it.effect("gives up on a Windows shell that survives the kill", () =>
761 Effect.gen(function* () {
762 const commands: Array<string> = [];
763 // Both PowerShell probes spawn pwsh, time out, then spawn powershell. Each probe's timeout
764 // is armed before its spawn runs, so waiting for the spawns keeps the clock in step.
765 const spawned = yield* Effect.forEach([0, 1, 2, 3], () => Deferred.make<void>());
766 const fiber = yield* runShellEnvironment({
767 env: { PATH: "C:\\Windows", APPDATA: "C:\\Roaming" },
768 platform: "win32",
769 handler: () => "",
770 processSpawner: ChildProcessSpawner.make((command) => {
771 const spawn = spawned[commands.length];
772 commands.push(command._tag === "StandardCommand" ? command.command : "");
773 return (spawn ? Deferred.succeed(spawn, undefined) : Effect.void).pipe(
774 Effect.andThen(
775 Effect.acquireRelease(Effect.succeed(makeHungProcess()), () => Effect.never),
776 ),
777 );
778 }),
779 }).pipe(Effect.forkChild({ startImmediately: true }));
780
781 yield* Effect.forEach(spawned.slice(0, 2), Deferred.await);
782 yield* TestClock.adjust("5 seconds");
783 yield* Effect.forEach(spawned.slice(2), Deferred.await);
784 yield* TestClock.adjust("5 seconds");
785 yield* Fiber.join(fiber);
786 assert.deepEqual([...new Set(commands)], ["pwsh.exe", "powershell.exe"]);
787 }),
788 );
789
790 it.effect("replaces a Path-spelled Windows PATH instead of adding a second key", () =>
791 Effect.gen(function* () {
792 const env: NodeJS.ProcessEnv = { Path: "C:\\Windows\\System32" };
793
794 yield* runShellEnvironment({
795 env,
796 platform: "win32",
797 handler: (command) =>
798 command._tag === "StandardCommand" && command.args.includes("-NoProfile")
799 ? envOutput({ PATH: "C:\\Shell\\Bin" })
800 : "",
801 });
802
803 assert.deepEqual(env, { PATH: "C:\\Shell\\Bin;C:\\Windows\\System32" });
804 }),
805 );
806
807 it.effect("prefers login-shell desktop session hints over inherited values on linux", () =>
808 Effect.gen(function* () {
809 const env: NodeJS.ProcessEnv = {
810 SHELL: "/bin/zsh",
811 PATH: "/usr/bin",
812 XDG_CURRENT_DESKTOP: "wrong-launcher",
813 XDG_SESSION_DESKTOP: "wrong-launcher",
814 };
815
816 yield* runShellEnvironment({
817 env,
818 platform: "linux",
819 handler: () =>
820 envOutput({
821 PATH: "/home/linuxbrew/.linuxbrew/bin:/usr/bin",
822 XDG_CURRENT_DESKTOP: "KDE",
823 XDG_SESSION_DESKTOP: "KDE",
824 XDG_SESSION_TYPE: "wayland",
825 }),
826 });
827
828 assert.equal(env.XDG_CURRENT_DESKTOP, "KDE");
829 assert.equal(env.XDG_SESSION_DESKTOP, "KDE");
830 assert.equal(env.XDG_SESSION_TYPE, "wayland");
831 }),
832 );
833});