Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 4 additions & 7 deletions apps/desktop/src/fixPath.ts
Original file line number Diff line number Diff line change
@@ -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"], {

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this was what broke in my local machine:
fish -ilc 'echo -n $PATH' gives space separated list of paths

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;
}
Expand Down
15 changes: 10 additions & 5 deletions apps/server/src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -235,12 +235,10 @@ export const recordStartupHeartbeat = Effect.gen(function* () {
});
});

const makeServerProgram = (input: CliInput) =>
const makeServerProgram = () =>

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

makeServerProgram no longer needs CLI args directly, as

  1. CLI handler runs fixPath() first.
  2. Then it provides LayerLive(input).
  3. ProviderHealthLive now starts after PATH is normalized.
  4. codex spawn checks run against corrected PATH.

Effect.gen(function* () {
const cliConfig = yield* CliConfig;
const { start, stopSignal } = yield* Server;
const openDeps = yield* Open;
yield* cliConfig.fixPath;

const config = yield* ServerConfig;

Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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)));
}),
),
),
);
12 changes: 5 additions & 7 deletions apps/server/src/os-jank.ts
Original file line number Diff line number Diff line change
@@ -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;
}
Expand Down
4 changes: 4 additions & 0 deletions packages/shared/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
26 changes: 26 additions & 0 deletions packages/shared/src/shellPath.test.ts
Original file line number Diff line number Diff line change
@@ -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",
);
});
42 changes: 42 additions & 0 deletions packages/shared/src/shellPath.ts
Original file line number Diff line number Diff line change
@@ -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<string> {
const shellName = basename(shellPath).toLowerCase();
if (shellName === "fish") {
return ["-ilc", "string join : $PATH"];

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

fish -ilc 'string join ":" "$PATH"' gives colon separated list of paths, like bash/zsh

}
return ["-ilc", 'printf "%s" "$PATH"'];

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

echo behaves differently depending on the shell
updated to use printf for consistency, as it's part of the POSIX standard

}

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);
}
Comment thread
baggiiiie marked this conversation as resolved.

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);
}