1import { listLoginShellCandidates } from "@t3tools/shared/shell";
2import * as Context from "effect/Context";
3import * as Duration from "effect/Duration";
4import * as Effect from "effect/Effect";
5import * as FileSystem from "effect/FileSystem";
6import * as Layer from "effect/Layer";
7import * as Option from "effect/Option";
8import * as Schema from "effect/Schema";
9import * as Stream from "effect/Stream";
10import * as ChildProcess from "effect/process/ChildProcess";
11import * as ChildProcessSpawner from "effect/process/ChildProcessSpawner";
12
13import * as DesktopEnvironment from "../app/DesktopEnvironment.ts";
14
15type EnvironmentPatch = Record<string, string>;
16
17interface ShellEnvironmentConfig {
18 readonly env: NodeJS.ProcessEnv;
19 readonly platform: NodeJS.Platform;
20}
21
22interface WindowsProbeOptions {
23 readonly loadProfile: boolean;
24}
25
26const DesktopShellEnvironmentProbe = Schema.Literals([
27 "login-shell",
28 "launchctl-path",
29 "powershell-profile",
30 "powershell-no-profile",
31]);
32type DesktopShellEnvironmentProbe = typeof DesktopShellEnvironmentProbe.Type;
33
34const desktopShellEnvironmentCommandFields = {
35 probe: DesktopShellEnvironmentProbe,
36 executable: Schema.String,
37 argumentCount: Schema.Number,
38};
39
40export class DesktopShellEnvironmentCommandError extends Schema.TaggedError<DesktopShellEnvironmentCommandError>()(
41 "DesktopShellEnvironmentCommandError",
42 {
43 ...desktopShellEnvironmentCommandFields,
44 cause: Schema.Defect(),
45 },
46) {
47 override get message(): string {
48 return `Desktop shell environment ${this.probe} probe (${this.executable}) failed.`;
49 }
50}
51
52export class DesktopShellEnvironmentCommandTimeoutError extends Schema.TaggedError<DesktopShellEnvironmentCommandTimeoutError>()(
53 "DesktopShellEnvironmentCommandTimeoutError",
54 {
55 ...desktopShellEnvironmentCommandFields,
56 timeoutMs: Schema.Number,
57 },
58) {
59 override get message(): string {
60 return `Desktop shell environment ${this.probe} probe (${this.executable}) timed out after ${this.timeoutMs}ms.`;
61 }
62}
63
64export class DesktopShellEnvironment extends Context.Service<
65 DesktopShellEnvironment,
66 {
67 readonly installIntoProcess: Effect.Effect<void>;
68 }
69>()("@t3tools/desktop/shell/DesktopShellEnvironment") {}
70
71const LOGIN_SHELL_ENV_NAMES = [
72 "PATH",
73 "DBUS_SESSION_BUS_ADDRESS",
74 "DISPLAY",
75 "LANG",
76 "LC_ALL",
77 "LC_CTYPE",
78 "SSH_AUTH_SOCK",
79 "HOMEBREW_PREFIX",
80 "HOMEBREW_CELLAR",
81 "HOMEBREW_REPOSITORY",
82 "XDG_CONFIG_HOME",
83 "XDG_CURRENT_DESKTOP",
84 "XDG_DATA_HOME",
85 "XDG_RUNTIME_DIR",
86 "XDG_SESSION_DESKTOP",
87 "XDG_SESSION_TYPE",
88 "WAYLAND_DISPLAY",
89 "SSL_CERT_FILE",
90 "SSL_CERT_DIR",
91 "NODE_EXTRA_CA_CERTS",
92 "__MISE_DIFF",
93] as const;
94const WINDOWS_PROFILE_ENV_NAMES = ["PATH", "FNM_DIR", "FNM_MULTISHELL_PATH"] as const;
95const LOCALE_ENV_NAMES = ["LANG", "LC_ALL", "LC_CTYPE"] as const;
96const FALLBACK_LC_CTYPE = "en_US.UTF-8";
97const WINDOWS_SHELL_CANDIDATES = ["pwsh.exe", "powershell.exe"] as const;
98const LOGIN_SHELL_TIMEOUT = Duration.seconds(5);
99const LAUNCHCTL_TIMEOUT = Duration.seconds(2);
100const PROCESS_TERMINATE_GRACE = Duration.seconds(1);
101
102const trimNonEmpty = (value: string | null | undefined): Option.Option<string> =>
103 Option.fromNullishOr(value).pipe(
104 Option.map((entry) => entry.trim()),
105 Option.filter((entry) => entry.length > 0),
106 );
107
108const pathDelimiter = (platform: NodeJS.Platform) => (platform === "win32" ? ";" : ":");
109
110const readEnvPath = (env: NodeJS.ProcessEnv): Option.Option<string> =>
111 trimNonEmpty(env.PATH ?? env.Path ?? env.path);
112
113const normalizeRuntimeDir = (value: string): string => value.replace(/\/+$/u, "");
114
115const linuxRuntimeDirCandidates = (
116 env: NodeJS.ProcessEnv,
117 uid: number | undefined,
118): ReadonlyArray<string> => {
119 const candidates: string[] = [];
120 const fromEnv = trimNonEmpty(env.XDG_RUNTIME_DIR);
121 if (Option.isSome(fromEnv)) {
122 candidates.push(normalizeRuntimeDir(fromEnv.value));
123 }
124 if (uid !== undefined) {
125 candidates.push(`/run/user/${uid}`);
126 }
127 return candidates.filter((candidate) => candidate.length > 0);
128};
129
130const pathComparisonKey = (entry: string, platform: NodeJS.Platform) => {
131 const normalized = entry.trim().replace(/^"+|"+$/g, "");
132 return platform === "win32" ? normalized.toLowerCase() : normalized;
133};
134
135const sanitizePathEntry = (entry: string, platform: NodeJS.Platform) =>
136 platform === "win32" ? entry.replaceAll('"', "") : entry;
137
138const mergePaths = (
139 platform: NodeJS.Platform,
140 values: ReadonlyArray<Option.Option<string>>,
141): Option.Option<string> => {
142 const delimiter = pathDelimiter(platform);
143 const entries: string[] = [];
144 const seen = new Set<string>();
145
146 for (const value of values) {
147 if (Option.isNone(value)) continue;
148
149 for (const entry of value.value.split(delimiter)) {
150 const sanitized = sanitizePathEntry(entry.trim(), platform);
151 if (sanitized.length === 0) continue;
152
153 const key = pathComparisonKey(sanitized, platform);
154 if (key.length === 0 || seen.has(key)) continue;
155
156 seen.add(key);
157 entries.push(sanitized);
158 }
159 }
160
161 return entries.length > 0 ? Option.some(entries.join(delimiter)) : Option.none();
162};
163
164const knownWindowsCliDirs = (env: NodeJS.ProcessEnv): ReadonlyArray<string> => [
165 ...trimNonEmpty(env.APPDATA).pipe(
166 Option.match({
167 onNone: () => [],
168 onSome: (value) => [`${value}\\npm`],
169 }),
170 ),
171 ...trimNonEmpty(env.LOCALAPPDATA).pipe(
172 Option.match({
173 onNone: () => [],
174 onSome: (value) => [`${value}\\Programs\\nodejs`, `${value}\\Volta\\bin`, `${value}\\pnpm`],
175 }),
176 ),
177 ...trimNonEmpty(env.USERPROFILE).pipe(
178 Option.match({
179 onNone: () => [],
180 onSome: (value) => [`${value}\\.local\\bin`, `${value}\\.bun\\bin`, `${value}\\scoop\\shims`],
181 }),
182 ),
183];
184
185const startMarker = (name: string) => `__T3CODE_ENV_${name}_START__`;
186const endMarker = (name: string) => `__T3CODE_ENV_${name}_END__`;
187
188const executableName = (command: string): string => command.split(/[\\/]/u).at(-1) ?? command;
189
190const logShellEnvironmentCommandError = (
191 error: DesktopShellEnvironmentCommandError | DesktopShellEnvironmentCommandTimeoutError,
192) =>
193 Effect.logWarning(error).pipe(
194 Effect.annotateLogs({
195 component: "desktop-shell-environment",
196 error,
197 }),
198 );
199
200const capturePosixEnvironmentCommand = (names: ReadonlyArray<string>) =>
201 names
202 .map((name) => {
203 return [
204 `printf '%s\\n' '${startMarker(name)}'`,
205 `printenv ${name} || true`,
206 `printf '%s\\n' '${endMarker(name)}'`,
207 ].join("; ");
208 })
209 .join("; ");
210
211const captureWindowsEnvironmentCommand = (names: ReadonlyArray<string>) =>
212 [
213 "$ErrorActionPreference = 'Stop'",
214 ...names.flatMap((name) => {
215 return [
216 `Write-Output '${startMarker(name)}'`,
217 `$value = [Environment]::GetEnvironmentVariable('${name}')`,
218 "if ($null -ne $value -and $value.Length -gt 0) { Write-Output $value }",
219 `Write-Output '${endMarker(name)}'`,
220 ];
221 }),
222 ].join("; ");
223
224const extractEnvironment = (output: string, names: ReadonlyArray<string>): EnvironmentPatch => {
225 const environment: EnvironmentPatch = {};
226
227 for (const name of names) {
228 const start = output.indexOf(startMarker(name));
229 if (start === -1) continue;
230
231 const valueStart = start + startMarker(name).length;
232 const end = output.indexOf(endMarker(name), valueStart);
233 if (end === -1) continue;
234
235 const value = output
236 .slice(valueStart, end)
237 .replace(/^\r?\n/, "")
238 .replace(/\r?\n$/, "");
239 if (value.length > 0) {
240 environment[name] = value;
241 }
242 }
243
244 return environment;
245};
246
247const runCommandOutput = Effect.fn("desktop.shellEnvironment.runCommandOutput")(function* (input: {
248 readonly probe: DesktopShellEnvironmentProbe;
249 readonly command: string;
250 readonly args: ReadonlyArray<string>;
251 readonly timeout: Duration.Duration;
252 readonly shell?: boolean;
253}): Effect.fn.Return<string, never, ChildProcessSpawner.ChildProcessSpawner> {
254 const spawner = yield* ChildProcessSpawner.ChildProcessSpawner;
255 const output = yield* Effect.scoped(
256 Effect.gen(function* () {
257 const child = yield* spawner.spawn(
258 ChildProcess.make(input.command, input.args, {
259 shell: input.shell ?? false,
260 stdin: "ignore",
261 stdout: "pipe",
262 stderr: "pipe",
263 killSignal: "SIGTERM",
264 forceKillAfter: PROCESS_TERMINATE_GRACE,
265 }),
266 );
267 return yield* Effect.all([Stream.mkString(Stream.decodeText(child.stdout)), child.exitCode], {
268 concurrency: 2,
269 });
270 }),
271 ).pipe(
272 Effect.mapError(
273 (cause) =>
274 new DesktopShellEnvironmentCommandError({
275 probe: input.probe,
276 executable: executableName(input.command),
277 argumentCount: input.args.length,
278 cause,
279 }),
280 ),
281 Effect.filterOrFail(
282 ([, exitCode]) => exitCode === 0,
283 ([, exitCode]) =>
284 new DesktopShellEnvironmentCommandError({
285 probe: input.probe,
286 executable: executableName(input.command),
287 argumentCount: input.args.length,
288 cause: { exitCode },
289 }),
290 ),
291 Effect.map(([stdout]) => stdout),
292 Effect.catchTags({
293 DesktopShellEnvironmentCommandError: (error) =>
294 logShellEnvironmentCommandError(error).pipe(Effect.as("")),
295 }),
296 Effect.timeoutOption(input.timeout),
297 );
298 if (Option.isSome(output)) {
299 return output.value;
300 }
301
302 const error = new DesktopShellEnvironmentCommandTimeoutError({
303 probe: input.probe,
304 executable: executableName(input.command),
305 argumentCount: input.args.length,
306 timeoutMs: Duration.toMillis(input.timeout),
307 });
308 yield* logShellEnvironmentCommandError(error);
309 return "";
310});
311
312const readLoginShellEnvironment = (
313 shell: string,
314 names: ReadonlyArray<string>,
315): Effect.Effect<EnvironmentPatch, never, ChildProcessSpawner.ChildProcessSpawner> =>
316 names.length === 0
317 ? Effect.succeed({})
318 : runCommandOutput({
319 probe: "login-shell",
320 command: shell,
321 args: ["-ilc", capturePosixEnvironmentCommand(names)],
322 timeout: LOGIN_SHELL_TIMEOUT,
323 }).pipe(Effect.map((output) => extractEnvironment(output, names)));
324
325const readLaunchctlPath = runCommandOutput({
326 probe: "launchctl-path",
327 command: "/bin/launchctl",
328 args: ["getenv", "PATH"],
329 timeout: LAUNCHCTL_TIMEOUT,
330}).pipe(Effect.map(trimNonEmpty));
331
332const readWindowsEnvironment = Effect.fn("desktop.shellEnvironment.readWindowsEnvironment")(
333 function* (
334 names: ReadonlyArray<string>,
335 options: WindowsProbeOptions,
336 ): Effect.fn.Return<EnvironmentPatch, never, ChildProcessSpawner.ChildProcessSpawner> {
337 if (names.length === 0) return {};
338
339 const args = [
340 "-NoLogo",
341 ...(options.loadProfile ? ([] as const) : (["-NoProfile"] as const)),
342 "-NonInteractive",
343 "-Command",
344 captureWindowsEnvironmentCommand(names),
345 ];
346
347 for (const command of WINDOWS_SHELL_CANDIDATES) {
348 const output = yield* runCommandOutput({
349 probe: options.loadProfile ? "powershell-profile" : "powershell-no-profile",
350 command,
351 args,
352 timeout: LOGIN_SHELL_TIMEOUT,
353 });
354 const environment = extractEnvironment(output, names);
355 if (Object.keys(environment).length > 0) {
356 return environment;
357 }
358 }
359
360 return {};
361 },
362);
363
364const installWindowsEnvironment = Effect.fn("desktop.shellEnvironment.installWindowsEnvironment")(
365 function* (
366 config: ShellEnvironmentConfig,
367 ): Effect.fn.Return<void, never, ChildProcessSpawner.ChildProcessSpawner> {
368 // Concurrent, not sequential: these two probes are independent (only their
369 // results are combined below) and each spawns its own PowerShell. Run in
370 // series they sit at offset 0 of desktop.startup, before anything else, and
371 // launch traces measured them at 2718ms then 2066ms — the entire 4.8s
372 // startup span, of which desktop.bootstrap is ~30ms.
373 const [noProfile, profile] = yield* Effect.all(
374 [
375 readWindowsEnvironment(["PATH"], { loadProfile: false }),
376 readWindowsEnvironment(WINDOWS_PROFILE_ENV_NAMES, { loadProfile: true }),
377 ],
378 { concurrency: 2 },
379 );
380 const mergedPath = mergePaths("win32", [
381 trimNonEmpty(profile.PATH),
382 trimNonEmpty(knownWindowsCliDirs(config.env).join(";")),
383 trimNonEmpty(noProfile.PATH),
384 readEnvPath(config.env),
385 ]);
386
387 if (Option.isSome(mergedPath)) {
388 config.env.PATH = mergedPath.value;
389 }
390 if (!config.env.FNM_DIR && profile.FNM_DIR) {
391 config.env.FNM_DIR = profile.FNM_DIR;
392 }
393 if (!config.env.FNM_MULTISHELL_PATH && profile.FNM_MULTISHELL_PATH) {
394 config.env.FNM_MULTISHELL_PATH = profile.FNM_MULTISHELL_PATH;
395 }
396 },
397);
398
399const installPosixEnvironment = Effect.fn("desktop.shellEnvironment.installPosixEnvironment")(
400 function* (
401 config: ShellEnvironmentConfig,
402 ): Effect.fn.Return<
403 void,
404 never,
405 ChildProcessSpawner.ChildProcessSpawner | FileSystem.FileSystem
406 > {
407 const fileSystem = yield* FileSystem.FileSystem;
408 const shellEnvironment: EnvironmentPatch = {};
409
410 for (const shell of listLoginShellCandidates(config.platform, config.env.SHELL)) {
411 Object.assign(
412 shellEnvironment,
413 yield* readLoginShellEnvironment(shell, LOGIN_SHELL_ENV_NAMES),
414 );
415 if (shellEnvironment.PATH) break;
416 }
417
418 const launchctlPath =
419 config.platform === "darwin" && !shellEnvironment.PATH
420 ? yield* readLaunchctlPath
421 : Option.none<string>();
422 const mergedPath = mergePaths(config.platform, [
423 trimNonEmpty(shellEnvironment.PATH).pipe(Option.orElse(() => launchctlPath)),
424 readEnvPath(config.env),
425 ]);
426
427 if (Option.isSome(mergedPath)) {
428 config.env.PATH = mergedPath.value;
429 }
430 if (!config.env.SSH_AUTH_SOCK && shellEnvironment.SSH_AUTH_SOCK) {
431 config.env.SSH_AUTH_SOCK = shellEnvironment.SSH_AUTH_SOCK;
432 }
433
434 const shellPreferredEnvNames = [
435 "DBUS_SESSION_BUS_ADDRESS",
436 "XDG_CURRENT_DESKTOP",
437 "XDG_SESSION_DESKTOP",
438 "XDG_SESSION_TYPE",
439 // mise's record of the PATH entries it added. It must travel with the
440 // shell's PATH, or `mise x` can rebuild PATH behind a same-named wrapper
441 // (`exec mise x claude -- claude`) that then re-runs itself forever.
442 "__MISE_DIFF",
443 ] as const;
444 for (const name of shellPreferredEnvNames) {
445 if (shellEnvironment[name]) {
446 config.env[name] = shellEnvironment[name];
447 }
448 }
449
450 for (const name of [
451 "DISPLAY",
452 "HOMEBREW_PREFIX",
453 "HOMEBREW_CELLAR",
454 "HOMEBREW_REPOSITORY",
455 "XDG_CONFIG_HOME",
456 "XDG_DATA_HOME",
457 "XDG_RUNTIME_DIR",
458 "WAYLAND_DISPLAY",
459 // CA trust set in the shell profile. Without it, a Node or OpenSSL build
460 // that has no bundled roots (a shared-OpenSSL node on PATH) cannot verify
461 // TLS, so provider updaters and agents fail to reach their registries.
462 "SSL_CERT_FILE",
463 "SSL_CERT_DIR",
464 "NODE_EXTRA_CA_CERTS",
465 ] as const) {
466 if (!config.env[name] && shellEnvironment[name]) {
467 config.env[name] = shellEnvironment[name];
468 }
469 }
470
471 // Locale variables form one precedence group: LC_ALL can override an inherited
472 // LANG or LC_CTYPE, so only hydrate the group when the process has none of them.
473 if (
474 config.platform === "darwin" &&
475 LOCALE_ENV_NAMES.every((name) => Option.isNone(trimNonEmpty(config.env[name])))
476 ) {
477 for (const name of LOCALE_ENV_NAMES) {
478 const value = trimNonEmpty(shellEnvironment[name]);
479 if (Option.isSome(value)) {
480 config.env[name] = value.value;
481 }
482 }
483
484 // GUI launches inherit no locale from launchd, so spawned agents land in the C
485 // locale and pbcopy decodes their UTF-8 output as MacRoman. Older supported
486 // macOS releases do not provide C.UTF-8, so set only LC_CTYPE to a UTF-8 locale
487 // available on those releases. Leaving LANG unset keeps C-stable collation and
488 // formatting, so output parsing is unaffected.
489 if (LOCALE_ENV_NAMES.every((name) => Option.isNone(trimNonEmpty(config.env[name])))) {
490 config.env.LC_CTYPE = FALLBACK_LC_CTYPE;
491 }
492 }
493
494 if (
495 config.platform === "linux" &&
496 Option.isNone(trimNonEmpty(config.env.DBUS_SESSION_BUS_ADDRESS))
497 ) {
498 for (const runtimeDir of linuxRuntimeDirCandidates(config.env, process.getuid?.())) {
499 const dbusSessionBusPath = `${runtimeDir}/bus`;
500 const busExists = yield* fileSystem
501 .exists(dbusSessionBusPath)
502 .pipe(Effect.orElseSucceed(() => false));
503 if (busExists) {
504 config.env.DBUS_SESSION_BUS_ADDRESS = `unix:path=${dbusSessionBusPath}`;
505 break;
506 }
507 }
508 }
509 },
510);
511
512const installShellEnvironment = (
513 config: ShellEnvironmentConfig,
514): Effect.Effect<void, never, ChildProcessSpawner.ChildProcessSpawner | FileSystem.FileSystem> => {
515 if (config.platform === "win32") {
516 return installWindowsEnvironment(config);
517 }
518 if (config.platform === "darwin" || config.platform === "linux") {
519 return installPosixEnvironment(config);
520 }
521 return Effect.void;
522};
523
524/** @public Service construction is part of the canonical Effect module API. */
525export const make = Effect.gen(function* () {
526 const environment = yield* DesktopEnvironment.DesktopEnvironment;
527 const fileSystem = yield* FileSystem.FileSystem;
528 const spawner = yield* ChildProcessSpawner.ChildProcessSpawner;
529 const installIntoProcess: DesktopShellEnvironment["Service"]["installIntoProcess"] =
530 installShellEnvironment({
531 env: process.env,
532 platform: environment.platform,
533 }).pipe(
534 Effect.provideService(FileSystem.FileSystem, fileSystem),
535 Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner),
536 Effect.withSpan("desktop.shellEnvironment.installIntoProcess"),
537 );
538
539 return DesktopShellEnvironment.of({ installIntoProcess });
540});
541
542export const layer = Layer.effect(DesktopShellEnvironment, make);