/** * 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", { 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; } 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 { 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, ): ReadonlyArray { 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, ): 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 { 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; /** * 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; } /** * Open - Service tag for browser/editor launch operations. */ export class Open extends ServiceMap.Service()("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 { 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 { 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);