History

Refactor open service onto Effect-backed process spawning

- Add external opener support alongside browser/editor launches - Remove the `open` dependency and cover platform behavior in tests

Julius Marminge committed 7 months ago

Commit 2ade0371 parent 48fc85e7 files · +1125 −428Browse files

Unified · Split

7 files
  1. apps/server/package.json +0 −1
  2. apps/server/src/main.test.ts +2 −0
  3. apps/server/src/open.test.ts +533 −244
  4. apps/server/src/open.ts +578 −165
  5. apps/server/src/wsServer.test.ts +6 −0
  6. apps/server/src/wsServer.ts +6 −3
  7. bun.lock +0 −15
apps/server/package.json+0−1
@@ -29,7 +29,6 @@ · 28 unmodified lines
2929 "@pierre/diffs": "^1.1.0-beta.16",
3030 "effect": "catalog:",
3131 "node-pty": "^1.1.0",
32− "open": "^10.1.0",
3332 "ws": "^8.18.0"
3433 },
3534 "devDependencies": {
apps/server/src/main.test.ts+2−0
@@ -51,6 +51,8 @@ const testLayer = Layer.mergeAll( · 50 unmodified lines
5151 stopSignal: Effect.void,
5252 } satisfies ServerShape),
5353 Layer.succeed(Open, {
54+ getAvailableEditors: Effect.succeed([]),
55+ openExternal: () => Effect.void,
5456 openBrowser: (_target: string) => Effect.void,
5557 openInEditor: () => Effect.void,
5658 } satisfies OpenShape),
apps/server/src/open.test.ts+533−244
@@ -1,269 +1,558 @@
1−import * as NodeServices from "@effect/platform-node/NodeServices";
1+import * as NodeFileSystem from "@effect/platform-node/NodeFileSystem";
2+import * as NodePath from "@effect/platform-node/NodePath";
23 import { assert, it } from "@effect/vitest";
3−import { assertSuccess } from "@effect/vitest/utils";
4−import { FileSystem, Path, Effect } from "effect";
5−
64 import {
7− isCommandAvailable,
8− launchDetached,
9− resolveAvailableEditors,
10− resolveEditorLaunch,
11−} from "./open";
12−
13−it.layer(NodeServices.layer)("resolveEditorLaunch", (it) => {
14− it.effect("returns commands for command-based editors", () =>
15− Effect.gen(function* () {
16− const antigravityLaunch = yield* resolveEditorLaunch(
17− { cwd: "/tmp/workspace", editor: "antigravity" },
18− "darwin",
19− );
20− assert.deepEqual(antigravityLaunch, {
21− command: "agy",
22− args: ["/tmp/workspace"],
23− });
5+ Deferred,
6+ Effect,
7+ Exit,
8+ FileSystem,
9+ Layer,
10+ PlatformError,
11+ Scope,
12+ Sink,
13+ Stream,
14+} from "effect";
15+import { ChildProcessSpawner } from "effect/unstable/process";
2416
25− const cursorLaunch = yield* resolveEditorLaunch(
26− { cwd: "/tmp/workspace", editor: "cursor" },
27− "darwin",
28− );
29− assert.deepEqual(cursorLaunch, {
30− command: "cursor",
31− args: ["/tmp/workspace"],
32− });
17+import { makeOpenLayer, Open } from "./open";
3318
34− const vscodeLaunch = yield* resolveEditorLaunch(
35− { cwd: "/tmp/workspace", editor: "vscode" },
36− "darwin",
37− );
38− assert.deepEqual(vscodeLaunch, {
39− command: "code",
40− args: ["/tmp/workspace"],
41− });
19+const encoder = new TextEncoder();
4220
43− const vscodeInsidersLaunch = yield* resolveEditorLaunch(
44− { cwd: "/tmp/workspace", editor: "vscode-insiders" },
45− "darwin",
46− );
47− assert.deepEqual(vscodeInsidersLaunch, {
48− command: "code-insiders",
49− args: ["/tmp/workspace"],
50− });
21+interface SpawnCall {
22+ readonly command: string;
23+ readonly args: ReadonlyArray<string>;
24+ readonly detached?: boolean | undefined;
25+ readonly shell?: boolean | string | undefined;
26+ readonly stdin?: unknown;
27+ readonly stdout?: unknown;
28+ readonly stderr?: unknown;
29+}
5130
52− const vscodiumLaunch = yield* resolveEditorLaunch(
53− { cwd: "/tmp/workspace", editor: "vscodium" },
54− "darwin",
55− );
56− assert.deepEqual(vscodiumLaunch, {
57− command: "codium",
58− args: ["/tmp/workspace"],
59− });
31+function decodePowerShellCommand(encoded: string): string {
32+ return Buffer.from(encoded, "base64").toString("utf16le");
33+}
6034
61− const zedLaunch = yield* resolveEditorLaunch(
62− { cwd: "/tmp/workspace", editor: "zed" },
63− "darwin",
35+function platformSpawnError(message: string) {
36+ return PlatformError.systemError({
37+ _tag: "Unknown",
38+ module: "ChildProcess",
39+ method: "spawn",
40+ description: message,
41+ });
42+}
43+
44+function spawnerLayer(
45+ calls: Array<SpawnCall>,
46+ handler: (call: SpawnCall) => {
47+ readonly code?: number;
48+ readonly fail?: string;
49+ readonly awaitExit?: Deferred.Deferred<void>;
50+ readonly onKill?: () => void;
51+ } = () => ({
52+ code: 0,
53+ }),
54+) {
55+ return Layer.succeed(
56+ ChildProcessSpawner.ChildProcessSpawner,
57+ ChildProcessSpawner.make((command) => {
58+ const standardCommand = command as unknown as {
59+ readonly command: string;
60+ readonly args: ReadonlyArray<string>;
61+ readonly options: {
62+ readonly detached?: boolean;
63+ readonly shell?: boolean | string;
64+ readonly stdin?: unknown;
65+ readonly stdout?: unknown;
66+ readonly stderr?: unknown;
67+ };
68+ };
69+
70+ const call: SpawnCall = {
71+ command: standardCommand.command,
72+ args: [...standardCommand.args],
73+ detached: standardCommand.options.detached,
74+ shell: standardCommand.options.shell,
75+ stdin: standardCommand.options.stdin,
76+ stdout: standardCommand.options.stdout,
77+ stderr: standardCommand.options.stderr,
78+ };
79+ calls.push(call);
80+
81+ const result = handler(call);
82+ if (result.fail) {
83+ return Effect.fail(platformSpawnError(result.fail));
84+ }
85+
86+ let exited = false;
87+ const exitCode = result.awaitExit
88+ ? Deferred.await(result.awaitExit).pipe(
89+ Effect.tap(() =>
90+ Effect.sync(() => {
91+ exited = true;
92+ }),
93+ ),
94+ Effect.andThen(Effect.succeed(ChildProcessSpawner.ExitCode(result.code ?? 0))),
95+ )
96+ : Effect.sync(() => {
97+ exited = true;
98+ return ChildProcessSpawner.ExitCode(result.code ?? 0);
99+ });
100+
101+ return Effect.succeed(
102+ ChildProcessSpawner.makeHandle({
103+ pid: ChildProcessSpawner.ProcessId(calls.length),
104+ exitCode,
105+ isRunning: Effect.succeed(false),
106+ kill: () =>
107+ Effect.sync(() => {
108+ if (!exited) {
109+ result.onKill?.();
110+ }
111+ }),
112+ stdin: Sink.drain,
113+ stdout: Stream.make(encoder.encode("")),
114+ stderr: Stream.make(encoder.encode("")),
115+ all: Stream.empty,
116+ getInputFd: () => Sink.drain,
117+ getOutputFd: () => Stream.empty,
118+ }),
64119 );
65− assert.deepEqual(zedLaunch, {
66− command: "zed",
67− args: ["/tmp/workspace"],
68− });
69120 }),
70121 );
122+}
71123
72− it.effect("uses --goto when editor supports line/column suffixes", () =>
73− Effect.gen(function* () {
74− const lineOnly = yield* resolveEditorLaunch(
75− { cwd: "/tmp/workspace/AGENTS.md:48", editor: "cursor" },
76− "darwin",
77− );
78− assert.deepEqual(lineOnly, {
79− command: "cursor",
80− args: ["--goto", "/tmp/workspace/AGENTS.md:48"],
81− });
82−
83− const lineAndColumn = yield* resolveEditorLaunch(
84− { cwd: "/tmp/workspace/src/open.ts:71:5", editor: "cursor" },
85− "darwin",
86− );
87− assert.deepEqual(lineAndColumn, {
88− command: "cursor",
89− args: ["--goto", "/tmp/workspace/src/open.ts:71:5"],
90− });
124+const provideOpen = (
125+ options: Parameters<typeof makeOpenLayer>[0],
126+ spawnLayer: Layer.Layer<ChildProcessSpawner.ChildProcessSpawner>,
127+) =>
128+ makeOpenLayer(options).pipe(
129+ Layer.provide(Layer.mergeAll(NodeFileSystem.layer, NodePath.layer, spawnLayer)),
130+ );
91131
92− const vscodeLineAndColumn = yield* resolveEditorLaunch(
93− { cwd: "/tmp/workspace/src/open.ts:71:5", editor: "vscode" },
94− "darwin",
95− );
96− assert.deepEqual(vscodeLineAndColumn, {
97− command: "code",
98− args: ["--goto", "/tmp/workspace/src/open.ts:71:5"],
99− });
132+const runOpen = <A>(
133+ options: Parameters<typeof makeOpenLayer>[0],
134+ spawnLayer: Layer.Layer<ChildProcessSpawner.ChildProcessSpawner>,
135+ effect: Effect.Effect<A, Error, Open>,
136+) => effect.pipe(Effect.provide(provideOpen(options, spawnLayer)));
100137
101− const vscodeInsidersLineAndColumn = yield* resolveEditorLaunch(
102− { cwd: "/tmp/workspace/src/open.ts:71:5", editor: "vscode-insiders" },
103− "darwin",
104− );
105− assert.deepEqual(vscodeInsidersLineAndColumn, {
106− command: "code-insiders",
107− args: ["--goto", "/tmp/workspace/src/open.ts:71:5"],
108− });
138+const readOpen = Effect.service(Open);
109139
110− const vscodiumLineAndColumn = yield* resolveEditorLaunch(
111− { cwd: "/tmp/workspace/src/open.ts:71:5", editor: "vscodium" },
112− "darwin",
113− );
114− assert.deepEqual(vscodiumLineAndColumn, {
115− command: "codium",
140+const writeExecutable = (filePath: string) =>
141+ Effect.gen(function* () {
142+ const fs = yield* FileSystem.FileSystem;
143+ yield* fs.writeFileString(filePath, "#!/bin/sh\nexit 0\n");
144+ yield* fs.chmod(filePath, 0o755);
145+ });
146+
147+it.effect("getAvailableEditors detects installed editors through the service", () =>
148+ Effect.gen(function* () {
149+ const fs = yield* FileSystem.FileSystem;
150+ const dir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-open-editors-" });
151+ yield* fs.writeFileString(`${dir}/code-insiders.CMD`, "@echo off\r\n");
152+ yield* fs.writeFileString(`${dir}/codium.CMD`, "@echo off\r\n");
153+ yield* fs.writeFileString(`${dir}/explorer.CMD`, "@echo off\r\n");
154+
155+ const calls: Array<SpawnCall> = [];
156+ const open = yield* runOpen(
157+ {
158+ platform: "win32",
159+ env: {
160+ PATH: dir,
161+ PATHEXT: ".COM;.EXE;.BAT;.CMD",
162+ },
163+ },
164+ spawnerLayer(calls),
165+ readOpen,
166+ );
167+
168+ const editors = yield* open.getAvailableEditors;
169+ assert.deepEqual(editors, ["vscode-insiders", "vscodium", "file-manager"]);
170+ assert.deepEqual(calls, []);
171+ }).pipe(Effect.provide(NodeFileSystem.layer)),
172+);
173+
174+it.effect("getAvailableEditors does not advertise WSL file-manager from PowerShell alone", () =>
175+ Effect.gen(function* () {
176+ const fs = yield* FileSystem.FileSystem;
177+ const dir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-open-wsl-editors-" });
178+ yield* fs.makeDirectory(`${dir}/mnt/c/Windows/System32/WindowsPowerShell/v1.0`, {
179+ recursive: true,
180+ });
181+ yield* writeExecutable(`${dir}/mnt/c/Windows/System32/WindowsPowerShell/v1.0/powershell.exe`);
182+
183+ const calls: Array<SpawnCall> = [];
184+ const open = yield* runOpen(
185+ {
186+ platform: "linux",
187+ env: {
188+ PATH: `${dir}:${dir}/mnt/c/Windows/System32/WindowsPowerShell/v1.0`,
189+ WSL_DISTRO_NAME: "Ubuntu",
190+ },
191+ isWsl: true,
192+ isInsideContainer: false,
193+ powerShellCommand: `${dir}/mnt/c/Windows/System32/WindowsPowerShell/v1.0/powershell.exe`,
194+ },
195+ spawnerLayer(calls),
196+ readOpen,
197+ );
198+
199+ const editors = yield* open.getAvailableEditors;
200+ assert.equal(editors.includes("file-manager"), false);
201+ assert.deepEqual(calls, []);
202+ }).pipe(Effect.provide(NodeFileSystem.layer)),
203+);
204+
205+it.effect("openInEditor uses --goto for editors that support it", () =>
206+ Effect.gen(function* () {
207+ const fs = yield* FileSystem.FileSystem;
208+ const dir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-open-cursor-" });
209+ yield* writeExecutable(`${dir}/cursor`);
210+
211+ const calls: Array<SpawnCall> = [];
212+ const open = yield* runOpen(
213+ {
214+ platform: "darwin",
215+ env: { PATH: dir },
216+ },
217+ spawnerLayer(calls),
218+ readOpen,
219+ );
220+
221+ yield* open.openInEditor({
222+ cwd: "/tmp/workspace/src/open.ts:71:5",
223+ editor: "cursor",
224+ });
225+
226+ assert.deepEqual(calls, [
227+ {
228+ command: `${dir}/cursor`,
116229 args: ["--goto", "/tmp/workspace/src/open.ts:71:5"],
117− });
230+ detached: true,
231+ shell: false,
232+ stdin: "ignore",
233+ stdout: "ignore",
234+ stderr: "ignore",
235+ },
236+ ]);
237+ }).pipe(Effect.provide(NodeFileSystem.layer)),
238+);
118239
119− const zedLineAndColumn = yield* resolveEditorLaunch(
120− { cwd: "/tmp/workspace/src/open.ts:71:5", editor: "zed" },
121− "darwin",
122− );
123− assert.deepEqual(zedLineAndColumn, {
124− command: "zed",
125− args: ["/tmp/workspace/src/open.ts:71:5"],
126− });
127− }),
128− );
240+it.effect("openInEditor launches Windows batch shims through cmd.exe without shell mode", () =>
241+ Effect.gen(function* () {
242+ const fs = yield* FileSystem.FileSystem;
243+ const dir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-open-vscode-win32-" });
244+ yield* fs.writeFileString(`${dir}/code.cmd`, "@echo off\r\n");
129245
130− it.effect("maps file-manager editor to OS open commands", () =>
131− Effect.gen(function* () {
132− const launch1 = yield* resolveEditorLaunch(
133− { cwd: "/tmp/workspace", editor: "file-manager" },
134− "darwin",
135− );
136− assert.deepEqual(launch1, {
137− command: "open",
246+ const calls: Array<SpawnCall> = [];
247+ const open = yield* runOpen(
248+ {
249+ platform: "win32",
250+ env: {
251+ PATH: dir,
252+ PATHEXT: ".COM;.EXE;.BAT;.CMD",
253+ },
254+ },
255+ spawnerLayer(calls),
256+ readOpen,
257+ );
258+
259+ yield* open.openInEditor({
260+ cwd: "C:\\work\\100% real\\file.ts:12:4",
261+ editor: "vscode",
262+ });
263+
264+ assert.equal(calls.length, 1);
265+ assert.equal(calls[0]?.command, "cmd.exe");
266+ assert.deepEqual(calls[0]?.args.slice(0, 4), ["/d", "/v:off", "/s", "/c"]);
267+ assert.equal(calls[0]?.args[4]?.toLowerCase().includes(`${dir}/code.cmd`.toLowerCase()), true);
268+ assert.equal(calls[0]?.args[4]?.includes('"--goto"'), true);
269+ assert.equal(calls[0]?.args[4]?.includes("100%% real"), true);
270+ assert.equal(calls[0]?.detached, true);
271+ assert.equal(calls[0]?.shell, false);
272+ assert.equal(calls[0]?.stdin, "ignore");
273+ assert.equal(calls[0]?.stdout, "ignore");
274+ assert.equal(calls[0]?.stderr, "ignore");
275+ }).pipe(Effect.provide(NodeFileSystem.layer)),
276+);
277+
278+it.effect("openInEditor detached launches survive service scope shutdown", () =>
279+ Effect.gen(function* () {
280+ const fs = yield* FileSystem.FileSystem;
281+ const dir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-open-zed-detached-" });
282+ yield* writeExecutable(`${dir}/zed`);
283+
284+ const calls: Array<SpawnCall> = [];
285+ const exitDeferred = yield* Deferred.make<void>();
286+ let killCount = 0;
287+
288+ yield* Effect.acquireUseRelease(
289+ Scope.make("sequential"),
290+ (scope) =>
291+ Effect.gen(function* () {
292+ const runtimeServices = yield* Layer.build(
293+ provideOpen(
294+ {
295+ platform: "darwin",
296+ env: { PATH: dir },
297+ },
298+ spawnerLayer(calls, () => ({
299+ awaitExit: exitDeferred,
300+ onKill: () => {
301+ killCount += 1;
302+ },
303+ })),
304+ ),
305+ ).pipe(Scope.provide(scope));
306+
307+ const open = yield* readOpen.pipe(Effect.provide(runtimeServices), Scope.provide(scope));
308+ yield* open.openInEditor({
309+ cwd: "/tmp/workspace",
310+ editor: "zed",
311+ });
312+ }),
313+ (scope) => Scope.close(scope, Exit.void),
314+ );
315+
316+ assert.equal(killCount, 0);
317+ assert.deepEqual(calls, [
318+ {
319+ command: `${dir}/zed`,
138320 args: ["/tmp/workspace"],
139− });
321+ detached: true,
322+ shell: false,
323+ stdin: "ignore",
324+ stdout: "ignore",
325+ stderr: "ignore",
326+ },
327+ ]);
140328
141− const launch2 = yield* resolveEditorLaunch(
142− { cwd: "C:\\workspace", editor: "file-manager" },
143− "win32",
144− );
145− assert.deepEqual(launch2, {
146− command: "explorer",
147− args: ["C:\\workspace"],
148− });
149−
150− const launch3 = yield* resolveEditorLaunch(
151− { cwd: "/tmp/workspace", editor: "file-manager" },
152− "linux",
153− );
154− assert.deepEqual(launch3, {
155− command: "xdg-open",
329+ yield* Deferred.succeed(exitDeferred, void 0);
330+ yield* Effect.yieldNow;
331+ assert.equal(killCount, 0);
332+ }).pipe(Effect.provide(NodeFileSystem.layer)),
333+);
334+
335+it.effect("openInEditor uses the default opener for file-manager on macOS", () =>
336+ Effect.gen(function* () {
337+ const fs = yield* FileSystem.FileSystem;
338+ const dir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-open-macos-" });
339+ yield* writeExecutable(`${dir}/open`);
340+
341+ const calls: Array<SpawnCall> = [];
342+ const open = yield* runOpen(
343+ {
344+ platform: "darwin",
345+ env: { PATH: dir },
346+ },
347+ spawnerLayer(calls),
348+ readOpen,
349+ );
350+
351+ yield* open.openInEditor({
352+ cwd: "/tmp/workspace",
353+ editor: "file-manager",
354+ });
355+
356+ assert.deepEqual(calls, [
357+ {
358+ command: `${dir}/open`,
156359 args: ["/tmp/workspace"],
157− });
158− }),
159− );
160−});
161−
162−it.layer(NodeServices.layer)("launchDetached", (it) => {
163− it.effect("resolves when command can be spawned", () =>
164− Effect.gen(function* () {
165− const result = yield* launchDetached({
166− command: process.execPath,
167− args: ["-e", "process.exit(0)"],
168− }).pipe(Effect.result);
169− assertSuccess(result, undefined);
170− }),
171− );
360+ detached: undefined,
361+ shell: false,
362+ stdin: undefined,
363+ stdout: undefined,
364+ stderr: undefined,
365+ },
366+ ]);
367+ }).pipe(Effect.provide(NodeFileSystem.layer)),
368+);
172369
173− it.effect("rejects when command does not exist", () =>
174− Effect.gen(function* () {
175− const result = yield* launchDetached({
176− command: `t3code-no-such-command-${Date.now()}`,
177− args: [],
178− }).pipe(Effect.result);
179− assert.equal(result._tag, "Failure");
180− }),
181− );
182−});
183−
184−it.layer(NodeServices.layer)("isCommandAvailable", (it) => {
185− it.effect("resolves win32 commands with PATHEXT", () =>
186− Effect.gen(function* () {
187− const fs = yield* FileSystem.FileSystem;
188− const path = yield* Path.Path;
189− const dir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-open-test-" });
190− yield* fs.writeFileString(path.join(dir, "code.CMD"), "@echo off\r\n");
191− const env = {
192− PATH: dir,
193− PATHEXT: ".COM;.EXE;.BAT;.CMD",
194− } satisfies NodeJS.ProcessEnv;
195− assert.equal(isCommandAvailable("code", { platform: "win32", env }), true);
196− }),
197− );
370+it.effect("openBrowser uses macOS open flags and app arguments", () =>
371+ Effect.gen(function* () {
372+ const fs = yield* FileSystem.FileSystem;
373+ const dir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-open-browser-macos-" });
374+ yield* writeExecutable(`${dir}/open`);
198375
199− it("returns false when a command is not on PATH", () => {
200− const env = {
201− PATH: "",
202− PATHEXT: ".COM;.EXE;.BAT;.CMD",
203− } satisfies NodeJS.ProcessEnv;
204− assert.equal(isCommandAvailable("definitely-not-installed", { platform: "win32", env }), false);
205− });
376+ const calls: Array<SpawnCall> = [];
377+ const open = yield* runOpen(
378+ {
379+ platform: "darwin",
380+ env: { PATH: dir },
381+ },
382+ spawnerLayer(calls),
383+ readOpen,
384+ );
206385
207− it.effect("does not treat bare files without executable extension as available on win32", () =>
208− Effect.gen(function* () {
209− const fs = yield* FileSystem.FileSystem;
210− const path = yield* Path.Path;
211− const dir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-open-test-" });
212− yield* fs.writeFileString(path.join(dir, "npm"), "echo nope\r\n");
213− const env = {
214− PATH: dir,
215− PATHEXT: ".COM;.EXE;.BAT;.CMD",
216− } satisfies NodeJS.ProcessEnv;
217− assert.equal(isCommandAvailable("npm", { platform: "win32", env }), false);
218− }),
219− );
386+ yield* open.openBrowser("https://example.com", {
387+ wait: true,
388+ background: true,
389+ newInstance: true,
390+ app: {
391+ name: "google chrome",
392+ arguments: ["--profile-directory=Work"],
393+ },
394+ });
220395
221− it.effect("appends PATHEXT for commands with non-executable extensions on win32", () =>
222− Effect.gen(function* () {
223− const fs = yield* FileSystem.FileSystem;
224− const path = yield* Path.Path;
225− const dir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-open-test-" });
226− yield* fs.writeFileString(path.join(dir, "my.tool.CMD"), "@echo off\r\n");
227− const env = {
228− PATH: dir,
229− PATHEXT: ".COM;.EXE;.BAT;.CMD",
230− } satisfies NodeJS.ProcessEnv;
231− assert.equal(isCommandAvailable("my.tool", { platform: "win32", env }), true);
232− }),
233− );
396+ assert.deepEqual(calls, [
397+ {
398+ command: `${dir}/open`,
399+ args: [
400+ "--wait-apps",
401+ "--background",
402+ "--new",
403+ "-a",
404+ "google chrome",
405+ "https://example.com",
406+ "--args",
407+ "--profile-directory=Work",
408+ ],
409+ detached: undefined,
410+ shell: false,
411+ stdin: undefined,
412+ stdout: undefined,
413+ stderr: undefined,
414+ },
415+ ]);
416+ }).pipe(Effect.provide(NodeFileSystem.layer)),
417+);
234418
235− it.effect("uses platform-specific PATH delimiter for platform overrides", () =>
236− Effect.gen(function* () {
237− const fs = yield* FileSystem.FileSystem;
238− const path = yield* Path.Path;
239− const firstDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-open-test-" });
240− const secondDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-open-test-" });
241− yield* fs.writeFileString(path.join(firstDir, "code.CMD"), "@echo off\r\n");
242− yield* fs.writeFileString(path.join(secondDir, "code.CMD"), "MZ");
243− const env = {
244− PATH: `${firstDir};${secondDir}`,
245− PATHEXT: ".COM;.EXE;.BAT;.CMD",
246− } satisfies NodeJS.ProcessEnv;
247− assert.equal(isCommandAvailable("code", { platform: "win32", env }), true);
248− }),
249− );
250−});
251−
252−it.layer(NodeServices.layer)("resolveAvailableEditors", (it) => {
253− it.effect("returns installed editors for command launches", () =>
254− Effect.gen(function* () {
255− const fs = yield* FileSystem.FileSystem;
256− const path = yield* Path.Path;
257− const dir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-editors-" });
258−
259− yield* fs.writeFileString(path.join(dir, "code-insiders.CMD"), "@echo off\r\n");
260− yield* fs.writeFileString(path.join(dir, "codium.CMD"), "@echo off\r\n");
261− yield* fs.writeFileString(path.join(dir, "explorer.CMD"), "MZ");
262− const editors = resolveAvailableEditors("win32", {
263− PATH: dir,
264− PATHEXT: ".COM;.EXE;.BAT;.CMD",
265− });
266− assert.deepEqual(editors, ["vscode-insiders", "vscodium", "file-manager"]);
267− }),
268− );
269−});
419+it.effect("openBrowser uses PowerShell on win32", () =>
420+ Effect.gen(function* () {
421+ const fs = yield* FileSystem.FileSystem;
422+ const dir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-open-browser-win32-" });
423+ yield* fs.writeFileString(`${dir}/powershell.exe`, "MZ");
424+
425+ const calls: Array<SpawnCall> = [];
426+ const open = yield* runOpen(
427+ {
428+ platform: "win32",
429+ env: {
430+ PATH: dir,
431+ PATHEXT: ".COM;.EXE;.BAT;.CMD",
432+ },
433+ },
434+ spawnerLayer(calls),
435+ readOpen,
436+ );
437+
438+ yield* open.openBrowser("https://example.com");
439+
440+ assert.equal(calls.length, 1);
441+ assert.equal(calls[0]?.command, `${dir}/powershell.exe`);
442+ assert.equal(calls[0]?.args.at(-2), "-EncodedCommand");
443+ assert.equal(
444+ decodePowerShellCommand(calls[0]?.args.at(-1) ?? ""),
445+ "Start 'https://example.com'",
446+ );
447+ }).pipe(Effect.provide(NodeFileSystem.layer)),
448+);
449+
450+it.effect("openBrowser falls back from WSL PowerShell to xdg-open", () =>
451+ Effect.gen(function* () {
452+ const fs = yield* FileSystem.FileSystem;
453+ const dir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-open-browser-wsl-" });
454+ yield* fs.makeDirectory(`${dir}/mnt/c/Windows/System32/WindowsPowerShell/v1.0`, {
455+ recursive: true,
456+ });
457+ yield* writeExecutable(`${dir}/mnt/c/Windows/System32/WindowsPowerShell/v1.0/powershell.exe`);
458+ yield* writeExecutable(`${dir}/xdg-open`);
459+
460+ const calls: Array<SpawnCall> = [];
461+ const open = yield* runOpen(
462+ {
463+ platform: "linux",
464+ env: {
465+ PATH: `${dir}:${dir}/mnt/c/Windows/System32/WindowsPowerShell/v1.0`,
466+ WSL_DISTRO_NAME: "Ubuntu",
467+ },
468+ isWsl: true,
469+ isInsideContainer: false,
470+ powerShellCommand: `${dir}/mnt/c/Windows/System32/WindowsPowerShell/v1.0/powershell.exe`,
471+ },
472+ spawnerLayer(calls, (call) =>
473+ call.command.includes("powershell")
474+ ? { fail: "powershell unavailable" }
475+ : {
476+ code: 0,
477+ },
478+ ),
479+ readOpen,
480+ );
481+
482+ yield* open.openBrowser("https://example.com");
483+
484+ assert.equal(calls[0]?.command.includes("powershell"), true);
485+ assert.equal(calls[1]?.command, `${dir}/xdg-open`);
486+ assert.deepEqual(calls[1]?.args, ["https://example.com"]);
487+ }).pipe(Effect.provide(NodeFileSystem.layer)),
488+);
489+
490+it.effect("openInEditor uses xdg-open first for WSL file-manager paths", () =>
491+ Effect.gen(function* () {
492+ const fs = yield* FileSystem.FileSystem;
493+ const dir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-open-wsl-file-manager-" });
494+ yield* fs.makeDirectory(`${dir}/mnt/c/Windows/System32/WindowsPowerShell/v1.0`, {
495+ recursive: true,
496+ });
497+ yield* writeExecutable(`${dir}/mnt/c/Windows/System32/WindowsPowerShell/v1.0/powershell.exe`);
498+ yield* writeExecutable(`${dir}/xdg-open`);
499+
500+ const calls: Array<SpawnCall> = [];
501+ const open = yield* runOpen(
502+ {
503+ platform: "linux",
504+ env: {
505+ PATH: `${dir}:${dir}/mnt/c/Windows/System32/WindowsPowerShell/v1.0`,
506+ WSL_DISTRO_NAME: "Ubuntu",
507+ },
508+ isWsl: true,
509+ isInsideContainer: false,
510+ powerShellCommand: `${dir}/mnt/c/Windows/System32/WindowsPowerShell/v1.0/powershell.exe`,
511+ },
512+ spawnerLayer(calls),
513+ readOpen,
514+ );
515+
516+ yield* open.openInEditor({
517+ cwd: "/home/julius/workspace",
518+ editor: "file-manager",
519+ });
520+
521+ assert.deepEqual(calls, [
522+ {
523+ command: `${dir}/xdg-open`,
524+ args: ["/home/julius/workspace"],
525+ detached: true,
526+ shell: false,
527+ stdin: "ignore",
528+ stdout: "ignore",
529+ stderr: "ignore",
530+ },
531+ ]);
532+ }).pipe(Effect.provide(NodeFileSystem.layer)),
533+);
534+
535+it.effect("openInEditor fails when the editor command is unavailable", () =>
536+ Effect.gen(function* () {
537+ const calls: Array<SpawnCall> = [];
538+ const open = yield* runOpen(
539+ {
540+ platform: "darwin",
541+ env: { PATH: "" },
542+ },
543+ spawnerLayer(calls),
544+ readOpen,
545+ );
546+
547+ const error = yield* open
548+ .openInEditor({
549+ cwd: "/tmp/workspace",
550+ editor: "cursor",
551+ })
552+ .pipe(Effect.flip);
553+
554+ assert.equal(error._tag, "OpenError");
555+ assert.equal(error.message, "Command not found: cursor");
556+ assert.deepEqual(calls, []);
557+ }),
558+);
apps/server/src/open.ts+578−165
@@ -1,21 +1,17 @@
11 /**
22 * Open - Browser/editor launch service interface.
33 *
4− * Owns process launch helpers for opening URLs in a browser and workspace
5− * paths in a configured editor.
4+ * Owns process launch helpers for opening URLs in a browser, workspace paths in
5+ * a configured editor, and generic external targets through the platform's
6+ * default opener.
67 *
78 * @module Open
89 */
9−import { spawn } from "node:child_process";
10−import { accessSync, constants, statSync } from "node:fs";
11−import { extname, join } from "node:path";
10+import os from "node:os";
1211
1312 import { EDITORS, type EditorId } from "@t3tools/contracts";
14−import { ServiceMap, Schema, Effect, Layer } from "effect";
15−
16−// ==============================
17−// Definitions
18−// ==============================
13+import { Effect, Exit, FileSystem, Layer, Option, Path, Schema, Scope, ServiceMap } from "effect";
14+import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process";
1915
2016 export class OpenError extends Schema.TaggedErrorClass<OpenError>()("OpenError", {
2117 message: Schema.String,
@@ -27,33 +23,66 @@ export interface OpenInEditorInput { · 5 unmodified lines
2723 readonly editor: EditorId;
2824 }
2925
30−interface EditorLaunch {
31− readonly command: string;
32− readonly args: ReadonlyArray<string>;
26+export interface OpenApplicationInput {
27+ readonly name: string | ReadonlyArray<string>;
28+ readonly arguments?: ReadonlyArray<string>;
29+}
30+
31+export interface OpenExternalInput {
32+ readonly target: string;
33+ readonly wait?: boolean;
34+ readonly background?: boolean;
35+ readonly newInstance?: boolean;
36+ readonly allowNonzeroExitCode?: boolean;
37+ readonly app?: OpenApplicationInput | ReadonlyArray<OpenApplicationInput>;
3338 }
3439
35−interface CommandAvailabilityOptions {
40+export interface OpenRuntimeOptions {
3641 readonly platform?: NodeJS.Platform;
3742 readonly env?: NodeJS.ProcessEnv;
43+ readonly isWsl?: boolean;
44+ readonly isInsideContainer?: boolean;
45+ readonly powerShellCommand?: string;
46+}
47+
48+interface OpenRuntime {
49+ readonly platform: NodeJS.Platform;
50+ readonly env: NodeJS.ProcessEnv;
51+ readonly isWsl: boolean;
52+ readonly isInsideContainer: boolean;
53+ readonly powerShellCandidates: ReadonlyArray<string>;
54+ readonly windowsPathExtensions: ReadonlyArray<string>;
55+}
56+
57+interface LaunchPlan {
58+ readonly command: string;
59+ readonly args: ReadonlyArray<string>;
60+ readonly wait: boolean;
61+ readonly allowNonzeroExitCode: boolean;
62+ readonly detached?: boolean;
63+ readonly shell?: boolean;
64+ readonly stdio?: "ignore";
65+}
66+
67+interface ResolvedCommand {
68+ readonly path: string;
69+ readonly usesCmdWrapper: boolean;
3870 }
3971
4072 const LINE_COLUMN_SUFFIX_PATTERN = /:\d+(?::\d+)?$/;
73+const WINDOWS_POWERSHELL_CANDIDATES = ["powershell.exe", "powershell", "pwsh.exe", "pwsh"] as const;
74+const WSL_POWERSHELL_CANDIDATES = [
75+ "/mnt/c/Windows/System32/WindowsPowerShell/v1.0/powershell.exe",
76+ "/mnt/c/Program Files/PowerShell/7/pwsh.exe",
77+ "powershell.exe",
78+ "pwsh.exe",
79+] as const;
80+const WINDOWS_BATCH_EXTENSIONS = [".CMD", ".BAT"] as const;
4181
4282 function shouldUseGotoFlag(editor: (typeof EDITORS)[number], target: string): boolean {
4383 return editor.supportsGoto && LINE_COLUMN_SUFFIX_PATTERN.test(target);
4484 }
4585
46−function fileManagerCommandForPlatform(platform: NodeJS.Platform): string {
47− switch (platform) {
48− case "darwin":
49− return "open";
50− case "win32":
51− return "explorer";
52− default:
53− return "xdg-open";
54− }
55−}
56−
5786 function stripWrappingQuotes(value: string): string {
5887 return value.replace(/^"+|"+$/g, "");
5988 }
@@ -72,19 +101,24 @@ function resolveWindowsPathExtensions(env: NodeJS.ProcessEnv): ReadonlyArray<str · 12 unmodified lines
72101 .map((entry) => entry.trim())
73102 .filter((entry) => entry.length > 0)
74103 .map((entry) => (entry.startsWith(".") ? entry.toUpperCase() : `.${entry.toUpperCase()}`));
104+
75105 return parsed.length > 0 ? Array.from(new Set(parsed)) : fallback;
76106 }
77107
108+function resolveWindowsCommandShell(env: NodeJS.ProcessEnv): string {
109+ return env.ComSpec ?? env.COMSPEC ?? "cmd.exe";
110+}
111+
78112 function resolveCommandCandidates(
79113 command: string,
80− platform: NodeJS.Platform,
81− windowsPathExtensions: ReadonlyArray<string>,
114+ runtime: OpenRuntime,
115+ pathService: Path.Path,
82116 ): ReadonlyArray<string> {
83− if (platform !== "win32") return [command];
84− const extension = extname(command);
117+ if (runtime.platform !== "win32") return [command];
118+ const extension = pathService.extname(command);
85119 const normalizedExtension = extension.toUpperCase();
86120
87− if (extension.length > 0 && windowsPathExtensions.includes(normalizedExtension)) {
121+ if (extension.length > 0 && runtime.windowsPathExtensions.includes(normalizedExtension)) {
88122 const commandWithoutExtension = command.slice(0, -extension.length);
89123 return Array.from(
90124 new Set([
@@ -96,179 +130,558 @@ function resolveCommandCandidates( · 5 unmodified lines
96130 }
97131
98132 const candidates: string[] = [];
99− for (const extension of windowsPathExtensions) {
100− candidates.push(`${command}${extension}`);
101− candidates.push(`${command}${extension.toLowerCase()}`);
133+ for (const extensionName of runtime.windowsPathExtensions) {
134+ candidates.push(`${command}${extensionName}`);
135+ candidates.push(`${command}${extensionName.toLowerCase()}`);
102136 }
103137 return Array.from(new Set(candidates));
104138 }
105139
106−function isExecutableFile(
107− filePath: string,
108− platform: NodeJS.Platform,
109− windowsPathExtensions: ReadonlyArray<string>,
110−): boolean {
111− try {
112− const stat = statSync(filePath);
113− if (!stat.isFile()) return false;
114− if (platform === "win32") {
115− const extension = extname(filePath);
116− if (extension.length === 0) return false;
117− return windowsPathExtensions.includes(extension.toUpperCase());
118− }
119− accessSync(filePath, constants.X_OK);
140+function resolvePathDelimiter(platform: NodeJS.Platform): string {
141+ return platform === "win32" ? ";" : ":";
142+}
143+
144+function detectWsl(platform: NodeJS.Platform, env: NodeJS.ProcessEnv): boolean {
145+ if (platform !== "linux") return false;
146+ if (typeof env.WSL_DISTRO_NAME === "string" || typeof env.WSL_INTEROP === "string") {
120147 return true;
121− } catch {
122− return false;
123148 }
149+ return os.release().toLowerCase().includes("microsoft");
124150 }
125151
126−function resolvePathDelimiter(platform: NodeJS.Platform): string {
127− return platform === "win32" ? ";" : ":";
152+function quotePowerShellValue(value: string): string {
153+ return `'${value.replaceAll("'", "''")}'`;
128154 }
129155
130−export function isCommandAvailable(
156+function encodePowerShellCommand(command: string): string {
157+ return Buffer.from(command, "utf16le").toString("base64");
158+}
159+
160+function normalizeAppCandidates(
161+ app: OpenExternalInput["app"],
162+): ReadonlyArray<{ readonly name: string; readonly arguments: ReadonlyArray<string> } | undefined> {
163+ if (!app) return [undefined];
164+
165+ const apps = Array.isArray(app) ? app : [app];
166+ const candidates: Array<{ readonly name: string; readonly arguments: ReadonlyArray<string> }> =
167+ [];
168+
169+ for (const appDef of apps) {
170+ const names = Array.isArray(appDef.name) ? appDef.name : [appDef.name];
171+ for (const name of names) {
172+ candidates.push({ name, arguments: appDef.arguments ?? [] });
173+ }
174+ }
175+
176+ return candidates;
177+}
178+
179+function isUriLikeTarget(target: string): boolean {
180+ return /^[A-Za-z][A-Za-z\d+.-]*:/.test(target);
181+}
182+
183+function shouldPreferWindowsOpenerOnWsl(input: OpenExternalInput, runtime: OpenRuntime): boolean {
184+ return runtime.isWsl && !runtime.isInsideContainer && isUriLikeTarget(input.target);
185+}
186+
187+function makeLaunchPlan(
188+ runtime: OpenRuntime,
131189 command: string,
132− options: CommandAvailabilityOptions = {},
133−): boolean {
134− const platform = options.platform ?? process.platform;
135− const env = options.env ?? process.env;
136− const windowsPathExtensions = platform === "win32" ? resolveWindowsPathExtensions(env) : [];
137− const commandCandidates = resolveCommandCandidates(command, platform, windowsPathExtensions);
138−
139− if (command.includes("/") || command.includes("\\")) {
140− return commandCandidates.some((candidate) =>
141− isExecutableFile(candidate, platform, windowsPathExtensions),
142− );
190+ args: ReadonlyArray<string>,
191+ options: {
192+ readonly wait: boolean;
193+ readonly allowNonzeroExitCode: boolean;
194+ readonly detached?: boolean;
195+ readonly shell?: boolean;
196+ readonly stdio?: "ignore";
197+ },
198+): LaunchPlan {
199+ return {
200+ command,
201+ args,
202+ wait: options.wait,
203+ allowNonzeroExitCode: options.allowNonzeroExitCode,
204+ shell: options.shell ?? false,
205+ ...(options.detached !== undefined ? { detached: options.detached } : {}),
206+ ...(options.stdio !== undefined ? { stdio: options.stdio } : {}),
207+ };
208+}
209+
210+function makeDarwinDefaultPlan(input: OpenExternalInput, runtime: OpenRuntime): LaunchPlan {
211+ const args: string[] = [];
212+ const wait = input.wait ?? false;
213+
214+ if (wait) args.push("--wait-apps");
215+ if (input.background) args.push("--background");
216+ if (input.newInstance) args.push("--new");
217+ args.push(input.target);
218+
219+ return makeLaunchPlan(runtime, "open", args, {
220+ wait,
221+ allowNonzeroExitCode: input.allowNonzeroExitCode ?? false,
222+ shell: false,
223+ });
224+}
225+
226+function makeDarwinApplicationPlan(
227+ input: OpenExternalInput,
228+ app: { readonly name: string; readonly arguments: ReadonlyArray<string> },
229+ runtime: OpenRuntime,
230+): LaunchPlan {
231+ const args: string[] = [];
232+ const wait = input.wait ?? false;
233+
234+ if (wait) args.push("--wait-apps");
235+ if (input.background) args.push("--background");
236+ if (input.newInstance) args.push("--new");
237+ args.push("-a", app.name);
238+ args.push(input.target);
239+ if (app.arguments.length > 0) {
240+ args.push("--args", ...app.arguments);
143241 }
144242
145− const pathValue = resolvePathEnvironmentVariable(env);
146− if (pathValue.length === 0) return false;
147− const pathEntries = pathValue
148− .split(resolvePathDelimiter(platform))
149− .map((entry) => stripWrappingQuotes(entry.trim()))
150− .filter((entry) => entry.length > 0);
151−
152− for (const pathEntry of pathEntries) {
153− for (const candidate of commandCandidates) {
154− if (isExecutableFile(join(pathEntry, candidate), platform, windowsPathExtensions)) {
155− return true;
243+ return makeLaunchPlan(runtime, "open", args, {
244+ wait,
245+ allowNonzeroExitCode: input.allowNonzeroExitCode ?? false,
246+ shell: false,
247+ });
248+}
249+
250+function makePowerShellPlan(
251+ input: OpenExternalInput,
252+ runtime: OpenRuntime,
253+ powerShellCommand: string,
254+): LaunchPlan {
255+ const encodedParts = ["Start"];
256+ const wait = input.wait ?? false;
257+
258+ if (wait) encodedParts.push("-Wait");
259+ encodedParts.push(quotePowerShellValue(input.target));
260+
261+ return makeLaunchPlan(
262+ runtime,
263+ powerShellCommand,
264+ [
265+ "-NoProfile",
266+ "-NonInteractive",
267+ "-ExecutionPolicy",
268+ "Bypass",
269+ "-EncodedCommand",
270+ encodePowerShellCommand(encodedParts.join(" ")),
271+ ],
272+ {
273+ wait,
274+ allowNonzeroExitCode: input.allowNonzeroExitCode ?? false,
275+ shell: false,
276+ },
277+ );
278+}
279+
280+function makeLinuxDefaultPlan(input: OpenExternalInput, runtime: OpenRuntime): LaunchPlan {
281+ const wait = input.wait ?? false;
282+ return makeLaunchPlan(runtime, "xdg-open", [input.target], {
283+ wait,
284+ allowNonzeroExitCode: input.allowNonzeroExitCode ?? false,
285+ detached: !wait,
286+ ...(wait ? {} : { stdio: "ignore" as const }),
287+ shell: false,
288+ });
289+}
290+
291+function makeWindowsExplorerPlan(input: OpenExternalInput, runtime: OpenRuntime): LaunchPlan {
292+ return makeLaunchPlan(runtime, "explorer", [input.target], {
293+ wait: false,
294+ allowNonzeroExitCode: false,
295+ shell: false,
296+ });
297+}
298+
299+function makeDirectApplicationPlan(
300+ input: OpenExternalInput,
301+ app: { readonly name: string; readonly arguments: ReadonlyArray<string> },
302+ runtime: OpenRuntime,
303+): LaunchPlan {
304+ return makeLaunchPlan(runtime, app.name, [...app.arguments, input.target], {
305+ wait: input.wait ?? false,
306+ allowNonzeroExitCode: input.allowNonzeroExitCode ?? false,
307+ shell: false,
308+ });
309+}
310+
311+function resolveExternalPlans(
312+ input: OpenExternalInput,
313+ runtime: OpenRuntime,
314+): ReadonlyArray<LaunchPlan> {
315+ const appCandidates = normalizeAppCandidates(input.app);
316+ const plans: LaunchPlan[] = [];
317+ const preferWindowsOpenerOnWsl = shouldPreferWindowsOpenerOnWsl(input, runtime);
318+
319+ for (const app of appCandidates) {
320+ if (app) {
321+ if (runtime.platform === "darwin") {
322+ plans.push(makeDarwinApplicationPlan(input, app, runtime));
323+ } else {
324+ plans.push(makeDirectApplicationPlan(input, app, runtime));
156325 }
326+ continue;
157327 }
158− }
159− return false;
160−}
161328
162−export function resolveAvailableEditors(
163− platform: NodeJS.Platform = process.platform,
164− env: NodeJS.ProcessEnv = process.env,
165−): ReadonlyArray<EditorId> {
166− const available: EditorId[] = [];
329+ if (runtime.platform === "darwin") {
330+ plans.push(makeDarwinDefaultPlan(input, runtime));
331+ continue;
332+ }
167333
168− for (const editor of EDITORS) {
169− const command = editor.command ?? fileManagerCommandForPlatform(platform);
170− if (isCommandAvailable(command, { platform, env })) {
171− available.push(editor.id);
334+ if (runtime.platform === "win32" || preferWindowsOpenerOnWsl) {
335+ for (const powerShellCommand of runtime.powerShellCandidates) {
336+ plans.push(makePowerShellPlan(input, runtime, powerShellCommand));
337+ }
172338 }
339+
340+ if (runtime.platform === "win32") {
341+ if (!(input.wait ?? false)) {
342+ plans.push(makeWindowsExplorerPlan(input, runtime));
343+ }
344+ continue;
345+ }
346+
347+ plans.push(makeLinuxDefaultPlan(input, runtime));
173348 }
174349
175− return available;
350+ return plans;
351+}
352+
353+function toOpenError(message: string, cause: unknown): OpenError {
354+ return new OpenError({ message, cause });
355+}
356+
357+function isWindowsBatchShim(pathService: Path.Path, filePath: string): boolean {
358+ return WINDOWS_BATCH_EXTENSIONS.includes(pathService.extname(filePath).toUpperCase() as never);
359+}
360+
361+function quoteForWindowsCmd(value: string): string {
362+ return `"${value.replaceAll("%", "%%").replaceAll('"', '""')}"`;
363+}
364+
365+function makeWindowsCmdCommandLine(commandPath: string, args: ReadonlyArray<string>): string {
366+ return `"${[commandPath, ...args].map(quoteForWindowsCmd).join(" ")}"`;
176367 }
177368
178−/**
179− * OpenShape - Service API for browser and editor launch actions.
180− */
181369 export interface OpenShape {
182− /**
183− * Open a URL target in the default browser.
184− */
185− readonly openBrowser: (target: string) => Effect.Effect<void, OpenError>;
186−
187− /**
188− * Open a workspace path in a selected editor integration.
189− *
190− * Launches the editor as a detached process so server startup is not blocked.
191− */
370+ readonly getAvailableEditors: Effect.Effect<ReadonlyArray<EditorId>, OpenError>;
371+ readonly openExternal: (input: OpenExternalInput) => Effect.Effect<void, OpenError>;
372+ readonly openBrowser: (
373+ target: string,
374+ options?: Omit<OpenExternalInput, "target">,
375+ ) => Effect.Effect<void, OpenError>;
192376 readonly openInEditor: (input: OpenInEditorInput) => Effect.Effect<void, OpenError>;
193377 }
194378
195−/**
196− * Open - Service tag for browser/editor launch operations.
197− */
198379 export class Open extends ServiceMap.Service<Open, OpenShape>()("t3/open") {}
199380
200−// ==============================
201−// Implementations
202−// ==============================
203−
204−export const resolveEditorLaunch = Effect.fnUntraced(function* (
205− input: OpenInEditorInput,
206− platform: NodeJS.Platform = process.platform,
207−): Effect.fn.Return<EditorLaunch, OpenError> {
208− const editorDef = EDITORS.find((editor) => editor.id === input.editor);
209− if (!editorDef) {
210− return yield* new OpenError({ message: `Unknown editor: ${input.editor}` });
211− }
381+const makeOpen = (options: OpenRuntimeOptions = {}) =>
382+ Effect.gen(function* () {
383+ const fileSystem = yield* FileSystem.FileSystem;
384+ const pathService = yield* Path.Path;
385+ const spawner = yield* ChildProcessSpawner.ChildProcessSpawner;
386+
387+ const isInsideContainer =
388+ options.isInsideContainer ??
389+ (typeof options.env?.CONTAINER === "string" ||
390+ typeof options.env?.container === "string" ||
391+ typeof options.env?.KUBERNETES_SERVICE_HOST === "string"
392+ ? true
393+ : yield* fileSystem.exists("/.dockerenv").pipe(Effect.catch(() => Effect.succeed(false))));
394+
395+ const runtime: OpenRuntime = {
396+ platform: options.platform ?? process.platform,
397+ env: options.env ?? process.env,
398+ isWsl:
399+ options.isWsl ??
400+ detectWsl(options.platform ?? process.platform, options.env ?? process.env),
401+ isInsideContainer,
402+ powerShellCandidates:
403+ options.powerShellCommand !== undefined
404+ ? [options.powerShellCommand]
405+ : (options.isWsl ??
406+ detectWsl(options.platform ?? process.platform, options.env ?? process.env))
407+ ? WSL_POWERSHELL_CANDIDATES
408+ : WINDOWS_POWERSHELL_CANDIDATES,
409+ windowsPathExtensions: resolveWindowsPathExtensions(options.env ?? process.env),
410+ };
411+
412+ const resolveCommand = Effect.fn(function* (
413+ command: string,
414+ ): Effect.fn.Return<Option.Option<ResolvedCommand>, never> {
415+ const candidates = resolveCommandCandidates(command, runtime, pathService);
416+
417+ const resolveExecutableFile = Effect.fn(function* (
418+ filePath: string,
419+ ): Effect.fn.Return<Option.Option<ResolvedCommand>, never> {
420+ const info = yield* fileSystem.stat(filePath).pipe(Effect.option);
421+ if (Option.isNone(info) || info.value.type !== "File") return Option.none();
422+
423+ if (runtime.platform === "win32") {
424+ const extension = pathService.extname(filePath);
425+ if (
426+ extension.length === 0 ||
427+ !runtime.windowsPathExtensions.includes(extension.toUpperCase())
428+ ) {
429+ return Option.none();
430+ }
431+
432+ return Option.some({
433+ path: filePath,
434+ usesCmdWrapper: isWindowsBatchShim(pathService, filePath),
435+ } satisfies ResolvedCommand);
436+ }
437+
438+ return (info.value.mode & 0o111) !== 0
439+ ? Option.some({
440+ path: filePath,
441+ usesCmdWrapper: false,
442+ } satisfies ResolvedCommand)
443+ : Option.none();
444+ });
445+
446+ if (command.includes("/") || command.includes("\\")) {
447+ for (const candidate of candidates) {
448+ const resolved = yield* resolveExecutableFile(candidate);
449+ if (Option.isSome(resolved)) return resolved;
450+ }
451+ return Option.none();
452+ }
212453
213− if (editorDef.command) {
214− return shouldUseGotoFlag(editorDef, input.cwd)
215− ? { command: editorDef.command, args: ["--goto", input.cwd] }
216− : { command: editorDef.command, args: [input.cwd] };
217− }
454+ const pathValue = resolvePathEnvironmentVariable(runtime.env);
455+ if (pathValue.length === 0) return Option.none();
456+
457+ const pathEntries = pathValue
458+ .split(resolvePathDelimiter(runtime.platform))
459+ .map((entry) => stripWrappingQuotes(entry.trim()))
460+ .filter((entry) => entry.length > 0);
461+
462+ for (const pathEntry of pathEntries) {
463+ for (const candidate of candidates) {
464+ const resolved = yield* resolveExecutableFile(pathService.join(pathEntry, candidate));
465+ if (Option.isSome(resolved)) {
466+ return resolved;
467+ }
468+ }
469+ }
218470
219− if (editorDef.id !== "file-manager") {
220− return yield* new OpenError({ message: `Unsupported editor: ${input.editor}` });
221− }
471+ return Option.none();
472+ });
222473
223− return { command: fileManagerCommandForPlatform(platform), args: [input.cwd] };
224−});
474+ const commandAvailable = (command: string) =>
475+ resolveCommand(command).pipe(Effect.map(Option.isSome));
476+
477+ const fileManagerAvailable = Effect.gen(function* () {
478+ const candidates =
479+ runtime.platform === "darwin"
480+ ? ["open"]
481+ : runtime.platform === "win32"
482+ ? [...runtime.powerShellCandidates, "explorer"]
483+ : ["xdg-open"];
484+
485+ for (const candidate of candidates) {
486+ if (yield* commandAvailable(candidate)) {
487+ return true;
488+ }
489+ }
225490
226−export const launchDetached = (launch: EditorLaunch) =>
227− Effect.gen(function* () {
228− if (!isCommandAvailable(launch.command)) {
229− return yield* new OpenError({ message: `Editor command not found: ${launch.command}` });
230− }
491+ return false;
492+ });
493+
494+ const getAvailableEditors = Effect.gen(function* () {
495+ const available: EditorId[] = [];
496+
497+ for (const editor of EDITORS) {
498+ if (editor.id === "file-manager") {
499+ if (yield* fileManagerAvailable) {
500+ available.push(editor.id);
501+ }
502+ continue;
503+ }
504+
505+ if (editor.command && (yield* commandAvailable(editor.command))) {
506+ available.push(editor.id);
507+ }
508+ }
509+
510+ return available as ReadonlyArray<EditorId>;
511+ }).pipe(Effect.mapError((cause) => toOpenError("Failed to resolve available editors", cause)));
512+
513+ const spawnPlan = (
514+ plan: LaunchPlan,
515+ resolvedCommand: ResolvedCommand,
516+ failureMessage: string,
517+ ) =>
518+ spawner
519+ .spawn(
520+ ChildProcess.make(
521+ resolvedCommand.usesCmdWrapper
522+ ? resolveWindowsCommandShell(runtime.env)
523+ : resolvedCommand.path,
524+ resolvedCommand.usesCmdWrapper
525+ ? [
526+ "/d",
527+ "/v:off",
528+ "/s",
529+ "/c",
530+ makeWindowsCmdCommandLine(resolvedCommand.path, plan.args),
531+ ]
532+ : [...plan.args],
533+ {
534+ detached: plan.detached,
535+ shell: plan.shell,
536+ ...(plan.stdio === "ignore"
537+ ? {
538+ stdin: "ignore",
539+ stdout: "ignore",
540+ stderr: "ignore",
541+ }
542+ : {}),
543+ },
544+ ),
545+ )
546+ .pipe(Effect.mapError((cause) => toOpenError(failureMessage, cause)));
547+
548+ const waitForExit = (
549+ plan: LaunchPlan,
550+ failureMessage: string,
551+ handle: ChildProcessSpawner.ChildProcessHandle,
552+ ) =>
553+ handle.exitCode.pipe(
554+ Effect.flatMap((exitCode) =>
555+ !plan.allowNonzeroExitCode && (exitCode as number) !== 0
556+ ? Effect.fail(
557+ new OpenError({
558+ message: `${failureMessage} (code=${exitCode as number})`,
559+ }),
560+ )
561+ : Effect.void,
562+ ),
563+ Effect.mapError((cause) => toOpenError(failureMessage, cause)),
564+ );
565+
566+ const runWaitedPlan = (
567+ plan: LaunchPlan,
568+ resolvedCommand: ResolvedCommand,
569+ failureMessage: string,
570+ ) =>
571+ Effect.acquireUseRelease(
572+ Scope.make("sequential"),
573+ (scope) =>
574+ Effect.gen(function* () {
575+ const handle = yield* spawnPlan(plan, resolvedCommand, failureMessage).pipe(
576+ Scope.provide(scope),
577+ );
578+ yield* waitForExit(plan, failureMessage, handle);
579+ }),
580+ (scope) => Scope.close(scope, Exit.void),
581+ );
582+
583+ const runDetachedPlan = (
584+ plan: LaunchPlan,
585+ resolvedCommand: ResolvedCommand,
586+ failureMessage: string,
587+ ) =>
588+ Effect.gen(function* () {
589+ const childScope = yield* Scope.make("sequential");
590+ const handle = yield* spawnPlan(plan, resolvedCommand, failureMessage).pipe(
591+ Scope.provide(childScope),
592+ Effect.catch((error) =>
593+ Scope.close(childScope, Exit.void).pipe(Effect.andThen(Effect.fail(error))),
594+ ),
595+ );
231596
232− yield* Effect.callback<void, OpenError>((resume) => {
233− let child;
234− try {
235− child = spawn(launch.command, [...launch.args], {
236− detached: true,
237− stdio: "ignore",
238− shell: process.platform === "win32",
239− });
240− } catch (error) {
241− return resume(
242− Effect.fail(new OpenError({ message: "failed to spawn detached process", cause: error })),
597+ const releaseOnExit: Effect.Effect<void, never> = handle.exitCode.pipe(
598+ Effect.ignoreCause,
599+ Effect.ensuring(Scope.close(childScope, Exit.void)),
243600 );
601+
602+ yield* Effect.forkDetach(releaseOnExit);
603+ });
604+
605+ const runPlan = (plan: LaunchPlan, resolvedCommand: ResolvedCommand, failureMessage: string) =>
606+ plan.wait
607+ ? runWaitedPlan(plan, resolvedCommand, failureMessage)
608+ : runDetachedPlan(plan, resolvedCommand, failureMessage);
609+
610+ const runFirstAvailablePlan = (
611+ plans: ReadonlyArray<LaunchPlan>,
612+ failureMessage: string,
613+ ): Effect.Effect<void, OpenError> => {
614+ const [first, ...rest] = plans;
615+ if (!first) {
616+ return Effect.fail(new OpenError({ message: failureMessage }));
244617 }
245618
246− const handleSpawn = () => {
247− child.unref();
248− resume(Effect.void);
249− };
619+ return resolveCommand(first.command).pipe(
620+ Effect.flatMap((resolvedCommand) => {
621+ if (Option.isNone(resolvedCommand)) {
622+ return rest.length === 0
623+ ? Effect.fail(new OpenError({ message: `Command not found: ${first.command}` }))
624+ : runFirstAvailablePlan(rest, failureMessage);
625+ }
626+
627+ return runPlan(first, resolvedCommand.value, failureMessage).pipe(
628+ Effect.catch((error) =>
629+ rest.length === 0 ? Effect.fail(error) : runFirstAvailablePlan(rest, failureMessage),
630+ ),
631+ );
632+ }),
633+ );
634+ };
635+
636+ const openExternal = (input: OpenExternalInput) => {
637+ if (input.target.trim().length === 0) {
638+ return Effect.fail(new OpenError({ message: "Open target must not be empty" }));
639+ }
250640
251− child.once("spawn", handleSpawn);
252− child.once("error", (cause) =>
253− resume(Effect.fail(new OpenError({ message: "failed to spawn detached process", cause }))),
641+ return runFirstAvailablePlan(
642+ resolveExternalPlans(input, runtime),
643+ `Failed to open ${input.target}`,
254644 );
255− });
256− });
645+ };
646+
647+ const openInEditor = (input: OpenInEditorInput) => {
648+ const editor = EDITORS.find((candidate) => candidate.id === input.editor);
649+ if (!editor) {
650+ return Effect.fail(new OpenError({ message: `Unknown editor: ${input.editor}` }));
651+ }
652+
653+ if (editor.command) {
654+ return runFirstAvailablePlan(
655+ [
656+ makeLaunchPlan(
657+ runtime,
658+ editor.command,
659+ shouldUseGotoFlag(editor, input.cwd) ? ["--goto", input.cwd] : [input.cwd],
660+ {
661+ wait: false,
662+ allowNonzeroExitCode: false,
663+ detached: true,
664+ stdio: "ignore",
665+ shell: false,
666+ },
667+ ),
668+ ],
669+ `Failed to open ${input.cwd} in ${input.editor}`,
670+ );
671+ }
257672
258−const make = Effect.gen(function* () {
259− const open = yield* Effect.tryPromise({
260− try: () => import("open"),
261− catch: (cause) => new OpenError({ message: "failed to load browser opener", cause }),
673+ return openExternal({ target: input.cwd });
674+ };
675+
676+ return {
677+ getAvailableEditors,
678+ openExternal,
679+ openBrowser: (target, openOptions = {}) => openExternal({ ...openOptions, target }),
680+ openInEditor,
681+ } satisfies OpenShape;
262682 });
263683
264− return {
265− openBrowser: (target) =>
266− Effect.tryPromise({
267− try: () => open.default(target),
268− catch: (cause) => new OpenError({ message: "Browser auto-open failed", cause }),
269− }),
270− openInEditor: (input) => Effect.flatMap(resolveEditorLaunch(input), launchDetached),
271− } satisfies OpenShape;
272−});
273−
274−export const OpenLive = Layer.effect(Open, make);
684+export const makeOpenLayer = (options: OpenRuntimeOptions = {}) =>
685+ Layer.effect(Open, makeOpen(options));
686+
687+export const OpenLive = makeOpenLayer();
apps/server/src/wsServer.test.ts+6−0
@@ -63,6 +63,8 @@ const asThreadId = (value: string): ThreadId => ThreadId.makeUnsafe(value); · 62 unmodified lines
6363 const asTurnId = (value: string): TurnId => TurnId.makeUnsafe(value);
6464
6565 const defaultOpenService: OpenShape = {
66+ getAvailableEditors: Effect.succeed([]),
67+ openExternal: () => Effect.void,
6668 openBrowser: () => Effect.void,
6769 openInEditor: () => Effect.void,
6870 };
@@ -1030,6 +1032,8 @@ describe("WebSocket Server", () => { · 961 unmodified lines
10301032 it("routes shell.openInEditor through the injected open service", async () => {
10311033 const openCalls: Array<{ cwd: string; editor: string }> = [];
10321034 const openService: OpenShape = {
1035+ getAvailableEditors: Effect.succeed([]),
1036+ openExternal: () => Effect.void,
10331037 openBrowser: () => Effect.void,
10341038 openInEditor: (input) => {
10351039 openCalls.push({ cwd: input.cwd, editor: input.editor });
@@ -1531,6 +1535,8 @@ describe("WebSocket Server", () => { · 495 unmodified lines
15311535 process.on("unhandledRejection", onUnhandledRejection);
15321536
15331537 const brokenOpenService: OpenShape = {
1538+ getAvailableEditors: Effect.succeed([]),
1539+ openExternal: () => Effect.void,
15341540 openBrowser: () => Effect.void,
15351541 openInEditor: () =>
15361542 Effect.sync(() => BigInt(1)).pipe(Effect.map((result) => result as unknown as void)),
apps/server/src/wsServer.ts+6−3
@@ -58,7 +58,7 @@ import { ProviderService } from "./provider/Services/ProviderService"; · 57 unmodified lines
5858 import { ProviderRegistry } from "./provider/Services/ProviderRegistry";
5959 import { CheckpointDiffQuery } from "./checkpointing/Services/CheckpointDiffQuery";
6060 import { clamp } from "effect/Number";
61−import { Open, resolveAvailableEditors } from "./open";
61+import { Open } from "./open";
6262 import { ServerConfig } from "./config";
6363 import { GitCore } from "./git/Services/GitCore.ts";
6464 import { tryHandleProjectFaviconRequest } from "./projectFaviconRoute";
@@ -250,7 +250,10 @@ export const createServer = Effect.fn(function* (): Effect.fn.Return< · 185 unmodified lines
250250 logWebSocketEvents,
251251 autoBootstrapProjectFromCwd,
252252 } = serverConfig;
253− const availableEditors = resolveAvailableEditors();
253+ const open = yield* Open;
254+ const availableEditors = yield* open.getAvailableEditors.pipe(
255+ Effect.mapError((cause) => new ServerLifecycleError({ operation: "availableEditors", cause })),
256+ );
254257
255258 const runtimeServices = yield* Effect.services<
256259 ServerRuntimeServices | ServerConfig | FileSystem.FileSystem | Path.Path
@@ -615,7 +618,7 @@ export const createServer = Effect.fn(function* (): Effect.fn.Return< · 358 unmodified lines
615618 const projectionReadModelQuery = yield* ProjectionSnapshotQuery;
616619 const checkpointDiffQuery = yield* CheckpointDiffQuery;
617620 const orchestrationReactor = yield* OrchestrationReactor;
618− const { openInEditor } = yield* Open;
621+ const { openInEditor } = open;
619622
620623 const subscriptionsScope = yield* Scope.make("sequential");
621624 yield* Effect.addFinalizer(() => Scope.close(subscriptionsScope, Exit.void));
bun.lock+0−15
@@ -54,7 +54,6 @@ · 53 unmodified lines
5454 "@pierre/diffs": "^1.1.0-beta.16",
5555 "effect": "catalog:",
5656 "node-pty": "^1.1.0",
57− "open": "^10.1.0",
5857 "ws": "^8.18.0",
5958 },
6059 "devDependencies": {
@@ -896,8 +895,6 @@ · 835 unmodified lines
896895
897896 "bun-types": ["bun-types@1.3.10", "", { "dependencies": { "@types/node": "*" } }, "sha512-tcpfCCl6XWo6nCVnpcVrxQ+9AYN1iqMIzgrSKYMB/fjLtV2eyAVEg7AxQJuCq/26R6HpKWykQXuSOq/21RYcbg=="],
898897
899− "bundle-name": ["bundle-name@4.1.0", "", { "dependencies": { "run-applescript": "^7.0.0" } }, "sha512-tjwM5exMg6BGRI+kNmTntNsvdZS1X8BFYS6tnJ2hdH0kVxM6/eVZ2xy+FqStSWvYmtfFMDLIxurorHwDKfDz5Q=="],
900−
901898 "cac": ["cac@6.7.14", "", {}, "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ=="],
902899
903900 "cacheable-lookup": ["cacheable-lookup@5.0.4", "", {}, "sha512-2/kNscPhpcxrOigMZzbiWF7dz8ilhb/nIHU3EyZiXWXpeq/au8qJ8VhdftMkty3n7Gj6HIGalQG8oiBNB3AJgA=="],
@@ -972,16 +969,10 @@ · 68 unmodified lines
972969
973970 "decompress-response": ["decompress-response@6.0.0", "", { "dependencies": { "mimic-response": "^3.1.0" } }, "sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ=="],
974971
975− "default-browser": ["default-browser@5.5.0", "", { "dependencies": { "bundle-name": "^4.1.0", "default-browser-id": "^5.0.0" } }, "sha512-H9LMLr5zwIbSxrmvikGuI/5KGhZ8E2zH3stkMgM5LpOWDutGM2JZaj460Udnf1a+946zc7YBgrqEWwbk7zHvGw=="],
976−
977− "default-browser-id": ["default-browser-id@5.0.1", "", {}, "sha512-x1VCxdX4t+8wVfd1so/9w+vQ4vx7lKd2Qp5tDRutErwmR85OgmfX7RlLRMWafRMY7hbEiXIbudNrjOAPa/hL8Q=="],
978−
979972 "defer-to-connect": ["defer-to-connect@2.0.1", "", {}, "sha512-4tvttepXG1VaYGrRibk5EwJd1t4udunSOVMdLSAL6mId1ix438oPwPZMALY41FCijukO1L0twNcGsdzS7dHgDg=="],
980973
981974 "define-data-property": ["define-data-property@1.1.4", "", { "dependencies": { "es-define-property": "^1.0.0", "es-errors": "^1.3.0", "gopd": "^1.0.1" } }, "sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A=="],
982975
983− "define-lazy-prop": ["define-lazy-prop@3.0.0", "", {}, "sha512-N+MeXYoqr3pOgn8xfyRPREN7gHakLYjhsHhWGT3fWAiL4IkAt0iDw14QiiEm2bE30c5XX5q0FtAA3CK5f9/BUg=="],
984−
985976 "define-properties": ["define-properties@1.2.1", "", { "dependencies": { "define-data-property": "^1.0.1", "has-property-descriptors": "^1.0.0", "object-keys": "^1.1.1" } }, "sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg=="],
986977
987978 "defu": ["defu@6.1.4", "", {}, "sha512-mEQCMmwJu317oSz8CwdIOdwf3xMif1ttiM8LTufzc3g6kR+9Pe236twL8j3IYT1F7GfRgGcW6MWxzZjLIkuHIg=="],
@@ -1462,8 +1453,6 @@ · 474 unmodified lines
14621453
14631454 "oniguruma-to-es": ["oniguruma-to-es@4.3.5", "", { "dependencies": { "oniguruma-parser": "^0.12.1", "regex": "^6.1.0", "regex-recursion": "^6.0.2" } }, "sha512-Zjygswjpsewa0NLTsiizVuMQZbp0MDyM6lIt66OxsF21npUDlzpHi1Mgb/qhQdkb+dWFTzJmFbEWdvZgRho8eQ=="],
14641455
1465− "open": ["open@10.2.0", "", { "dependencies": { "default-browser": "^5.2.1", "define-lazy-prop": "^3.0.0", "is-inside-container": "^1.0.0", "wsl-utils": "^0.1.0" } }, "sha512-YgBpdJHPyQ2UE5x+hlSXcnejzAvD0b22U2OuAP+8OnlJT+PjWPxtgmGqKKc+RgTM63U9gN0YzrYc71R2WT/hTA=="],
1466−
14671456 "outvariant": ["outvariant@1.4.3", "", {}, "sha512-+Sl2UErvtsoajRDKCE5/dBz4DIvHXQQnAxtQTF04OJxY0+DyZXSo5P5Bb7XYWOh81syohlYL24hbDwxedPUJCA=="],
14681457
14691458 "oxfmt": ["oxfmt@0.40.0", "", { "dependencies": { "tinypool": "2.1.0" }, "optionalDependencies": { "@oxfmt/binding-android-arm-eabi": "0.40.0", "@oxfmt/binding-android-arm64": "0.40.0", "@oxfmt/binding-darwin-arm64": "0.40.0", "@oxfmt/binding-darwin-x64": "0.40.0", "@oxfmt/binding-freebsd-x64": "0.40.0", "@oxfmt/binding-linux-arm-gnueabihf": "0.40.0", "@oxfmt/binding-linux-arm-musleabihf": "0.40.0", "@oxfmt/binding-linux-arm64-gnu": "0.40.0", "@oxfmt/binding-linux-arm64-musl": "0.40.0", "@oxfmt/binding-linux-ppc64-gnu": "0.40.0", "@oxfmt/binding-linux-riscv64-gnu": "0.40.0", "@oxfmt/binding-linux-riscv64-musl": "0.40.0", "@oxfmt/binding-linux-s390x-gnu": "0.40.0", "@oxfmt/binding-linux-x64-gnu": "0.40.0", "@oxfmt/binding-linux-x64-musl": "0.40.0", "@oxfmt/binding-openharmony-arm64": "0.40.0", "@oxfmt/binding-win32-arm64-msvc": "0.40.0", "@oxfmt/binding-win32-ia32-msvc": "0.40.0", "@oxfmt/binding-win32-x64-msvc": "0.40.0" }, "bin": { "oxfmt": "bin/oxfmt" } }, "sha512-g0C3I7xUj4b4DcagevM9kgH6+pUHytikxUcn3/VUkvzTNaaXBeyZqb7IBsHwojeXm4mTBEC/aBjBTMVUkZwWUQ=="],
@@ -1598,8 +1587,6 @@ · 128 unmodified lines
15981587
15991588 "rolldown-plugin-dts": ["rolldown-plugin-dts@0.22.5", "", { "dependencies": { "@babel/generator": "8.0.0-rc.2", "@babel/helper-validator-identifier": "8.0.0-rc.2", "@babel/parser": "8.0.0-rc.2", "@babel/types": "8.0.0-rc.2", "ast-kit": "^3.0.0-beta.1", "birpc": "^4.0.0", "dts-resolver": "^2.1.3", "get-tsconfig": "^4.13.6", "obug": "^2.1.1" }, "peerDependencies": { "@ts-macro/tsc": "^0.3.6", "@typescript/native-preview": ">=7.0.0-dev.20250601.1", "rolldown": "^1.0.0-rc.3", "typescript": "^5.0.0 || ^6.0.0-beta", "vue-tsc": "~3.2.0" }, "optionalPeers": ["@ts-macro/tsc", "@typescript/native-preview", "typescript", "vue-tsc"] }, "sha512-M/HXfM4cboo+jONx9Z0X+CUf3B5tCi7ni+kR5fUW50Fp9AlZk0oVLesibGWgCXDKFp5lpgQ9yhKoImUFjl3VZw=="],
16001589
1601− "run-applescript": ["run-applescript@7.1.0", "", {}, "sha512-DPe5pVFaAsinSaV6QjQ6gdiedWDcRCbUuiQfQa2wmWV7+xC9bGulGI8+TdRmoFkAPaBXk8CrAbnlY2ISniJ47Q=="],
1602−
16031590 "rxjs": ["rxjs@7.8.2", "", { "dependencies": { "tslib": "^2.1.0" } }, "sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA=="],
16041591
16051592 "sax": ["sax@1.5.0", "", {}, "sha512-21IYA3Q5cQf089Z6tgaUTr7lDAyzoTPx5HRtbhsME8Udispad8dC/+sziTNugOEx54ilvatQ9YCzl4KQLPcRHA=="],
@@ -1856,8 +1843,6 @@ · 250 unmodified lines
18561843
18571844 "ws": ["ws@8.19.0", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg=="],
18581845
1859− "wsl-utils": ["wsl-utils@0.1.0", "", { "dependencies": { "is-wsl": "^3.1.0" } }, "sha512-h3Fbisa2nKGPxCpm89Hk33lBLsnaGBvctQopaBSOW/uIs6FTe1ATyAnKFJrzVs9vpGdsTe73WF3V4lIsk4Gacw=="],
1860−
18611846 "xxhash-wasm": ["xxhash-wasm@1.1.0", "", {}, "sha512-147y/6YNh+tlp6nd/2pWq38i9h6mz/EuQ6njIrmW8D1BS5nCqs0P6DG+m6zTGnNz5I+uhZ0SHxBs9BsPrwcKDA=="],
18621847
18631848 "y18n": ["y18n@5.0.8", "", {}, "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA=="],