Skip to content
Source
Sign inSign up
Open History to see the last commit

os-jank.ts

238 lines8.5 KB

1import * as HostProcess from "@t3tools/shared/HostProcess";
2import {
3 buildEnvironmentCaptureCommand,
4 extractEnvironmentValues,
5 listLoginShellCandidates,
6 mergePathEntries,
7 readEnvironmentFromLoginShell,
8 readPathFromLaunchctl,
9 resolveWindowsEnvironment,
10 setWindowsPath,
11} from "@t3tools/shared/shell";
12import * as Duration from "effect/Duration";
13import * as Effect from "effect/Effect";
14import * as FileSystem from "effect/FileSystem";
15import * as Option from "effect/Option";
16import * as Path from "effect/Path";
17import * as Stream from "effect/Stream";
18import * as ChildProcess from "effect/process/ChildProcess";
19import * as ChildProcessSpawner from "effect/process/ChildProcessSpawner";
20// @effect-diagnostics-next-line nodeBuiltinImport:off -- the shared login shell probe runs synchronously through execFileSync.
21import * as NodeChildProcess from "node:child_process";
22import * as NodeOS from "node:os";
23​
24function logPathHydrationWarning(message: string, error?: unknown): void {
25 process.stderr.write(
26 `[server] ${message} ${error instanceof Error ? error.message : (error ?? "")}\n`,
27 );
29​
30// __MISE_DIFF is mise's record of the PATH entries it added. It must travel
31// with the shell's PATH, or `mise x` can rebuild PATH behind a same-named
32// wrapper (`exec mise x claude -- claude`) that then re-runs itself forever.
33const LOGIN_SHELL_ENV_NAMES = ["PATH", "__MISE_DIFF", "JAVA_HOME"] as const;
34const LOGIN_SHELL_TIMEOUT = Duration.seconds(5);
35const SLOW_LOGIN_SHELL_TIMEOUT_MS = 20_000;
36​
37function readSlowLoginShellEnvironment(shell: string): Partial<Record<string, string>> {
38 return readEnvironmentFromLoginShell(shell, LOGIN_SHELL_ENV_NAMES, (file, args, options) =>
39 NodeChildProcess.execFileSync(file, args, {
40 ...options,
41 timeout: SLOW_LOGIN_SHELL_TIMEOUT_MS,
42 }),
43 );
45​
46function applyLoginShellEnvironment(
47 env: NodeJS.ProcessEnv,
48 platform: NodeJS.Platform,
49 shellEnvironment: Partial<Record<string, string>>,
50 fallbackPath?: string,
51): void {
52 const shellPath = shellEnvironment.PATH;
53 const mergedPath = mergePathEntries(shellPath ?? fallbackPath, env.PATH, platform);
54 if (mergedPath) {
55 env.PATH = mergedPath;
56 }
57 if (shellPath && shellEnvironment.__MISE_DIFF) {
58 env.__MISE_DIFF = shellEnvironment.__MISE_DIFF;
59 }
60 // Android SDK tools are Java programs. A keg-only JDK (Homebrew's openjdk)
61 // is only reachable through the JAVA_HOME the shell profile sets.
62 if (!env.JAVA_HOME && shellEnvironment.JAVA_HOME) {
63 env.JAVA_HOME = shellEnvironment.JAVA_HOME;
64 }
66​
67function hydratePosixPath(env: NodeJS.ProcessEnv, platform: NodeJS.Platform): void {
68 let shellEnvironment: Partial<Record<string, string>> = {};
69 let waitedForSlowShell = false;
70 for (const shell of listLoginShellCandidates(platform, env.SHELL)) {
71 try {
72 shellEnvironment = readEnvironmentFromLoginShell(shell, LOGIN_SHELL_ENV_NAMES);
73 } catch (error) {
74 logPathHydrationWarning(`Failed to read PATH from login shell ${shell}.`, error);
75 // A profile can be slow only while the machine boots. Wait for it once,
76 // here: providers copy this env when they are built, so a later read
77 // would never reach them.
78 if (!waitedForSlowShell && (error as NodeJS.ErrnoException | null)?.code === "ETIMEDOUT") {
79 waitedForSlowShell = true;
80 try {
81 shellEnvironment = readSlowLoginShellEnvironment(shell);
82 } catch (retryError) {
83 logPathHydrationWarning(
84 `Failed to read PATH from slow login shell ${shell}.`,
85 retryError,
86 );
87 }
88 }
89 }
90​
91 if (shellEnvironment.PATH) break;
92 }
93​
94 const launchctlPath =
95 platform === "darwin" && !shellEnvironment.PATH ? readPathFromLaunchctl() : undefined;
96 applyLoginShellEnvironment(env, platform, shellEnvironment, launchctlPath);
98​
99export function hydratePosixHome(
100 env: NodeJS.ProcessEnv,
101 resolveHomeDir = () => NodeOS.userInfo().homedir,
102): void {
103 if ((env.HOME?.trim() ?? "").length > 0) return;
104​
105 const homeDir = resolveHomeDir();
106 if (homeDir.length > 0) {
107 env.HOME = homeDir;
108 }
110​
111export const fixPath = Effect.fn("fixPath")(function* (options?: {
112 readonly shellEnvironmentPrepared?: boolean | undefined;
113}): Effect.fn.Return<void, never, FileSystem.FileSystem | Path.Path> {
114 const platform = yield* HostProcess.Platform;
115 const env = yield* HostProcess.Environment;
116​
117 if (platform === "win32") {
118 // Desktop already populated the child env; repeating PowerShell probes costs seconds.
119 if (options?.shellEnvironmentPrepared === true) return;
120 const repairedEnvironment = yield* resolveWindowsEnvironment(env).pipe(
121 Effect.catchDefect((defect) =>
122 Effect.sync(() => {
123 logPathHydrationWarning("Failed to hydrate PATH from the user environment.", defect);
124 return {} as Partial<NodeJS.ProcessEnv>;
125 }),
126 ),
127 );
128 for (const [key, value] of Object.entries(repairedEnvironment)) {
129 if (value === undefined) continue;
130 if (key === "PATH") {
131 setWindowsPath(env, value);
132 } else {
133 env[key] = value;
134 }
135 }
136 return;
137 }
138​
139 if (platform !== "darwin" && platform !== "linux") return;
140​
141 yield* Effect.sync(() => hydratePosixHome(env)).pipe(
142 Effect.catchDefect((defect) =>
143 Effect.sync(() => {
144 logPathHydrationWarning("Failed to hydrate HOME from the user account.", defect);
145 }),
146 ),
147 );
148 if (options?.shellEnvironmentPrepared === true) return;
149 yield* Effect.sync(() => hydratePosixPath(env, platform)).pipe(
150 Effect.catchDefect((defect) =>
151 Effect.sync(() => {
152 logPathHydrationWarning("Failed to hydrate PATH from the user environment.", defect);
153 }),
154 ),
155 );
156});
157​
158const readLoginShellOutput = Effect.fn("readLoginShellOutput")(
159 function* (shell: string) {
160 const spawner = yield* ChildProcessSpawner.ChildProcessSpawner;
161 const child = yield* spawner.spawn(
162 ChildProcess.make(
163 shell,
164 ["-ilc", buildEnvironmentCaptureCommand(shell, LOGIN_SHELL_ENV_NAMES)],
165 {
166 stdin: "ignore",
167 stdout: "pipe",
168 stderr: "ignore",
169 // A profile that traps SIGTERM would otherwise hold the scope open
170 // past the timeout while cleanup waits for the exit.
171 forceKillAfter: "1 second",
172 },
173 ),
174 );
175 const [stdout, exitCode] = yield* Effect.all(
176 [Stream.mkString(Stream.decodeText(child.stdout)), child.exitCode],
177 { concurrency: 2 },
178 );
179 if (exitCode === 0) return stdout;
180 yield* Effect.logWarning(
181 `Login shell ${shell} exited with code ${exitCode} while reading PATH.`,
182 );
183 return "";
184 },
185 Effect.scoped,
186 Effect.timeoutOption(LOGIN_SHELL_TIMEOUT),
187);
188​
189// Runs even when the desktop prepared the environment: that capture was taken
190// once at launch. Only the user's own shell counts; a fallback shell's PATH is
191// not their environment. Failure leaves PATH alone.
192export const refreshPathFromLoginShell = Effect.fn("refreshPathFromLoginShell")(function* () {
193 const platform = yield* HostProcess.Platform;
194 if (platform !== "darwin" && platform !== "linux") return;
195 const env = yield* HostProcess.Environment;
196 const [shell] = listLoginShellCandidates(platform, env.SHELL);
197 if (!shell) return;
198​
199 const output = yield* readLoginShellOutput(shell).pipe(
200 Effect.catchCause((cause) =>
201 Effect.logWarning(`Failed to read PATH from login shell ${shell}.`, cause).pipe(
202 Effect.as(Option.some("")),
203 ),
204 ),
205 );
206 if (Option.isNone(output)) {
207 yield* Effect.logWarning(
208 `Timed out reading PATH from login shell ${shell} after ${Duration.format(LOGIN_SHELL_TIMEOUT)}; PATH is unchanged.`,
209 );
210 return;
211 }
212 const shellEnvironment = extractEnvironmentValues(output.value, LOGIN_SHELL_ENV_NAMES);
213 if (shellEnvironment.PATH) {
214 applyLoginShellEnvironment(env, platform, shellEnvironment);
215 } else {
216 yield* Effect.logWarning(`Login shell ${shell} printed no PATH; PATH is unchanged.`);
217 }
218});
219​
220export const expandHomePath = Effect.fn(function* (input: string) {
221 const { join } = yield* Path.Path;
222 const home = yield* HostProcess.HomeDirectory;
223 if (input === "~") {
224 return home;
225 }
226 if (input.startsWith("~/") || input.startsWith("~\\")) {
227 return join(home, input.slice(2));
228 }
229 return input;
230});
231​
232export const resolveBaseDir = Effect.fn(function* (raw: string | undefined) {
233 const { join, resolve } = yield* Path.Path;
234 if (!raw || raw.trim().length === 0) {
235 return join(yield* HostProcess.HomeDirectory, ".t3");
236 }
237 return resolve(yield* expandHomePath(raw.trim()));
238});