Skip to content
Merged
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
28 changes: 24 additions & 4 deletions apps/desktop/src/syncShellEnvironment.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -62,14 +62,13 @@ describe("syncShellEnvironment", () => {
expect(env.SSH_AUTH_SOCK).toBe("/tmp/inherited.sock");
});

it("does nothing outside macOS", () => {
it("hydrates PATH and missing SSH_AUTH_SOCK from the login shell on linux", () => {
const env: NodeJS.ProcessEnv = {
SHELL: "/bin/zsh",
PATH: "/usr/bin",
SSH_AUTH_SOCK: "/tmp/inherited.sock",
};
const readEnvironment = vi.fn(() => ({
PATH: "/opt/homebrew/bin:/usr/bin",
PATH: "/home/linuxbrew/.linuxbrew/bin:/usr/bin",
SSH_AUTH_SOCK: "/tmp/secretive.sock",
}));

Expand All @@ -78,8 +77,29 @@ describe("syncShellEnvironment", () => {
readEnvironment,
});

expect(readEnvironment).toHaveBeenCalledWith("/bin/zsh", ["PATH", "SSH_AUTH_SOCK"]);
expect(env.PATH).toBe("/home/linuxbrew/.linuxbrew/bin:/usr/bin");
expect(env.SSH_AUTH_SOCK).toBe("/tmp/secretive.sock");
});

it("does nothing outside macOS and linux", () => {
const env: NodeJS.ProcessEnv = {
SHELL: "C:/Program Files/Git/bin/bash.exe",
PATH: "C:\\Windows\\System32",
SSH_AUTH_SOCK: "/tmp/inherited.sock",
};
const readEnvironment = vi.fn(() => ({
PATH: "/usr/local/bin:/usr/bin",
SSH_AUTH_SOCK: "/tmp/secretive.sock",
}));

syncShellEnvironment(env, {
platform: "win32",
readEnvironment,
});

expect(readEnvironment).not.toHaveBeenCalled();
expect(env.PATH).toBe("/usr/bin");
expect(env.PATH).toBe("C:\\Windows\\System32");
expect(env.SSH_AUTH_SOCK).toBe("/tmp/inherited.sock");
});
});
13 changes: 10 additions & 3 deletions apps/desktop/src/syncShellEnvironment.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,8 @@
import { readEnvironmentFromLoginShell, ShellEnvironmentReader } from "@t3tools/shared/shell";
import {
readEnvironmentFromLoginShell,
resolveLoginShell,
ShellEnvironmentReader,
} from "@t3tools/shared/shell";

