Skip to content
Open
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
54 changes: 35 additions & 19 deletions apps/server/src/process/externalLauncher.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,6 @@ import * as NodeOS from "node:os";
import * as NodePath from "node:path";
import * as NodeServices from "@effect/platform-node/NodeServices";
import { assert, it } from "@effect/vitest";
import * as ConfigProvider from "effect/ConfigProvider";
import * as Effect from "effect/Effect";
import * as Fiber from "effect/Fiber";
import * as FileSystem from "effect/FileSystem";
Expand All @@ -16,7 +15,7 @@ import * as Stream from "effect/Stream";
import * as TestClock from "effect/testing/TestClock";
import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process";

import { HostProcessPlatform } from "@t3tools/shared/hostProcess";
import { HostProcessEnvironment, HostProcessPlatform } from "@t3tools/shared/hostProcess";
import { SpawnExecutableResolution } from "@t3tools/shared/shell";
import * as ExternalLauncher from "./externalLauncher.ts";

Expand Down Expand Up @@ -89,7 +88,7 @@ const testLayer = (input: {
SpawnExecutableResolution,
(command) => input.resolveExecutable?.(command) ?? command,
),
ConfigProvider.layer(ConfigProvider.fromEnv({ env: input.env ?? {} })),
Layer.succeed(HostProcessEnvironment, input.env ?? {}),
);
};

Expand Down Expand Up @@ -716,6 +715,31 @@ it.effect.skipIf(windowsHost)("reveals by opening the containing directory on Li
}).pipe(Effect.scoped, Effect.provide(NodeServices.layer)),
);

// A Dock/Finder launch starts with the bare launchd PATH and `fixPath` hydrates
// process.env in place afterwards; discovery has to see the hydrated value.
it.effect.skipIf(windowsHost)("discovers editors on a PATH hydrated after startup", () =>
Effect.gen(function* () {
const fileSystem = yield* FileSystem.FileSystem;
const path = yield* Path.Path;
const binDir = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-editors-" });
const cursorPath = path.join(binDir, "cursor");
yield* fileSystem.writeFileString(cursorPath, "#!/bin/sh\n");
yield* fileSystem.chmod(cursorPath, 0o755);

const emptyDir = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-editors-empty-" });
const env: Record<string, string> = { PATH: emptyDir };
const editors = yield* Effect.gen(function* () {
const launcher = yield* ExternalLauncher.ExternalLauncher;
env.PATH = `${binDir}:${emptyDir}`;
return yield* launcher.resolveAvailableEditors();
}).pipe(Effect.provide(testLayer({ platform: "linux", env })));

// Exact match: a lookup that fell back to the ambient process PATH would
// also report whatever editors the host machine has installed.
assert.deepEqual(editors, ["cursor"]);
}).pipe(Effect.scoped, Effect.provide(NodeServices.layer)),
);

it.effect.skipIf(windowsHost)(
"does not advertise a Linux file manager without a graphical session",
() =>
Expand Down Expand Up @@ -968,14 +992,10 @@ it.effect("memoizes editor discovery and refreshes after the cache window", () =
Layer.mergeAll(
launcherLayer,
Layer.succeed(HostProcessPlatform, "win32"),
ConfigProvider.layer(
ConfigProvider.fromEnv({
env: {
PATH: "C:\\t3-editor-discovery-cache-test",
PATHEXT: ".COM;.EXE;.BAT;.CMD",
},
}),
),
Layer.succeed(HostProcessEnvironment, {
PATH: "C:\\t3-editor-discovery-cache-test",
PATHEXT: ".COM;.EXE;.BAT;.CMD",
}),
TestClock.layer(),
),
),
Expand Down Expand Up @@ -1033,14 +1053,10 @@ it.effect("rescans after an interrupted discovery instead of caching the interru
Layer.mergeAll(
launcherLayer,
Layer.succeed(HostProcessPlatform, "win32"),
ConfigProvider.layer(
ConfigProvider.fromEnv({
env: {
PATH: "C:\\t3-editor-discovery-interrupt-test",
PATHEXT: ".COM;.EXE;.BAT;.CMD",
},
}),
),
Layer.succeed(HostProcessEnvironment, {
PATH: "C:\\t3-editor-discovery-interrupt-test",
PATHEXT: ".COM;.EXE;.BAT;.CMD",
}),
),
),
);
Expand Down
54 changes: 25 additions & 29 deletions apps/server/src/process/externalLauncher.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,10 +18,9 @@ import {
type FileManagerRevealKind,
type LaunchEditorInput,
} from "@t3tools/contracts";
import { HostProcessPlatform } from "@t3tools/shared/hostProcess";
import { HostProcessEnvironment, HostProcessPlatform } from "@t3tools/shared/hostProcess";
import { isCommandAvailable, resolveSpawnCommand } from "@t3tools/shared/shell";
import * as Clock from "effect/Clock";
import * as Config from "effect/Config";
import * as Context from "effect/Context";
import * as Effect from "effect/Effect";
import * as Encoding from "effect/Encoding";
Expand Down Expand Up @@ -82,37 +81,34 @@ const DETACHED_IGNORE_STDIO_OPTIONS = {
stderr: "ignore",
} as const satisfies ChildProcess.CommandOptions;

