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
37 changes: 23 additions & 14 deletions apps/server/src/os-jank.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import * as NodeOS from "node:os";
import * as Data from "effect/Data";
import * as Effect from "effect/Effect";
import * as Path from "effect/Path";
import {
Expand All @@ -15,15 +16,19 @@ import {
type WindowsCommandAvailabilityChecker = (
command: string,
options?: CommandAvailabilityOptions,
) => boolean;
) => Effect.Effect<boolean, never>;

class ShellPathReadError extends Data.TaggedError("ShellPathReadError")<{
readonly cause: unknown;
}> {}

function logPathHydrationWarning(message: string, error?: unknown): void {
process.stderr.write(
`[server] ${message} ${error instanceof Error ? error.message : (error ?? "")}\n`,
);
}

export function fixPath(
export const fixPath = Effect.fn("fixPath")(function* (
options: {
env?: NodeJS.ProcessEnv;
platform?: NodeJS.Platform;
Expand All @@ -34,15 +39,15 @@ export function fixPath(
userShell?: string;
logWarning?: (message: string, error?: unknown) => void;
} = {},
): void {
) {
const platform = options.platform ?? process.platform;
const env = options.env ?? process.env;
const logWarning = options.logWarning ?? logPathHydrationWarning;
const readPath = options.readPath ?? readPathFromLoginShell;

try {
yield* Effect.gen(function* () {
if (platform === "win32") {
const repairedEnvironment = resolveWindowsEnvironment(env, {
const repairedEnvironment = yield* resolveWindowsEnvironment(env, {
readEnvironment: options.readWindowsEnvironment ?? readEnvironmentFromWindowsShell,
...(options.isWindowsCommandAvailable
? { commandAvailable: options.isWindowsCommandAvailable }
Expand All @@ -60,11 +65,17 @@ export function fixPath(

let shellPath: string | undefined;
for (const shell of listLoginShellCandidates(platform, env.SHELL, options.userShell)) {
try {
shellPath = readPath(shell);
} catch (error) {
logWarning(`Failed to read PATH from login shell ${shell}.`, error);
}
shellPath = yield* Effect.try({
try: () => readPath(shell),
catch: (error) => new ShellPathReadError({ cause: error }),
}).pipe(
Effect.catchTag("ShellPathReadError", (error) =>
Effect.sync(() => {
logWarning(`Failed to read PATH from login shell ${shell}.`, error.cause);
return undefined;
}),
),
);

if (shellPath) {
break;
Expand All @@ -79,10 +90,8 @@ export function fixPath(
if (mergedPath) {
env.PATH = mergedPath;
}
} catch (error) {
logWarning("Failed to hydrate PATH from the user environment.", error);
}
}
});
});

export const expandHomePath = Effect.fn(function* (input: string) {
const { join } = yield* Path.Path;
Expand Down
45 changes: 26 additions & 19 deletions apps/server/src/process/externalLauncher.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -672,17 +672,22 @@ it.layer(NodeServices.layer)("isCommandAvailable", (it) => {
PATH: dir,
PATHEXT: ".COM;.EXE;.BAT;.CMD",
} satisfies NodeJS.ProcessEnv;
assert.equal(isCommandAvailable("code", { platform: "win32", env }), true);
assert.equal(yield* isCommandAvailable("code", { platform: "win32", env }), true);
}),
);

it("returns false when a command is not on PATH", () => {
const env = {
PATH: "",
PATHEXT: ".COM;.EXE;.BAT;.CMD",
} satisfies NodeJS.ProcessEnv;
assert.equal(isCommandAvailable("definitely-not-installed", { platform: "win32", env }), false);
});
it.effect("returns false when a command is not on PATH", () =>
Effect.gen(function* () {
const env = {
PATH: "",
PATHEXT: ".COM;.EXE;.BAT;.CMD",
} satisfies NodeJS.ProcessEnv;
assert.equal(
yield* isCommandAvailable("definitely-not-installed", { platform: "win32", env }),
false,
);
}),
);

it.effect("does not treat bare files without executable extension as available on win32", () =>
Effect.gen(function* () {
Expand All @@ -694,7 +699,7 @@ it.layer(NodeServices.layer)("isCommandAvailable", (it) => {
PATH: dir,
PATHEXT: ".COM;.EXE;.BAT;.CMD",
} satisfies NodeJS.ProcessEnv;
assert.equal(isCommandAvailable("npm", { platform: "win32", env }), false);
assert.equal(yield* isCommandAvailable("npm", { platform: "win32", env }), false);
}),
);

Expand All @@ -708,7 +713,7 @@ it.layer(NodeServices.layer)("isCommandAvailable", (it) => {
PATH: dir,
PATHEXT: ".COM;.EXE;.BAT;.CMD",
} satisfies NodeJS.ProcessEnv;
assert.equal(isCommandAvailable("my.tool", { platform: "win32", env }), true);
assert.equal(yield* isCommandAvailable("my.tool", { platform: "win32", env }), true);
}),
);

Expand All @@ -724,7 +729,7 @@ it.layer(NodeServices.layer)("isCommandAvailable", (it) => {
PATH: `${firstDir};${secondDir}`,
PATHEXT: ".COM;.EXE;.BAT;.CMD",
} satisfies NodeJS.ProcessEnv;
assert.equal(isCommandAvailable("code", { platform: "win32", env }), true);
assert.equal(yield* isCommandAvailable("code", { platform: "win32", env }), true);
}),
);
});
Expand Down Expand Up @@ -752,7 +757,7 @@ it.layer(NodeServices.layer)("resolveAvailableEditors", (it) => {
yield* fs.writeFileString(path.join(dir, "rustrover.CMD"), "@echo off\r\n");
yield* fs.writeFileString(path.join(dir, "webstorm.CMD"), "@echo off\r\n");
yield* fs.writeFileString(path.join(dir, "explorer.CMD"), "MZ");
const editors = resolveAvailableEditors("win32", {
const editors = yield* resolveAvailableEditors("win32", {
PATH: dir,
PATHEXT: ".COM;.EXE;.BAT;.CMD",
});
Expand Down Expand Up @@ -788,17 +793,19 @@ it.layer(NodeServices.layer)("resolveAvailableEditors", (it) => {
yield* fs.chmod(path.join(dir, "zeditor"), 0o755);
yield* fs.chmod(path.join(dir, "xdg-open"), 0o755);

const editors = resolveAvailableEditors("linux", {
const editors = yield* resolveAvailableEditors("linux", {
PATH: dir,
});
assert.deepEqual(editors, ["zed", "file-manager"]);
}),
);

it("omits file-manager when the platform opener is unavailable", () => {
const editors = resolveAvailableEditors("linux", {
PATH: "",
});
assert.deepEqual(editors, []);
});
it.effect("omits file-manager when the platform opener is unavailable", () =>
Effect.gen(function* () {
const editors = yield* resolveAvailableEditors("linux", {
PATH: "",
});
assert.deepEqual(editors, []);
}),
);
});
78 changes: 49 additions & 29 deletions apps/server/src/process/externalLauncher.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,8 +16,10 @@ import { isCommandAvailable, type CommandAvailabilityOptions } from "@t3tools/sh
import * as Context from "effect/Context";
import * as Effect from "effect/Effect";
import * as Encoding from "effect/Encoding";
import * as FileSystem from "effect/FileSystem";
import * as Layer from "effect/Layer";
import * as Option from "effect/Option";
import * as Path from "effect/Path";
import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process";

// ==============================
Expand Down Expand Up @@ -109,17 +111,17 @@ function resolveEditorArgs(
return [...baseArgs, ...resolveCommandEditorArgs(editor, target)];
}

function resolveAvailableCommand(
const resolveAvailableCommand = Effect.fn("externalLauncher.resolveAvailableCommand")(function* (
commands: ReadonlyArray<string>,
options: CommandAvailabilityOptions = {},
): Option.Option<string> {
): Effect.fn.Return<Option.Option<string>, never, FileSystem.FileSystem | Path.Path> {
for (const command of commands) {
if (isCommandAvailable(command, options)) {
if (yield* isCommandAvailable(command, options)) {
return Option.some(command);
}
}
return Option.none();
}
});

function encodeUtf16LeBase64(input: string): string {
const bytes = new Uint8Array(input.length * 2);
Expand Down Expand Up @@ -212,29 +214,31 @@ export function resolveBrowserLaunch(
};
}

export function resolveAvailableEditors(
platform: NodeJS.Platform = process.platform,
env: NodeJS.ProcessEnv = process.env,
): ReadonlyArray<EditorId> {
const available: EditorId[] = [];
export const resolveAvailableEditors = Effect.fn("externalLauncher.resolveAvailableEditors")(
function* (
platform: NodeJS.Platform = process.platform,
env: NodeJS.ProcessEnv = process.env,
): Effect.fn.Return<ReadonlyArray<EditorId>, never, FileSystem.FileSystem | Path.Path> {
const available: EditorId[] = [];

for (const editor of EDITORS) {
if (editor.commands === null) {
const command = fileManagerCommandForPlatform(platform);
if (yield* isCommandAvailable(command, { platform, env })) {
available.push(editor.id);
}
continue;
}

for (const editor of EDITORS) {
if (editor.commands === null) {
const command = fileManagerCommandForPlatform(platform);
if (isCommandAvailable(command, { platform, env })) {
const command = yield* resolveAvailableCommand(editor.commands, { platform, env });
if (Option.isSome(command)) {
available.push(editor.id);
}
continue;
}

const command = resolveAvailableCommand(editor.commands, { platform, env });
if (Option.isSome(command)) {
available.push(editor.id);
}
}

return available;
}
return available;
},
);

/**
* ExternalLauncherShape - Service API for browser and editor launch actions.
Expand Down Expand Up @@ -268,7 +272,7 @@ export const resolveEditorLaunch = Effect.fn("resolveEditorLaunch")(function* (
input: LaunchEditorInput,
platform: NodeJS.Platform = process.platform,
env: NodeJS.ProcessEnv = process.env,
): Effect.fn.Return<EditorLaunch, ExternalLauncherError> {
): Effect.fn.Return<EditorLaunch, ExternalLauncherError, FileSystem.FileSystem | Path.Path> {
yield* Effect.annotateCurrentSpan({
"externalLauncher.editor": input.editor,
"externalLauncher.cwd": input.cwd,
Expand All @@ -281,7 +285,7 @@ export const resolveEditorLaunch = Effect.fn("resolveEditorLaunch")(function* (

if (editorDef.commands) {
const command = Option.getOrElse(
resolveAvailableCommand(editorDef.commands, { platform, env }),
yield* resolveAvailableCommand(editorDef.commands, { platform, env }),
() => editorDef.commands[0],
);
return {
Expand Down Expand Up @@ -320,8 +324,12 @@ export const launchBrowser = Effect.fn("externalLauncher.launchBrowser")(functio

export const launchEditorProcess = Effect.fn("externalLauncher.launchEditorProcess")(function* (
launch: EditorLaunch,
): Effect.fn.Return<void, ExternalLauncherError, ChildProcessSpawner.ChildProcessSpawner> {
if (!isCommandAvailable(launch.command)) {
): Effect.fn.Return<
void,
ExternalLauncherError,
ChildProcessSpawner.ChildProcessSpawner | FileSystem.FileSystem | Path.Path
> {
if (!(yield* isCommandAvailable(launch.command))) {
return yield* new ExternalLauncherError({
message: `Editor command not found: ${launch.command}`,
});
Expand All @@ -346,16 +354,28 @@ export const launchEditorProcess = Effect.fn("externalLauncher.launchEditorProce

const make = Effect.gen(function* () {
const spawner = yield* ChildProcessSpawner.ChildProcessSpawner;
const fileSystem = yield* FileSystem.FileSystem;
const path = yield* Path.Path;

const provideCommandResolutionServices = <A, E, R>(
effect: Effect.Effect<A, E, R | FileSystem.FileSystem | Path.Path>,
) =>
effect.pipe(
Effect.provideService(FileSystem.FileSystem, fileSystem),
Effect.provideService(Path.Path, path),
);

return {
launchBrowser: (target) =>
launchBrowser(target).pipe(
Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner),
),
launchEditor: (input) =>
Effect.flatMap(resolveEditorLaunch(input), (launch) =>
launchEditorProcess(launch).pipe(
Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner),
provideCommandResolutionServices(
Effect.flatMap(resolveEditorLaunch(input), (launch) =>
launchEditorProcess(launch).pipe(
Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner),
),
),
),
} satisfies ExternalLauncherShape;
Expand Down
10 changes: 5 additions & 5 deletions apps/server/src/provider/providerMaintenance.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -145,7 +145,7 @@ it.layer(NodeServices.layer)("providerMaintenance", (it) => {
chmodSync(packageToolPath, 0o755);

expect(
packageToolUpdate.resolve({
yield* resolveProviderMaintenanceCapabilitiesEffect(packageToolUpdate, {
binaryPath: "package-tool",
platform: "darwin",
env: {
Expand Down Expand Up @@ -178,7 +178,7 @@ it.layer(NodeServices.layer)("providerMaintenance", (it) => {
writeFileSync(path.join(bunBinDir, "native-package-tool.exe"), "MZ");

expect(
nativePackageToolUpdate.resolve({
yield* resolveProviderMaintenanceCapabilitiesEffect(nativePackageToolUpdate, {
binaryPath: "native-package-tool",
platform: "win32",
env: {
Expand Down Expand Up @@ -214,7 +214,7 @@ it.layer(NodeServices.layer)("providerMaintenance", (it) => {
chmodSync(scopedPackageToolPath, 0o755);

expect(
scopedPackageToolUpdate.resolve({
yield* resolveProviderMaintenanceCapabilitiesEffect(scopedPackageToolUpdate, {
binaryPath: "scoped-package-tool",
platform: "darwin",
env: {
Expand Down Expand Up @@ -273,7 +273,7 @@ it.layer(NodeServices.layer)("providerMaintenance", (it) => {
chmodSync(nativePackageToolPath, 0o755);

expect(
nativePackageToolUpdate.resolve({
yield* resolveProviderMaintenanceCapabilitiesEffect(nativePackageToolUpdate, {
binaryPath: "native-package-tool",
platform: "darwin",
env: {
Expand Down Expand Up @@ -308,7 +308,7 @@ it.layer(NodeServices.layer)("providerMaintenance", (it) => {
chmodSync(scopedPackageToolPath, 0o755);

expect(
scopedPackageToolUpdate.resolve({
yield* resolveProviderMaintenanceCapabilitiesEffect(scopedPackageToolUpdate, {
binaryPath: "scoped-package-tool",
platform: "darwin",
env: {
Expand Down
10 changes: 4 additions & 6 deletions apps/server/src/provider/providerMaintenance.ts
Original file line number Diff line number Diff line change
Expand Up @@ -251,10 +251,7 @@ export function resolvePackageManagedProviderMaintenance(
}

const resolvedCommandPath =
resolveCommandPath(binaryPath, {
...(options?.platform ? { platform: options.platform } : {}),
...(options?.env ? { env: options.env } : {}),
}) ?? (hasPathSeparator(binaryPath) ? binaryPath : null);
options?.realCommandPath ?? (hasPathSeparator(binaryPath) ? binaryPath : null);

if (resolvedCommandPath) {
const commandPaths = [
Expand Down Expand Up @@ -336,10 +333,11 @@ export const resolveProviderMaintenanceCapabilitiesEffect = Effect.fn(
}

const resolvedCommandPath =
resolveCommandPath(binaryPath, {
(yield* resolveCommandPath(binaryPath, {
...(options?.platform ? { platform: options.platform } : {}),
...(options?.env ? { env: options.env } : {}),
}) ?? (hasPathSeparator(binaryPath) ? binaryPath : null);
}).pipe(Effect.catchTag("CommandResolutionError", () => Effect.succeed(null)))) ??
(hasPathSeparator(binaryPath) ? binaryPath : null);
if (!resolvedCommandPath) {
return resolver.resolve(options);
}
Expand Down
Loading
Loading