/** * 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 { spawn } from "node:child_process";import { accessSync, constants, statSync } from "node:fs";import { extname, join } from "node:path"; import { EDITORS, type EditorId } from "@t3tools/contracts";import { ServiceMap, Schema, Effect, Layer } from "effect"; // ==============================// 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;} const LINE_COLUMN_SUFFIX_PATTERN = /:\d+(?::\d+)?$/; 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( platform: NodeJS.Platform = process.platform, env: NodeJS.ProcessEnv = process.env,): ReadonlyArray<EditorId> { const available: EditorId[] = []; for (const editor of EDITORS) { const command = editor.command ?? fileManagerCommandForPlatform(platform); if (isCommandAvailable(command, { 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// ============================== export const resolveEditorLaunch = Effect.fnUntraced(function* ( input: OpenInEditorInput, platform: NodeJS.Platform = process.platform,): Effect.fn.Return<EditorLaunch, OpenError> { const editorDef = EDITORS.find((editor) => editor.id === input.editor); if (!editorDef) { return yield* new OpenError({ message: `Unknown editor: ${input.editor}` }); } if (editorDef.command) { return shouldUseGotoFlag(editorDef.id, input.cwd) ? { command: editorDef.command, args: ["--goto", input.cwd] } : { command: editorDef.command, args: [input.cwd] }; } if (editorDef.id !== "file-manager") { return yield* new OpenError({ message: `Unsupported editor: ${input.editor}` }); } return { command: fileManagerCommandForPlatform(platform), args: [input.cwd] };}); export const launchDetached = (launch: EditorLaunch) => Effect.gen(function* () { if (!isCommandAvailable(launch.command)) { return yield* new OpenError({ message: `Editor command not found: ${launch.command}` }); } yield* Effect.callback<void, OpenError>((resume) => { let child; try { child = spawn(launch.command, [...launch.args], { detached: true, stdio: "ignore", shell: process.platform === "win32", }); } catch (error) { return resume( Effect.fail(new OpenError({ message: "failed to spawn detached process", cause: error })), ); } const handleSpawn = () => { child.unref(); resume(Effect.void); }; child.once("spawn", handleSpawn); child.once("error", (cause) => resume(Effect.fail(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) => 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 { spawn } from "node:child_process";
import { accessSync, constants, statSync } from "node:fs";
import { extname, join } from "node:path";
import { EDITORS, type EditorId } from "@t3tools/contracts";
import { ServiceMap, Schema, Effect, Layer } from "effect";
// ==============================
// 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;
}
const LINE_COLUMN_SUFFIX_PATTERN = /:\d+(?::\d+)?$/;
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(
platform: NodeJS.Platform = process.platform,
env: NodeJS.ProcessEnv = process.env,
): ReadonlyArray<EditorId> {
const available: EditorId[] = [];
for (const editor of EDITORS) {
const command = editor.command ?? fileManagerCommandForPlatform(platform);
if (isCommandAvailable(command, { 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
// ==============================
export const resolveEditorLaunch = Effect.fnUntraced(function* (
input: OpenInEditorInput,
platform: NodeJS.Platform = process.platform,
): Effect.fn.Return<EditorLaunch, OpenError> {
const editorDef = EDITORS.find((editor) => editor.id === input.editor);
if (!editorDef) {
return yield* new OpenError({ message: `Unknown editor: ${input.editor}` });
}
if (editorDef.command) {
return shouldUseGotoFlag(editorDef.id, input.cwd)
? { command: editorDef.command, args: ["--goto", input.cwd] }
: { command: editorDef.command, args: [input.cwd] };
}
if (editorDef.id !== "file-manager") {
return yield* new OpenError({ message: `Unsupported editor: ${input.editor}` });
}
return { command: fileManagerCommandForPlatform(platform), args: [input.cwd] };
});
export const launchDetached = (launch: EditorLaunch) =>
Effect.gen(function* () {
if (!isCommandAvailable(launch.command)) {
return yield* new OpenError({ message: `Editor command not found: ${launch.command}` });
}
yield* Effect.callback<void, OpenError>((resume) => {
let child;
try {
child = spawn(launch.command, [...launch.args], {
detached: true,
stdio: "ignore",
shell: process.platform === "win32",
});
} catch (error) {
return resume(
Effect.fail(new OpenError({ message: "failed to spawn detached process", cause: error })),
);
}
const handleSpawn = () => {
child.unref();
resume(Effect.void);
};
child.once("spawn", handleSpawn);
child.once("error", (cause) =>
resume(Effect.fail(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) =>
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);