const compactEnv = (input: Record<string, Option.Option<string>>): NodeJS.ProcessEnv =>
Object.fromEntries(
Object.entries(input).flatMap(([key, value]) =>
Option.match(value, {
onNone: () => [],
onSome: (resolved) => [[key, resolved]],
const BROWSER_LAUNCH_ENV_NAMES = [
"SYSTEMROOT",
"windir",
"WSL_DISTRO_NAME",
"WSL_INTEROP",
"SSH_CONNECTION",
"SSH_TTY",
"container",
"DISPLAY",
"WAYLAND_DISPLAY",
] as const;

const COMMAND_LOOKUP_ENV_NAMES = ["PATH", "Path", "path", "PATHEXT"] as const;

// Not Config: the default ConfigProvider snapshots process.env on first use,
// before `fixPath` hydrates PATH, so it only ever sees the bare launchd PATH.
const readHostEnv = (names: ReadonlyArray<string>) =>
Effect.map(HostProcessEnvironment, (env): NodeJS.ProcessEnv =>
Object.fromEntries(
names.flatMap((name) => {
const value = env[name];
return value === undefined || value.length === 0 ? [] : [[name, value]];
}),
),
);

const BrowserLaunchEnvConfig = Config.all({
SYSTEMROOT: Config.String("SYSTEMROOT").pipe(Config.option),
windir: Config.String("windir").pipe(Config.option),
WSL_DISTRO_NAME: Config.String("WSL_DISTRO_NAME").pipe(Config.option),
WSL_INTEROP: Config.String("WSL_INTEROP").pipe(Config.option),
SSH_CONNECTION: Config.String("SSH_CONNECTION").pipe(Config.option),
SSH_TTY: Config.String("SSH_TTY").pipe(Config.option),
container: Config.String("container").pipe(Config.option),
DISPLAY: Config.String("DISPLAY").pipe(Config.option),
WAYLAND_DISPLAY: Config.String("WAYLAND_DISPLAY").pipe(Config.option),
}).pipe(Config.map(compactEnv));

const CommandLookupEnvConfig = Config.all({
PATH: Config.String("PATH").pipe(Config.option),
Path: Config.String("Path").pipe(Config.option),
path: Config.String("path").pipe(Config.option),
PATHEXT: Config.String("PATHEXT").pipe(Config.option),
}).pipe(Config.map(compactEnv));

const readBrowserLaunchEnv = BrowserLaunchEnvConfig.pipe(Effect.orElseSucceed(() => ({})));
const readCommandLookupEnv = CommandLookupEnvConfig.pipe(Effect.orElseSucceed(() => ({})));
const readBrowserLaunchEnv = readHostEnv(BROWSER_LAUNCH_ENV_NAMES);
const readCommandLookupEnv = readHostEnv(COMMAND_LOOKUP_ENV_NAMES);

function parseTargetPathAndPosition(target: string): Option.Option<TargetPathAndPosition> {
const match = TARGET_WITH_POSITION_PATTERN.exec(target);
Expand Down
Loading