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
4 changes: 2 additions & 2 deletions apps/server/src/preview/PortScanner.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,9 @@ import * as Net from "@t3tools/shared/Net";
import { Effect, Layer } from "effect";
import { expect } from "vite-plus/test";

import { ProcessRunner } from "../processRunner.ts";
import * as ProcessRunner from "../processRunner.ts";
import * as PortScanner from "./PortScanner.ts";
const TestProcessRunner = Layer.succeed(ProcessRunner, {
const TestProcessRunner = Layer.succeed(ProcessRunner.ProcessRunner, {
run: () => Effect.die("ProcessRunner should not be used by Windows TCP probe tests"),
});
const TestPortDiscoveryLive = PortScanner.layer.pipe(
Expand Down
20 changes: 11 additions & 9 deletions apps/server/src/process/externalLauncher.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process";

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

function makeMockDetachedHandle(onUnref: () => void = () => undefined) {
return ChildProcessSpawner.makeHandle({
Expand Down Expand Up @@ -54,7 +54,7 @@ const testLayer = (input: {
);

return Layer.mergeAll(
ExternalLauncherLive.pipe(Layer.provide(Layer.merge(NodeServices.layer, spawnerLayer))),
ExternalLauncher.layer.pipe(Layer.provide(Layer.merge(NodeServices.layer, spawnerLayer))),
Layer.succeed(HostProcessPlatform, input.platform),
Layer.succeed(
SpawnExecutableResolution,
Expand All @@ -68,7 +68,7 @@ it.effect("launches the default browser through the platform command", () => {
let spawned: ChildProcess.StandardCommand | undefined;
let didUnref = false;
return Effect.gen(function* () {
const launcher = yield* ExternalLauncher;
const launcher = yield* ExternalLauncher.ExternalLauncher;

yield* launcher.launchBrowser("https://example.com/some path");

Expand Down Expand Up @@ -101,7 +101,7 @@ it.effect("launches an installed editor with platform-safe arguments", () =>

let spawned: ChildProcess.StandardCommand | undefined;
yield* Effect.gen(function* () {
const launcher = yield* ExternalLauncher;
const launcher = yield* ExternalLauncher.ExternalLauncher;
yield* launcher.launchEditor({
editor: "vscode",
cwd: "C:\\workspace with spaces\\src\\index.ts:12:4",
Expand Down Expand Up @@ -139,7 +139,7 @@ it.effect("discovers editors through the service API", () =>
yield* fileSystem.writeFileString(path.join(binDir, "explorer.CMD"), "@echo off\r\n");

const editors = yield* Effect.gen(function* () {
const launcher = yield* ExternalLauncher;
const launcher = yield* ExternalLauncher.ExternalLauncher;
return yield* launcher.resolveAvailableEditors();
}).pipe(
Effect.provide(
Expand All @@ -157,10 +157,12 @@ it.effect("discovers editors through the service API", () =>

it.effect("rejects unknown editors through the service API", () =>
Effect.gen(function* () {
const launcher = yield* ExternalLauncher;
const result = yield* launcher
const launcher = yield* ExternalLauncher.ExternalLauncher;
const error = yield* launcher
.launchEditor({ editor: "missing-editor" as never, cwd: "/tmp/workspace" })
.pipe(Effect.result);
assert.equal(result._tag, "Failure");
.pipe(Effect.flip);
assert.instanceOf(error, ExternalLauncher.ExternalLauncherUnknownEditorError);
assert.equal(error.editor, "missing-editor");
assert.equal(error.message, "Unknown editor: missing-editor");
}).pipe(Effect.provide(testLayer({ platform: "linux", env: { PATH: "" } }))),
);
59 changes: 49 additions & 10 deletions apps/server/src/process/externalLauncher.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,11 @@
import {
EDITORS,
ExternalLauncherError,
ExternalLauncherBrowserSpawnError,
ExternalLauncherCommandNotFoundError,
ExternalLauncherEditorSpawnError,
ExternalLauncherUnknownEditorError,
ExternalLauncherUnsupportedEditorError,
type EditorId,
type LaunchEditorInput,
} from "@t3tools/contracts";
Expand All @@ -29,9 +34,19 @@ import * as ChildProcessSpawner from "effect/unstable/process/ChildProcessSpawne
// Definitions
// ==============================

export { ExternalLauncherError };
export {
ExternalLauncherError,
ExternalLauncherBrowserSpawnError,
ExternalLauncherCommandNotFoundError,
ExternalLauncherEditorSpawnError,
ExternalLauncherUnknownEditorError,
ExternalLauncherUnsupportedEditorError,
isExternalLauncherError,
} from "@t3tools/contracts";
export type { LaunchEditorInput };
interface EditorLaunch {
readonly editor: EditorId;
readonly target: string;
readonly command: string;
readonly args: ReadonlyArray<string>;
}
Expand Down Expand Up @@ -317,7 +332,7 @@ const resolveEditorLaunch = Effect.fn("resolveEditorLaunch")(function* (
});
const editorDef = EDITORS.find((editor) => editor.id === input.editor);
if (!editorDef) {
return yield* new ExternalLauncherError({ message: `Unknown editor: ${input.editor}` });
return yield* new ExternalLauncherUnknownEditorError({ editor: input.editor });
}

if (editorDef.commands) {
Expand All @@ -326,21 +341,28 @@ const resolveEditorLaunch = Effect.fn("resolveEditorLaunch")(function* (
() => editorDef.commands[0],
);
return {
editor: editorDef.id,
target: input.cwd,
command,
args: resolveEditorArgs(editorDef, input.cwd),
};
}

if (editorDef.id !== "file-manager") {
return yield* new ExternalLauncherError({ message: `Unsupported editor: ${input.editor}` });
return yield* new ExternalLauncherUnsupportedEditorError({ editor: input.editor });
}

return { command: fileManagerCommandForPlatform(platform), args: [input.cwd] };
return {
editor: editorDef.id,
target: input.cwd,
command: fileManagerCommandForPlatform(platform),
args: [input.cwd],
};
});

const launchAndUnref = Effect.fn("externalLauncher.launchAndUnref")(function* (
launch: ProcessLaunch,
errorMessage: string,
onError: (cause: unknown) => ExternalLauncherError,
): Effect.fn.Return<void, ExternalLauncherError, ChildProcessSpawner.ChildProcessSpawner> {
const spawner = yield* ChildProcessSpawner.ChildProcessSpawner;
const command = ChildProcess.make(launch.command, launch.args, launch.options);
Expand All @@ -349,15 +371,24 @@ const launchAndUnref = Effect.fn("externalLauncher.launchAndUnref")(function* (
Effect.flatMap((handle) => handle.unref),
Effect.asVoid,
Effect.scoped,
Effect.mapError((cause) => new ExternalLauncherError({ message: errorMessage, cause })),
Effect.mapError(onError),
);
});

const launchBrowser = Effect.fn("externalLauncher.launchBrowser")(function* (
target: string,
): Effect.fn.Return<void, ExternalLauncherError, ChildProcessSpawner.ChildProcessSpawner> {
const launch = yield* resolveBrowserLaunch(target);
return yield* launchAndUnref(launch, "Browser auto-open failed");
return yield* launchAndUnref(
launch,
(cause) =>
new ExternalLauncherBrowserSpawnError({
target,
command: launch.command,
args: launch.args,
cause,
}),
);
});

const launchEditorProcess = Effect.fn("externalLauncher.launchEditorProcess")(function* (
Expand All @@ -369,8 +400,9 @@ const launchEditorProcess = Effect.fn("externalLauncher.launchEditorProcess")(fu
> {
const env = yield* readCommandLookupEnv;
if (!(yield* isCommandAvailable(launch.command, { env }))) {
return yield* new ExternalLauncherError({
message: `Editor command not found: ${launch.command}`,
return yield* new ExternalLauncherCommandNotFoundError({
editor: launch.editor,
command: launch.command,
});
}

Expand All @@ -387,7 +419,14 @@ const launchEditorProcess = Effect.fn("externalLauncher.launchEditorProcess")(fu
stderr: "ignore",
},
},
"failed to spawn detached process",
(cause) =>
new ExternalLauncherEditorSpawnError({
editor: launch.editor,
target: launch.target,
command: spawnCommand.command,
args: spawnCommand.args,
cause,
}),
);
});

Expand Down
7 changes: 4 additions & 3 deletions apps/server/src/server.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ import {
GitCommandError,
KeybindingRule,
MessageId,
ExternalLauncherError,
ExternalLauncherCommandNotFoundError,
type OrchestrationThreadShell,
TerminalNotRunningError,
type OrchestrationCommand,
Expand Down Expand Up @@ -4570,8 +4570,9 @@ it.layer(NodeServices.layer)("server router seam", (it) => {

it.effect("routes websocket rpc shell.openInEditor errors", () =>
Effect.gen(function* () {
const externalLauncherError = new ExternalLauncherError({
message: "Editor command not found: cursor",
const externalLauncherError = new ExternalLauncherCommandNotFoundError({
editor: "cursor",
command: "cursor",
});
yield* buildAppUnderTest({
layers: {
Expand Down
78 changes: 73 additions & 5 deletions packages/contracts/src/editor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,10 +50,78 @@ export const LaunchEditorInput = Schema.Struct({
});
export type LaunchEditorInput = typeof LaunchEditorInput.Type;

export class ExternalLauncherError extends Schema.TaggedErrorClass<ExternalLauncherError>()(
"ExternalLauncherError",
export class ExternalLauncherUnknownEditorError extends Schema.TaggedErrorClass<ExternalLauncherUnknownEditorError>()(
"ExternalLauncherUnknownEditorError",
{
message: Schema.String,
cause: Schema.optional(Schema.Defect()),
editor: Schema.String,
},
) {}
) {
override get message(): string {
return `Unknown editor: ${this.editor}`;
}
}

export class ExternalLauncherUnsupportedEditorError extends Schema.TaggedErrorClass<ExternalLauncherUnsupportedEditorError>()(
"ExternalLauncherUnsupportedEditorError",
{
editor: EditorId,
},
) {
override get message(): string {
return `Unsupported editor: ${this.editor}`;
}
}

export class ExternalLauncherCommandNotFoundError extends Schema.TaggedErrorClass<ExternalLauncherCommandNotFoundError>()(
"ExternalLauncherCommandNotFoundError",
{
editor: EditorId,
command: Schema.String,
},
) {
override get message(): string {
return `Editor command not found: ${this.command}`;
}
}

const ExternalLauncherSpawnFields = {
command: Schema.String,
args: Schema.Array(Schema.String),
cause: Schema.Defect(),
};

export class ExternalLauncherBrowserSpawnError extends Schema.TaggedErrorClass<ExternalLauncherBrowserSpawnError>()(
"ExternalLauncherBrowserSpawnError",
{
...ExternalLauncherSpawnFields,
target: Schema.String,
},
) {
override get message(): string {
return `Failed to launch browser target '${this.target}' with '${[this.command, ...this.args].join(" ")}'`;
}
}

export class ExternalLauncherEditorSpawnError extends Schema.TaggedErrorClass<ExternalLauncherEditorSpawnError>()(
"ExternalLauncherEditorSpawnError",
{
...ExternalLauncherSpawnFields,
editor: EditorId,
target: Schema.String,
},
) {
override get message(): string {
return `Failed to launch '${this.target}' in ${this.editor} with '${[this.command, ...this.args].join(" ")}'`;
}
}

export const ExternalLauncherError = Schema.Union([
ExternalLauncherUnknownEditorError,
ExternalLauncherUnsupportedEditorError,
ExternalLauncherCommandNotFoundError,
ExternalLauncherBrowserSpawnError,
ExternalLauncherEditorSpawnError,
]);
export type ExternalLauncherError = typeof ExternalLauncherError.Type;

export const isExternalLauncherError = Schema.is(ExternalLauncherError);
Loading