diff --git a/apps/desktop/src/fixPath.ts b/apps/desktop/src/fixPath.ts index 150848c14c63..377aee115853 100644 --- a/apps/desktop/src/fixPath.ts +++ b/apps/desktop/src/fixPath.ts @@ -1,14 +1,11 @@ -import * as ChildProcess from "node:child_process"; +import { resolveLoginShellPath } from "@t3tools/shared/shellPath"; export function fixPath(): void { - if (process.platform !== "darwin") return; + if (process.platform === "win32") return; try { - const shell = process.env.SHELL ?? "/bin/zsh"; - const result = ChildProcess.execFileSync(shell, ["-ilc", "echo -n $PATH"], { - encoding: "utf8", - timeout: 5000, - }); + const shell = process.env.SHELL ?? (process.platform === "darwin" ? "/bin/zsh" : "/bin/sh"); + const result = resolveLoginShellPath(shell, process.env); if (result) { process.env.PATH = result; } diff --git a/apps/server/src/main.ts b/apps/server/src/main.ts index 0a33be0cbb2e..66cf52f596d8 100644 --- a/apps/server/src/main.ts +++ b/apps/server/src/main.ts @@ -235,12 +235,10 @@ export const recordStartupHeartbeat = Effect.gen(function* () { }); }); -const makeServerProgram = (input: CliInput) => +const makeServerProgram = () => Effect.gen(function* () { - const cliConfig = yield* CliConfig; const { start, stopSignal } = yield* Server; const openDeps = yield* Open; - yield* cliConfig.fixPath; const config = yield* ServerConfig; @@ -280,7 +278,7 @@ const makeServerProgram = (input: CliInput) => } return yield* stopSignal; - }).pipe(Effect.provide(LayerLive(input))); + }); /** * These flags mirrors the environment variables and the config shape. @@ -343,5 +341,12 @@ export const t3Cli = Command.make("t3", { logWebSocketEvents: logWebSocketEventsFlag, }).pipe( Command.withDescription("Run the T3 Code server."), - Command.withHandler((input) => Effect.scoped(makeServerProgram(input))), + Command.withHandler((input) => + Effect.scoped( + Effect.gen(function* () { + yield* Effect.sync(fixPath); + return yield* makeServerProgram().pipe(Effect.provide(LayerLive(input))); + }), + ), + ), ); diff --git a/apps/server/src/os-jank.ts b/apps/server/src/os-jank.ts index 3f5e2129da06..09a55e5e4fa5 100644 --- a/apps/server/src/os-jank.ts +++ b/apps/server/src/os-jank.ts @@ -1,16 +1,14 @@ import * as OS from "node:os"; import { Effect, Path } from "effect"; -import { execFileSync } from "node:child_process"; + +import { resolveLoginShellPath } from "@t3tools/shared/shellPath"; export function fixPath(): void { - if (process.platform !== "darwin") return; + if (process.platform === "win32") return; try { - const shell = process.env.SHELL ?? "/bin/zsh"; - const result = execFileSync(shell, ["-ilc", "echo -n $PATH"], { - encoding: "utf8", - timeout: 5000, - }); + const shell = process.env.SHELL ?? (process.platform === "darwin" ? "/bin/zsh" : "/bin/sh"); + const result = resolveLoginShellPath(shell, process.env); if (result) { process.env.PATH = result; } diff --git a/packages/shared/package.json b/packages/shared/package.json index 4ac86444b8c0..8d58909cc56f 100644 --- a/packages/shared/package.json +++ b/packages/shared/package.json @@ -16,6 +16,10 @@ "types": "./src/logging.ts", "import": "./src/logging.ts" }, + "./shellPath": { + "types": "./src/shellPath.ts", + "import": "./src/shellPath.ts" + }, "./Net": { "types": "./src/Net.ts", "import": "./src/Net.ts" diff --git a/packages/shared/src/shellPath.test.ts b/packages/shared/src/shellPath.test.ts new file mode 100644 index 000000000000..f044636a55f1 --- /dev/null +++ b/packages/shared/src/shellPath.test.ts @@ -0,0 +1,26 @@ +import assert from "node:assert/strict"; + +import { it } from "@effect/vitest"; + +import { normalizeShellPathOutput } from "./shellPath"; + +it("normalizes colon-delimited shell PATH output", () => { + assert.strictEqual( + normalizeShellPathOutput("/usr/local/bin:/opt/homebrew/bin:/usr/bin"), + "/usr/local/bin:/opt/homebrew/bin:/usr/bin", + ); +}); + +it("normalizes fish-style whitespace-delimited PATH output", () => { + assert.strictEqual( + normalizeShellPathOutput("/usr/local/bin /opt/homebrew/bin /usr/bin"), + "/usr/local/bin:/opt/homebrew/bin:/usr/bin", + ); +}); + +it("strips ANSI control sequences from shell PATH output", () => { + assert.strictEqual( + normalizeShellPathOutput("\u001b[6 q/usr/local/bin:/opt/homebrew/bin\u001b[2 q"), + "/usr/local/bin:/opt/homebrew/bin", + ); +}); diff --git a/packages/shared/src/shellPath.ts b/packages/shared/src/shellPath.ts new file mode 100644 index 000000000000..3ac4ec07b5bb --- /dev/null +++ b/packages/shared/src/shellPath.ts @@ -0,0 +1,42 @@ +import { delimiter, basename } from "node:path"; +import { execFileSync } from "node:child_process"; + +const ESCAPE_CHARACTER = String.fromCharCode(27); +const ANSI_ESCAPE_SEQUENCE = new RegExp(`${ESCAPE_CHARACTER}\\[[0-?]*[ -/]*[@-~]`, "g"); + +function getShellProbe(shellPath: string): ReadonlyArray { + const shellName = basename(shellPath).toLowerCase(); + if (shellName === "fish") { + return ["-ilc", "string join : $PATH"]; + } + return ["-ilc", 'printf "%s" "$PATH"']; +} + +export function normalizeShellPathOutput(raw: string): string | undefined { + const stripped = raw.replace(ANSI_ESCAPE_SEQUENCE, "").trim(); + if (stripped.length === 0) return undefined; + + const separator = stripped.includes(":") ? ":" : stripped.includes(";") ? ";" : undefined; + if (!separator) return undefined; + + const segments = stripped + .split(separator) + .map((segment) => segment.trim()) + .filter((segment) => segment.length > 0); + + if (segments.length === 0) return undefined; + return segments.join(delimiter); +} + +export function resolveLoginShellPath(shellPath: string, env: NodeJS.ProcessEnv = process.env) { + const result = execFileSync(shellPath, [...getShellProbe(shellPath)], { + encoding: "utf8", + timeout: 5000, + env: { + ...env, + TERM: "dumb", + }, + }); + + return normalizeShellPathOutput(result); +}