355 lines · 10.9 KB · TypeScript
RawDownload
import * as Context from "effect/Context";import * as Duration from "effect/Duration";import * as Effect from "effect/Effect";import * as Layer from "effect/Layer";import * as Option from "effect/Option";import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; import * as DesktopEnvironment from "../app/DesktopEnvironment.ts"; type EnvironmentPatch = Record<string, string>; interface ShellEnvironmentConfig {  readonly env: NodeJS.ProcessEnv;  readonly platform: NodeJS.Platform;  readonly userShell: Option.Option<string>;} interface WindowsProbeOptions {  readonly loadProfile: boolean;} export interface DesktopShellEnvironmentShape {  readonly installIntoProcess: Effect.Effect<void, never>;} export class DesktopShellEnvironment extends Context.Service<  DesktopShellEnvironment,  DesktopShellEnvironmentShape>()("@t3tools/desktop/shell/DesktopShellEnvironment") {} const LOGIN_SHELL_ENV_NAMES = [  "PATH",  "SSH_AUTH_SOCK",  "HOMEBREW_PREFIX",  "HOMEBREW_CELLAR",  "HOMEBREW_REPOSITORY",  "XDG_CONFIG_HOME",  "XDG_DATA_HOME",] as const;const WINDOWS_PROFILE_ENV_NAMES = ["PATH", "FNM_DIR", "FNM_MULTISHELL_PATH"] as const;const WINDOWS_SHELL_CANDIDATES = ["pwsh.exe", "powershell.exe"] as const;const LOGIN_SHELL_TIMEOUT = Duration.seconds(5);const LAUNCHCTL_TIMEOUT = Duration.seconds(2);const PROCESS_TERMINATE_GRACE = Duration.seconds(1); const trimNonEmpty = (value: string | null | undefined): Option.Option<string> =>  Option.fromNullishOr(value).pipe(    Option.map((entry) => entry.trim()),    Option.filter((entry) => entry.length > 0),  ); const pathDelimiter = (platform: NodeJS.Platform) => (platform === "win32" ? ";" : ":"); const readEnvPath = (env: NodeJS.ProcessEnv): Option.Option<string> =>  trimNonEmpty(env.PATH ?? env.Path ?? env.path); const pathComparisonKey = (entry: string, platform: NodeJS.Platform) => {  const normalized = entry.trim().replace(/^"+|"+$/g, "");  return platform === "win32" ? normalized.toLowerCase() : normalized;}; const mergePaths = (  platform: NodeJS.Platform,  values: ReadonlyArray<Option.Option<string>>,): Option.Option<string> => {  const delimiter = pathDelimiter(platform);  const entries: string[] = [];  const seen = new Set<string>();   for (const value of values) {    if (Option.isNone(value)) continue;     for (const entry of value.value.split(delimiter)) {      const trimmed = entry.trim();      if (trimmed.length === 0) continue;       const key = pathComparisonKey(trimmed, platform);      if (key.length === 0 || seen.has(key)) continue;       seen.add(key);      entries.push(trimmed);    }  }   return entries.length > 0 ? Option.some(entries.join(delimiter)) : Option.none();}; const listLoginShellCandidates = (config: ShellEnvironmentConfig): ReadonlyArray<string> => {  const fallback =    config.platform === "darwin" ? "/bin/zsh" : config.platform === "linux" ? "/bin/bash" : "";  const seen = new Set<string>();  const candidates: string[] = [];   for (const candidate of [    trimNonEmpty(config.env.SHELL),    config.userShell,    trimNonEmpty(fallback),  ]) {    if (Option.isNone(candidate) || seen.has(candidate.value)) continue;    seen.add(candidate.value);    candidates.push(candidate.value);  }   return candidates;}; const knownWindowsCliDirs = (env: NodeJS.ProcessEnv): ReadonlyArray<string> => [  ...trimNonEmpty(env.APPDATA).pipe(    Option.match({      onNone: () => [],      onSome: (value) => [`${value}\\npm`],    }),  ),  ...trimNonEmpty(env.LOCALAPPDATA).pipe(    Option.match({      onNone: () => [],      onSome: (value) => [`${value}\\Programs\\nodejs`, `${value}\\Volta\\bin`, `${value}\\pnpm`],    }),  ),  ...trimNonEmpty(env.USERPROFILE).pipe(    Option.match({      onNone: () => [],      onSome: (value) => [`${value}\\.bun\\bin`, `${value}\\scoop\\shims`],    }),  ),]; const startMarker = (name: string) => `__T3CODE_ENV_${name}_START__`;const endMarker = (name: string) => `__T3CODE_ENV_${name}_END__`; const capturePosixEnvironmentCommand = (names: ReadonlyArray<string>) =>  names    .map((name) => {      return [        `printf '%s\\n' '${startMarker(name)}'`,        `printenv ${name} || true`,        `printf '%s\\n' '${endMarker(name)}'`,      ].join("; ");    })    .join("; "); const captureWindowsEnvironmentCommand = (names: ReadonlyArray<string>) =>  [    "$ErrorActionPreference = 'Stop'",    ...names.flatMap((name) => {      return [        `Write-Output '${startMarker(name)}'`,        `$value = [Environment]::GetEnvironmentVariable('${name}')`,        "if ($null -ne $value -and $value.Length -gt 0) { Write-Output $value }",        `Write-Output '${endMarker(name)}'`,      ];    }),  ].join("; "); const extractEnvironment = (output: string, names: ReadonlyArray<string>): EnvironmentPatch => {  const environment: EnvironmentPatch = {};   for (const name of names) {    const start = output.indexOf(startMarker(name));    if (start === -1) continue;     const valueStart = start + startMarker(name).length;    const end = output.indexOf(endMarker(name), valueStart);    if (end === -1) continue;     const value = output      .slice(valueStart, end)      .replace(/^\r?\n/, "")      .replace(/\r?\n$/, "");    if (value.length > 0) {      environment[name] = value;    }  }   return environment;}; const runCommandOutput = Effect.fn("desktop.shellEnvironment.runCommandOutput")(function* (input: {  readonly command: string;  readonly args: ReadonlyArray<string>;  readonly timeout: Duration.Duration;  readonly shell?: boolean;}): Effect.fn.Return<string, never, ChildProcessSpawner.ChildProcessSpawner> {  const spawner = yield* ChildProcessSpawner.ChildProcessSpawner;  return yield* spawner    .string(      ChildProcess.make(input.command, input.args, {        shell: input.shell ?? false,        stdin: "ignore",        stdout: "pipe",        stderr: "pipe",        killSignal: "SIGTERM",        forceKillAfter: PROCESS_TERMINATE_GRACE,      }),    )    .pipe(      Effect.timeoutOption(input.timeout),      Effect.map(Option.getOrElse(() => "")),      Effect.orElseSucceed(() => ""),    );}); const readLoginShellEnvironment = (  shell: string,  names: ReadonlyArray<string>,): Effect.Effect<EnvironmentPatch, never, ChildProcessSpawner.ChildProcessSpawner> =>  names.length === 0    ? Effect.succeed({})    : runCommandOutput({        command: shell,        args: ["-ilc", capturePosixEnvironmentCommand(names)],        timeout: LOGIN_SHELL_TIMEOUT,      }).pipe(Effect.map((output) => extractEnvironment(output, names))); const readLaunchctlPath: Effect.Effect<  Option.Option<string>,  never,  ChildProcessSpawner.ChildProcessSpawner> = runCommandOutput({  command: "/bin/launchctl",  args: ["getenv", "PATH"],  timeout: LAUNCHCTL_TIMEOUT,}).pipe(Effect.map(trimNonEmpty)); const readWindowsEnvironment = Effect.fn("desktop.shellEnvironment.readWindowsEnvironment")(  function* (    names: ReadonlyArray<string>,    options: WindowsProbeOptions,  ): Effect.fn.Return<EnvironmentPatch, never, ChildProcessSpawner.ChildProcessSpawner> {    if (names.length === 0) return {};     const args = [      "-NoLogo",      ...(options.loadProfile ? ([] as const) : (["-NoProfile"] as const)),      "-NonInteractive",      "-Command",      captureWindowsEnvironmentCommand(names),    ];     for (const command of WINDOWS_SHELL_CANDIDATES) {      const output = yield* runCommandOutput({        command,        args,        timeout: LOGIN_SHELL_TIMEOUT,      });      const environment = extractEnvironment(output, names);      if (Object.keys(environment).length > 0) {        return environment;      }    }     return {};  },); const installWindowsEnvironment = Effect.fn("desktop.shellEnvironment.installWindowsEnvironment")(  function* (    config: ShellEnvironmentConfig,  ): Effect.fn.Return<void, never, ChildProcessSpawner.ChildProcessSpawner> {    const noProfile = yield* readWindowsEnvironment(["PATH"], { loadProfile: false });    const profile = yield* readWindowsEnvironment(WINDOWS_PROFILE_ENV_NAMES, {      loadProfile: true,    });    const mergedPath = mergePaths("win32", [      trimNonEmpty(profile.PATH),      trimNonEmpty(knownWindowsCliDirs(config.env).join(";")),      trimNonEmpty(noProfile.PATH),      readEnvPath(config.env),    ]);     if (Option.isSome(mergedPath)) {      config.env.PATH = mergedPath.value;    }    if (!config.env.FNM_DIR && profile.FNM_DIR) {      config.env.FNM_DIR = profile.FNM_DIR;    }    if (!config.env.FNM_MULTISHELL_PATH && profile.FNM_MULTISHELL_PATH) {      config.env.FNM_MULTISHELL_PATH = profile.FNM_MULTISHELL_PATH;    }  },); const installPosixEnvironment = Effect.fn("desktop.shellEnvironment.installPosixEnvironment")(  function* (    config: ShellEnvironmentConfig,  ): Effect.fn.Return<void, never, ChildProcessSpawner.ChildProcessSpawner> {    const shellEnvironment: EnvironmentPatch = {};     for (const shell of listLoginShellCandidates(config)) {      Object.assign(        shellEnvironment,        yield* readLoginShellEnvironment(shell, LOGIN_SHELL_ENV_NAMES),      );      if (shellEnvironment.PATH) break;    }     const launchctlPath =      config.platform === "darwin" && !shellEnvironment.PATH        ? yield* readLaunchctlPath        : Option.none<string>();    const mergedPath = mergePaths(config.platform, [      trimNonEmpty(shellEnvironment.PATH).pipe(Option.orElse(() => launchctlPath)),      readEnvPath(config.env),    ]);     if (Option.isSome(mergedPath)) {      config.env.PATH = mergedPath.value;    }    if (!config.env.SSH_AUTH_SOCK && shellEnvironment.SSH_AUTH_SOCK) {      config.env.SSH_AUTH_SOCK = shellEnvironment.SSH_AUTH_SOCK;    }     for (const name of [      "HOMEBREW_PREFIX",      "HOMEBREW_CELLAR",      "HOMEBREW_REPOSITORY",      "XDG_CONFIG_HOME",      "XDG_DATA_HOME",    ] as const) {      if (!config.env[name] && shellEnvironment[name]) {        config.env[name] = shellEnvironment[name];      }    }  },); const installShellEnvironment = (  config: ShellEnvironmentConfig,): Effect.Effect<void, never, ChildProcessSpawner.ChildProcessSpawner> => {  if (config.platform === "win32") {    return installWindowsEnvironment(config);  }  if (config.platform === "darwin" || config.platform === "linux") {    return installPosixEnvironment(config);  }  return Effect.void;}; export const layer = Layer.effect(  DesktopShellEnvironment,  Effect.gen(function* () {    const environment = yield* DesktopEnvironment.DesktopEnvironment;    const spawner = yield* ChildProcessSpawner.ChildProcessSpawner;    return DesktopShellEnvironment.of({      installIntoProcess: installShellEnvironment({        env: process.env,        platform: environment.platform,        userShell: Option.none(),      }).pipe(        Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner),        Effect.withSpan("desktop.shellEnvironment.installIntoProcess"),      ),    });  }),);