export function syncShellEnvironment(
env: NodeJS.ProcessEnv = process.env,
Expand All @@ -7,10 +11,13 @@ export function syncShellEnvironment(
readEnvironment?: ShellEnvironmentReader;
} = {},
): void {
if ((options.platform ?? process.platform) !== "darwin") return;
const platform = options.platform ?? process.platform;
if (platform !== "darwin" && platform !== "linux") return;

try {
const shell = env.SHELL ?? "/bin/zsh";
const shell = resolveLoginShell(platform, env.SHELL);
if (!shell) return;

const shellEnvironment = (options.readEnvironment ?? readEnvironmentFromLoginShell)(shell, [
"PATH",
"SSH_AUTH_SOCK",
Expand Down
19 changes: 18 additions & 1 deletion apps/server/src/main.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import { ServerSettingsService } from "./serverSettings";

const start = vi.fn(() => undefined);
const stop = vi.fn(() => undefined);
const fixPath = vi.fn(() => undefined);
let resolvedConfig: ServerConfigShape | null = null;
const serverStart = Effect.acquireRelease(
Effect.gen(function* () {
Expand All @@ -36,7 +37,7 @@ const findAvailablePort = vi.fn((preferred: number) => Effect.succeed(preferred)
const testLayer = Layer.mergeAll(
Layer.succeed(CliConfig, {
cwd: "/tmp/t3-test-workspace",
fixPath: Effect.void,
fixPath: Effect.sync(fixPath),
resolveStaticDir: Effect.undefined,
} satisfies CliConfigShape),
Layer.succeed(NetService, {
Expand Down Expand Up @@ -81,6 +82,7 @@ beforeEach(() => {
resolvedConfig = null;
start.mockImplementation(() => undefined);
stop.mockImplementation(() => undefined);
fixPath.mockImplementation(() => undefined);
findAvailablePort.mockImplementation((preferred: number) => Effect.succeed(preferred));
});

Expand Down Expand Up @@ -329,6 +331,21 @@ it.layer(testLayer)("server CLI command", (it) => {
}),
);

it.effect("hydrates PATH before server startup", () =>
Effect.gen(function* () {
yield* runCli([]);

assert.equal(fixPath.mock.calls.length, 1);
assert.equal(start.mock.calls.length, 1);
const fixPathOrder = fixPath.mock.invocationCallOrder[0];
const startOrder = start.mock.invocationCallOrder[0];
if (typeof fixPathOrder !== "number" || typeof startOrder !== "number") {
assert.fail("Expected fixPath and start to be called");
}
assert.isTrue(fixPathOrder < startOrder);
}),
);

it.effect("records a startup heartbeat with thread/project counts", () =>
Effect.gen(function* () {
const recordTelemetry = vi.fn(
Expand Down
39 changes: 39 additions & 0 deletions apps/server/src/os-jank.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
import { describe, expect, it, vi } from "vitest";

import { fixPath } from "./os-jank";

describe("fixPath", () => {
it("hydrates PATH on linux using the resolved login shell", () => {
const env: NodeJS.ProcessEnv = {
SHELL: "/bin/zsh",
PATH: "/usr/bin",
};
const readPath = vi.fn(() => "/opt/homebrew/bin:/usr/bin");

fixPath({
env,
platform: "linux",
readPath,
});

expect(readPath).toHaveBeenCalledWith("/bin/zsh");
expect(env.PATH).toBe("/opt/homebrew/bin:/usr/bin");
});

it("does nothing outside macOS and linux even when SHELL is set", () => {
const env: NodeJS.ProcessEnv = {
SHELL: "C:/Program Files/Git/bin/bash.exe",
PATH: "C:\\Windows\\System32",
};
const readPath = vi.fn(() => "/usr/local/bin:/usr/bin");

fixPath({
env,
platform: "win32",
readPath,
});

expect(readPath).not.toHaveBeenCalled();
expect(env.PATH).toBe("C:\\Windows\\System32");
});
});
22 changes: 16 additions & 6 deletions apps/server/src/os-jank.ts
Original file line number Diff line number Diff line change
@@ -1,15 +1,25 @@
import * as OS from "node:os";
import { Effect, Path } from "effect";
import { readPathFromLoginShell } from "@t3tools/shared/shell";
import { readPathFromLoginShell, resolveLoginShell } from "@t3tools/shared/shell";

export function fixPath(): void {
if (process.platform !== "darwin") return;
export function fixPath(
options: {
env?: NodeJS.ProcessEnv;
platform?: NodeJS.Platform;
readPath?: typeof readPathFromLoginShell;
} = {},
): void {
const platform = options.platform ?? process.platform;
if (platform !== "darwin" && platform !== "linux") return;

const env = options.env ?? process.env;

try {
const shell = process.env.SHELL ?? "/bin/zsh";
const result = readPathFromLoginShell(shell);
const shell = resolveLoginShell(platform, env.SHELL);
if (!shell) return;
const result = (options.readPath ?? readPathFromLoginShell)(shell);
if (result) {
process.env.PATH = result;
env.PATH = result;
}
} catch {
// Silently ignore — keep default PATH
Expand Down
20 changes: 20 additions & 0 deletions packages/shared/src/shell.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,26 @@ type ExecFileSyncLike = (
options: { encoding: "utf8"; timeout: number },
) => string;

export function resolveLoginShell(
platform: NodeJS.Platform,
shell: string | undefined,
): string | undefined {
const trimmedShell = shell?.trim();
if (trimmedShell) {
return trimmedShell;
}

if (platform === "darwin") {
return "/bin/zsh";
}

if (platform === "linux") {
return "/bin/bash";
}

return undefined;
}

export function extractPathFromShellOutput(output: string): string | null {
const startIndex = output.indexOf(PATH_CAPTURE_START);
if (startIndex === -1) return null;
Expand Down