/** * Open - Browser/editor launch service interface. * * Owns process launch helpers for opening URLs in a browser and workspace * paths in a configured editor. * * @module Open */import { accessSync, constants, statSync } from "node:fs";import { extname, join } from "node:path"; import { EDITORS, type EditorId, type ServerRuntimeEnvironment } from "@t3tools/contracts";import { ServiceMap, Schema, Effect, Layer } from "effect";import { spawnDetachedProcess, spawnProcessSync } from "./processRunner";import { detectServerRuntimeEnvironment } from "./runtimeEnvironment"; // ==============================// Definitions// ============================== export class OpenError extends Schema.TaggedErrorClass<OpenError>()("OpenError", { message: Schema.String, cause: Schema.optional(Schema.Defect),}) {} export interface OpenInEditorInput { readonly cwd: string; readonly editor: EditorId;} interface EditorLaunch { readonly command: string; readonly args: ReadonlyArray<string>;} interface CommandAvailabilityOptions { readonly platform?: NodeJS.Platform; readonly env?: NodeJS.ProcessEnv;} interface ResolveOpenOptions extends CommandAvailabilityOptions { readonly runtimeEnvironment?: ServerRuntimeEnvironment; readonly translateWslPathToWindows?: (target: string) => string;} const LINE_COLUMN_SUFFIX_PATTERN = /:\d+(?::\d+)?$/;const WINDOWS_ABSOLUTE_PATH_PATTERN = /^[A-Za-z]:[\\/]/;const WINDOWS_UNC_PATH_PATTERN = /^\\\\/; function detectRuntimeEnvironment(options: ResolveOpenOptions): ServerRuntimeEnvironment { return ( options.runtimeEnvironment ?? detectServerRuntimeEnvironment({ ...(options.platform !== undefined ? { platform: options.platform } : {}), ...(options.env !== undefined ? { env: options.env } : {}), }) );} function shouldUseGotoFlag(editorId: EditorId, target: string): boolean { return (editorId === "cursor" || editorId === "vscode") && LINE_COLUMN_SUFFIX_PATTERN.test(target);} function fileManagerCommandForPlatform(platform: NodeJS.Platform): string { switch (platform) { case "darwin": return "open"; case "win32": return "explorer"; default: return "xdg-open"; }} function stripWrappingQuotes(value: string): string { return value.replace(/^"+|"+$/g, "");} function resolvePathEnvironmentVariable(env: NodeJS.ProcessEnv): string { return env.PATH ?? env.Path ?? env.path ?? "";} function resolveWindowsPathExtensions(env: NodeJS.ProcessEnv): ReadonlyArray<string> { const rawValue = env.PATHEXT; const fallback = [".COM", ".EXE", ".BAT", ".CMD"]; if (!rawValue) return fallback; const parsed = rawValue .split(";") .map((entry) => entry.trim()) .filter((entry) => entry.length > 0) .map((entry) => (entry.startsWith(".") ? entry.toUpperCase() : `.${entry.toUpperCase()}`)); return parsed.length > 0 ? Array.from(new Set(parsed)) : fallback;} function resolveCommandCandidates( command: string, platform: NodeJS.Platform, windowsPathExtensions: ReadonlyArray<string>,): ReadonlyArray<string> { if (platform !== "win32") return [command]; const extension = extname(command); const normalizedExtension = extension.toUpperCase(); if (extension.length > 0 && windowsPathExtensions.includes(normalizedExtension)) { const commandWithoutExtension = command.slice(0, -extension.length); return Array.from( new Set([ command, `${commandWithoutExtension}${normalizedExtension}`, `${commandWithoutExtension}${normalizedExtension.toLowerCase()}`, ]), ); } const candidates: string[] = []; for (const extension of windowsPathExtensions) { candidates.push(`${command}${extension}`); candidates.push(`${command}${extension.toLowerCase()}`); } return Array.from(new Set(candidates));} function isExecutableFile( filePath: string, platform: NodeJS.Platform, windowsPathExtensions: ReadonlyArray<string>,): boolean { try { const stat = statSync(filePath); if (!stat.isFile()) return false; if (platform === "win32") { const extension = extname(filePath); if (extension.length === 0) return false; return windowsPathExtensions.includes(extension.toUpperCase()); } accessSync(filePath, constants.X_OK); return true; } catch { return false; }} function resolvePathDelimiter(platform: NodeJS.Platform): string { return platform === "win32" ? ";" : ":";} export function isCommandAvailable( command: string, options: CommandAvailabilityOptions = {},): boolean { const platform = options.platform ?? process.platform; const env = options.env ?? process.env; const windowsPathExtensions = platform === "win32" ? resolveWindowsPathExtensions(env) : []; const commandCandidates = resolveCommandCandidates(command, platform, windowsPathExtensions); if (command.includes("/") || command.includes("\\")) { return commandCandidates.some((candidate) => isExecutableFile(candidate, platform, windowsPathExtensions), ); } const pathValue = resolvePathEnvironmentVariable(env); if (pathValue.length === 0) return false; const pathEntries = pathValue .split(resolvePathDelimiter(platform)) .map((entry) => stripWrappingQuotes(entry.trim())) .filter((entry) => entry.length > 0); for (const pathEntry of pathEntries) { for (const candidate of commandCandidates) { if (isExecutableFile(join(pathEntry, candidate), platform, windowsPathExtensions)) { return true; } } } return false;} export function resolveAvailableEditors( options: ResolveOpenOptions = {},): ReadonlyArray<EditorId> { const platform = options.platform ?? process.platform; const env = options.env ?? process.env; const runtimeEnvironment = detectRuntimeEnvironment({ ...options, platform, env }); const available: EditorId[] = []; for (const editor of EDITORS) { const launch = resolveOpenCommand(editor.id, { platform, env, runtimeEnvironment, }); if (launch.some((entry) => isCommandAvailable(entry, { platform, env }))) { available.push(editor.id); } } return available;} /** * OpenShape - Service API for browser and editor launch actions. */export interface OpenShape { /** * Open a URL target in the default browser. */ readonly openBrowser: (target: string) => Effect.Effect<void, OpenError>; /** * Open a workspace path in a selected editor integration. * * Launches the editor as a detached process so server startup is not blocked. */ readonly openInEditor: (input: OpenInEditorInput) => Effect.Effect<void, OpenError>;} /** * Open - Service tag for browser/editor launch operations. */export class Open extends ServiceMap.Service<Open, OpenShape>()("t3/open") {} // ==============================// Implementations// ============================== function splitPathAndPosition(value: string): { path: string; line: string | undefined; column: string | undefined;} { let path = value; let column: string | undefined; let line: string | undefined; const columnMatch = path.match(/:(\d+)$/); if (!columnMatch?.[1]) { return { path, line: undefined, column: undefined }; } column = columnMatch[1]; path = path.slice(0, -columnMatch[0].length); const lineMatch = path.match(/:(\d+)$/); if (lineMatch?.[1]) { line = lineMatch[1]; path = path.slice(0, -lineMatch[0].length); } else { line = column; column = undefined; } return { path, line, column };} function stripLineColumnSuffix(value: string): string { const { path } = splitPathAndPosition(value); return path;} function formatPathWithPosition(input: { readonly path: string; readonly line?: string; readonly column?: string;}): string { if (!input.line) { return input.path; } return `${input.path}:${input.line}${input.column ? `:${input.column}` : ""}`;} function isWindowsPath(value: string): boolean { return WINDOWS_ABSOLUTE_PATH_PATTERN.test(value) || WINDOWS_UNC_PATH_PATTERN.test(value);} function defaultTranslateWslPathToWindows(target: string): string { const parsed = splitPathAndPosition(target); if (parsed.path.length === 0 || !parsed.path.startsWith("/") || isWindowsPath(parsed.path)) { return target; } const result = spawnProcessSync("wslpath", ["-w", parsed.path], { encoding: "utf8", stdio: ["ignore", "pipe", "pipe"], }); if (result.error) { throw result.error; } if (result.status !== 0) { const detail = result.stderr.trim() || result.stdout.trim() || "wslpath failed"; throw new Error(detail); } const translatedPath = result.stdout.trim(); if (translatedPath.length === 0) { throw new Error("wslpath returned an empty path"); } return formatPathWithPosition({ path: translatedPath, ...(parsed.line ? { line: parsed.line } : {}), ...(parsed.column ? { column: parsed.column } : {}), });} function resolveOpenCommand( editorId: EditorId, options: ResolveOpenOptions,): ReadonlyArray<string> { const platform = options.platform ?? process.platform; const env = options.env ?? process.env; const runtimeEnvironment = detectRuntimeEnvironment({ ...options, platform, env }); const editorDef = EDITORS.find((editor) => editor.id === editorId); if (!editorDef) { return []; } if (editorId === "file-manager") { if (runtimeEnvironment.windowsInteropMode === "wsl-hosted") { return ["explorer.exe", "xdg-open"]; } return [fileManagerCommandForPlatform(platform)]; } if (!editorDef.command) { return []; } if (runtimeEnvironment.windowsInteropMode === "wsl-hosted") { return [editorDef.command, `${editorDef.command}.exe`]; } return [editorDef.command];} export function resolveBrowserLaunch( target: string, options: ResolveOpenOptions = {},): EditorLaunch | null { const platform = options.platform ?? process.platform; const env = options.env ?? process.env; const runtimeEnvironment = detectRuntimeEnvironment({ ...options, platform, env }); if (runtimeEnvironment.windowsInteropMode === "wsl-hosted") { if (isCommandAvailable("wslview", { platform, env })) { return { command: "wslview", args: [target] }; } if (isCommandAvailable("explorer.exe", { platform, env })) { return { command: "explorer.exe", args: [target] }; } } return null;} export const resolveEditorLaunch = Effect.fnUntraced(function* ( input: OpenInEditorInput, options: ResolveOpenOptions = {},): Effect.fn.Return<EditorLaunch, OpenError> { const platform = options.platform ?? process.platform; const env = options.env ?? process.env; const runtimeEnvironment = detectRuntimeEnvironment({ ...options, platform, env }); const editorDef = EDITORS.find((editor) => editor.id === input.editor); if (!editorDef) { return yield* new OpenError({ message: `Unknown editor: ${input.editor}` }); } const translateWslPathToWindows = options.translateWslPathToWindows ?? defaultTranslateWslPathToWindows; if (editorDef.command) { const candidateCommands = resolveOpenCommand(editorDef.id, { platform, env, runtimeEnvironment, }); const command = candidateCommands.find((entry) => isCommandAvailable(entry, { platform, env })) ?? candidateCommands[0]; if (!command) { return yield* new OpenError({ message: `Editor command not found: ${editorDef.command}` }); } const usesWindowsPath = runtimeEnvironment.windowsInteropMode === "wsl-hosted" && command.endsWith(".exe"); const target = usesWindowsPath ? yield* Effect.try({ try: () => translateWslPathToWindows(input.cwd), catch: (cause) => new OpenError({ message: "Failed to translate WSL path for Windows editor launch", cause, }), }) : input.cwd; return shouldUseGotoFlag(editorDef.id, target) ? { command, args: ["--goto", target] } : { command, args: [target] }; } if (editorDef.id !== "file-manager") { return yield* new OpenError({ message: `Unsupported editor: ${input.editor}` }); } const candidateCommands = resolveOpenCommand(editorDef.id, { platform, env, runtimeEnvironment, }); const command = candidateCommands.find((entry) => isCommandAvailable(entry, { platform, env })) ?? candidateCommands[0]; if (!command) { return yield* new OpenError({ message: "Editor command not found: file-manager" }); } let target = stripLineColumnSuffix(input.cwd); if (runtimeEnvironment.windowsInteropMode === "wsl-hosted" && command === "explorer.exe") { target = yield* Effect.try({ try: () => stripLineColumnSuffix(translateWslPathToWindows(target)), catch: (cause) => new OpenError({ message: "Failed to translate WSL path for Windows file manager launch", cause, }), }); } return { command, args: [target] };}); export const launchDetached = (launch: EditorLaunch) => Effect.tryPromise({ try: async () => { if (!isCommandAvailable(launch.command)) { throw new OpenError({ message: `Editor command not found: ${launch.command}` }); } await spawnDetachedProcess(launch.command, [...launch.args]); }, catch: (cause) => Schema.is(OpenError)(cause) ? cause : new OpenError({ message: "failed to spawn detached process", cause }), }); const make = Effect.gen(function* () { const open = yield* Effect.tryPromise({ try: () => import("open"), catch: (cause) => new OpenError({ message: "failed to load browser opener", cause }), }); return { openBrowser: (target) => { const launch = resolveBrowserLaunch(target); if (launch) { return launchDetached(launch); } return Effect.tryPromise({ try: () => open.default(target), catch: (cause) => new OpenError({ message: "Browser auto-open failed", cause }), }); }, openInEditor: (input) => Effect.flatMap(resolveEditorLaunch(input), launchDetached), } satisfies OpenShape;}); export const OpenLive = Layer.effect(Open, make);
/**
* Open - Browser/editor launch service interface.
*
* Owns process launch helpers for opening URLs in a browser and workspace
* paths in a configured editor.
*
* @module Open
*/
import { accessSync, constants, statSync } from "node:fs";
import { extname, join } from "node:path";
import { EDITORS, type EditorId, type ServerRuntimeEnvironment } from "@t3tools/contracts";
import { ServiceMap, Schema, Effect, Layer } from "effect";
import { spawnDetachedProcess, spawnProcessSync } from "./processRunner";
import { detectServerRuntimeEnvironment } from "./runtimeEnvironment";
// ==============================
// Definitions
// ==============================
export class OpenError extends Schema.TaggedErrorClass<OpenError>()("OpenError", {
message: Schema.String,
cause: Schema.optional(Schema.Defect),
}) {}
export interface OpenInEditorInput {
readonly cwd: string;
readonly editor: EditorId;
}
interface EditorLaunch {
readonly command: string;
readonly args: ReadonlyArray<string>;
}
interface CommandAvailabilityOptions {
readonly platform?: NodeJS.Platform;
readonly env?: NodeJS.ProcessEnv;
}
interface ResolveOpenOptions extends CommandAvailabilityOptions {
readonly runtimeEnvironment?: ServerRuntimeEnvironment;
readonly translateWslPathToWindows?: (target: string) => string;
}
const LINE_COLUMN_SUFFIX_PATTERN = /:\d+(?::\d+)?$/;
const WINDOWS_ABSOLUTE_PATH_PATTERN = /^[A-Za-z]:[\\/]/;
const WINDOWS_UNC_PATH_PATTERN = /^\\\\/;
function detectRuntimeEnvironment(options: ResolveOpenOptions): ServerRuntimeEnvironment {
return (
options.runtimeEnvironment ??
detectServerRuntimeEnvironment({
...(options.platform !== undefined ? { platform: options.platform } : {}),
...(options.env !== undefined ? { env: options.env } : {}),
})
);
}
function shouldUseGotoFlag(editorId: EditorId, target: string): boolean {
return (editorId === "cursor" || editorId === "vscode") && LINE_COLUMN_SUFFIX_PATTERN.test(target);
}
function fileManagerCommandForPlatform(platform: NodeJS.Platform): string {
switch (platform) {
case "darwin":
return "open";
case "win32":
return "explorer";
default:
return "xdg-open";
}
}
function stripWrappingQuotes(value: string): string {
return value.replace(/^"+|"+$/g, "");
}
function resolvePathEnvironmentVariable(env: NodeJS.ProcessEnv): string {
return env.PATH ?? env.Path ?? env.path ?? "";
}
function resolveWindowsPathExtensions(env: NodeJS.ProcessEnv): ReadonlyArray<string> {
const rawValue = env.PATHEXT;
const fallback = [".COM", ".EXE", ".BAT", ".CMD"];
if (!rawValue) return fallback;
const parsed = rawValue
.split(";")
.map((entry) => entry.trim())
.filter((entry) => entry.length > 0)
.map((entry) => (entry.startsWith(".") ? entry.toUpperCase() : `.${entry.toUpperCase()}`));
return parsed.length > 0 ? Array.from(new Set(parsed)) : fallback;
}
function resolveCommandCandidates(
command: string,
platform: NodeJS.Platform,
windowsPathExtensions: ReadonlyArray<string>,
): ReadonlyArray<string> {
if (platform !== "win32") return [command];
const extension = extname(command);
const normalizedExtension = extension.toUpperCase();
if (extension.length > 0 && windowsPathExtensions.includes(normalizedExtension)) {
const commandWithoutExtension = command.slice(0, -extension.length);
return Array.from(
new Set([
command,
`${commandWithoutExtension}${normalizedExtension}`,
`${commandWithoutExtension}${normalizedExtension.toLowerCase()}`,
]),
);
}
const candidates: string[] = [];
for (const extension of windowsPathExtensions) {
candidates.push(`${command}${extension}`);
candidates.push(`${command}${extension.toLowerCase()}`);
}
return Array.from(new Set(candidates));
}
function isExecutableFile(
filePath: string,
platform: NodeJS.Platform,
windowsPathExtensions: ReadonlyArray<string>,
): boolean {
try {
const stat = statSync(filePath);
if (!stat.isFile()) return false;
if (platform === "win32") {
const extension = extname(filePath);
if (extension.length === 0) return false;
return windowsPathExtensions.includes(extension.toUpperCase());
}
accessSync(filePath, constants.X_OK);
return true;
} catch {
return false;
}
}
function resolvePathDelimiter(platform: NodeJS.Platform): string {
return platform === "win32" ? ";" : ":";
}
export function isCommandAvailable(
command: string,
options: CommandAvailabilityOptions = {},
): boolean {
const platform = options.platform ?? process.platform;
const env = options.env ?? process.env;
const windowsPathExtensions = platform === "win32" ? resolveWindowsPathExtensions(env) : [];
const commandCandidates = resolveCommandCandidates(command, platform, windowsPathExtensions);
if (command.includes("/") || command.includes("\\")) {
return commandCandidates.some((candidate) =>
isExecutableFile(candidate, platform, windowsPathExtensions),
);
}
const pathValue = resolvePathEnvironmentVariable(env);
if (pathValue.length === 0) return false;
const pathEntries = pathValue
.split(resolvePathDelimiter(platform))
.map((entry) => stripWrappingQuotes(entry.trim()))
.filter((entry) => entry.length > 0);
for (const pathEntry of pathEntries) {
for (const candidate of commandCandidates) {
if (isExecutableFile(join(pathEntry, candidate), platform, windowsPathExtensions)) {
return true;
}
}
}
return false;
}
export function resolveAvailableEditors(
options: ResolveOpenOptions = {},
): ReadonlyArray<EditorId> {
const platform = options.platform ?? process.platform;
const env = options.env ?? process.env;
const runtimeEnvironment = detectRuntimeEnvironment({ ...options, platform, env });
const available: EditorId[] = [];
for (const editor of EDITORS) {
const launch = resolveOpenCommand(editor.id, {
platform,
env,
runtimeEnvironment,
});
if (launch.some((entry) => isCommandAvailable(entry, { platform, env }))) {
available.push(editor.id);
}
}
return available;
}
/**
* OpenShape - Service API for browser and editor launch actions.
*/
export interface OpenShape {
/**
* Open a URL target in the default browser.
*/
readonly openBrowser: (target: string) => Effect.Effect<void, OpenError>;
/**
* Open a workspace path in a selected editor integration.
*
* Launches the editor as a detached process so server startup is not blocked.
*/
readonly openInEditor: (input: OpenInEditorInput) => Effect.Effect<void, OpenError>;
}
/**
* Open - Service tag for browser/editor launch operations.
*/
export class Open extends ServiceMap.Service<Open, OpenShape>()("t3/open") {}
// ==============================
// Implementations
// ==============================
function splitPathAndPosition(value: string): {
path: string;
line: string | undefined;
column: string | undefined;
} {
let path = value;
let column: string | undefined;
let line: string | undefined;
const columnMatch = path.match(/:(\d+)$/);
if (!columnMatch?.[1]) {
return { path, line: undefined, column: undefined };
}
column = columnMatch[1];
path = path.slice(0, -columnMatch[0].length);
const lineMatch = path.match(/:(\d+)$/);
if (lineMatch?.[1]) {
line = lineMatch[1];
path = path.slice(0, -lineMatch[0].length);
} else {
line = column;
column = undefined;
}
return { path, line, column };
}
function stripLineColumnSuffix(value: string): string {
const { path } = splitPathAndPosition(value);
return path;
}
function formatPathWithPosition(input: {
readonly path: string;
readonly line?: string;
readonly column?: string;
}): string {
if (!input.line) {
return input.path;
}
return `${input.path}:${input.line}${input.column ? `:${input.column}` : ""}`;
}
function isWindowsPath(value: string): boolean {
return WINDOWS_ABSOLUTE_PATH_PATTERN.test(value) || WINDOWS_UNC_PATH_PATTERN.test(value);
}
function defaultTranslateWslPathToWindows(target: string): string {
const parsed = splitPathAndPosition(target);
if (parsed.path.length === 0 || !parsed.path.startsWith("/") || isWindowsPath(parsed.path)) {
return target;
}
const result = spawnProcessSync("wslpath", ["-w", parsed.path], {
encoding: "utf8",
stdio: ["ignore", "pipe", "pipe"],
});
if (result.error) {
throw result.error;
}
if (result.status !== 0) {
const detail = result.stderr.trim() || result.stdout.trim() || "wslpath failed";
throw new Error(detail);
}
const translatedPath = result.stdout.trim();
if (translatedPath.length === 0) {
throw new Error("wslpath returned an empty path");
}
return formatPathWithPosition({
path: translatedPath,
...(parsed.line ? { line: parsed.line } : {}),
...(parsed.column ? { column: parsed.column } : {}),
});
}
function resolveOpenCommand(
editorId: EditorId,
options: ResolveOpenOptions,
): ReadonlyArray<string> {
const platform = options.platform ?? process.platform;
const env = options.env ?? process.env;
const runtimeEnvironment = detectRuntimeEnvironment({ ...options, platform, env });
const editorDef = EDITORS.find((editor) => editor.id === editorId);
if (!editorDef) {
return [];
}
if (editorId === "file-manager") {
if (runtimeEnvironment.windowsInteropMode === "wsl-hosted") {
return ["explorer.exe", "xdg-open"];
}
return [fileManagerCommandForPlatform(platform)];
}
if (!editorDef.command) {
return [];
}
if (runtimeEnvironment.windowsInteropMode === "wsl-hosted") {
return [editorDef.command, `${editorDef.command}.exe`];
}
return [editorDef.command];
}
export function resolveBrowserLaunch(
target: string,
options: ResolveOpenOptions = {},
): EditorLaunch | null {
const platform = options.platform ?? process.platform;
const env = options.env ?? process.env;
const runtimeEnvironment = detectRuntimeEnvironment({ ...options, platform, env });
if (runtimeEnvironment.windowsInteropMode === "wsl-hosted") {
if (isCommandAvailable("wslview", { platform, env })) {
return { command: "wslview", args: [target] };
}
if (isCommandAvailable("explorer.exe", { platform, env })) {
return { command: "explorer.exe", args: [target] };
}
}
return null;
}
export const resolveEditorLaunch = Effect.fnUntraced(function* (
input: OpenInEditorInput,
options: ResolveOpenOptions = {},
): Effect.fn.Return<EditorLaunch, OpenError> {
const platform = options.platform ?? process.platform;
const env = options.env ?? process.env;
const runtimeEnvironment = detectRuntimeEnvironment({ ...options, platform, env });
const editorDef = EDITORS.find((editor) => editor.id === input.editor);
if (!editorDef) {
return yield* new OpenError({ message: `Unknown editor: ${input.editor}` });
}
const translateWslPathToWindows = options.translateWslPathToWindows ?? defaultTranslateWslPathToWindows;
if (editorDef.command) {
const candidateCommands = resolveOpenCommand(editorDef.id, {
platform,
env,
runtimeEnvironment,
});
const command =
candidateCommands.find((entry) => isCommandAvailable(entry, { platform, env })) ??
candidateCommands[0];
if (!command) {
return yield* new OpenError({ message: `Editor command not found: ${editorDef.command}` });
}
const usesWindowsPath = runtimeEnvironment.windowsInteropMode === "wsl-hosted" && command.endsWith(".exe");
const target = usesWindowsPath
? yield* Effect.try({
try: () => translateWslPathToWindows(input.cwd),
catch: (cause) =>
new OpenError({
message: "Failed to translate WSL path for Windows editor launch",
cause,
}),
})
: input.cwd;
return shouldUseGotoFlag(editorDef.id, target)
? { command, args: ["--goto", target] }
: { command, args: [target] };
}
if (editorDef.id !== "file-manager") {
return yield* new OpenError({ message: `Unsupported editor: ${input.editor}` });
}
const candidateCommands = resolveOpenCommand(editorDef.id, {
platform,
env,
runtimeEnvironment,
});
const command =
candidateCommands.find((entry) => isCommandAvailable(entry, { platform, env })) ??
candidateCommands[0];
if (!command) {
return yield* new OpenError({ message: "Editor command not found: file-manager" });
}
let target = stripLineColumnSuffix(input.cwd);
if (runtimeEnvironment.windowsInteropMode === "wsl-hosted" && command === "explorer.exe") {
target = yield* Effect.try({
try: () => stripLineColumnSuffix(translateWslPathToWindows(target)),
catch: (cause) =>
new OpenError({
message: "Failed to translate WSL path for Windows file manager launch",
cause,
}),
});
}
return { command, args: [target] };
});
export const launchDetached = (launch: EditorLaunch) =>
Effect.tryPromise({
try: async () => {
if (!isCommandAvailable(launch.command)) {
throw new OpenError({ message: `Editor command not found: ${launch.command}` });
}
await spawnDetachedProcess(launch.command, [...launch.args]);
},
catch: (cause) =>
Schema.is(OpenError)(cause)
? cause
: new OpenError({ message: "failed to spawn detached process", cause }),
});
const make = Effect.gen(function* () {
const open = yield* Effect.tryPromise({
try: () => import("open"),
catch: (cause) => new OpenError({ message: "failed to load browser opener", cause }),
});
return {
openBrowser: (target) => {
const launch = resolveBrowserLaunch(target);
if (launch) {
return launchDetached(launch);
}
return Effect.tryPromise({
try: () => open.default(target),
catch: (cause) => new OpenError({ message: "Browser auto-open failed", cause }),
});
},
openInEditor: (input) => Effect.flatMap(resolveEditorLaunch(input), launchDetached),
} satisfies OpenShape;
});
export const OpenLive = Layer.effect(Open, make);