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
29 "@pierre/diffs": "^1.1.0-beta.16",29 "@pierre/diffs": "^1.1.0-beta.16",
30 "effect": "catalog:",30 "effect": "catalog:",
31 "node-pty": "^1.1.0",31 "node-pty": "^1.1.0",
32 "open": "^10.1.0",
33 "ws": "^8.18.0"32 "ws": "^8.18.0"
34 },33 },
35 "devDependencies": {34 "devDependencies": {
apps/server/src/main.test.ts+2−0
@@ -51,6 +51,8 @@ const testLayer = Layer.mergeAll( · 50 unmodified lines
51 stopSignal: Effect.void,51 stopSignal: Effect.void,
52 } satisfies ServerShape),52 } satisfies ServerShape),
53 Layer.succeed(Open, {53 Layer.succeed(Open, {
54 getAvailableEditors: Effect.succeed([]),
55 openExternal: () => Effect.void,
54 openBrowser: (_target: string) => Effect.void,56 openBrowser: (_target: string) => Effect.void,
55 openInEditor: () => Effect.void,57 openInEditor: () => Effect.void,
56 } satisfies OpenShape),58 } satisfies OpenShape),
apps/server/src/open.test.ts+533−244
@@ -1,269 +1,558 @@
1import * as NodeServices from "@effect/platform-node/NodeServices";1import * as NodeFileSystem from "@effect/platform-node/NodeFileSystem";
2import * as NodePath from "@effect/platform-node/NodePath";
2import { assert, it } from "@effect/vitest";3import { assert, it } from "@effect/vitest";
3import { assertSuccess } from "@effect/vitest/utils";
4import { FileSystem, Path, Effect } from "effect";
5
6import {4import {
7 isCommandAvailable,5 Deferred,
8 launchDetached,6 Effect,
9 resolveAvailableEditors,7 Exit,
10 resolveEditorLaunch,8 FileSystem,
11} from "./open";9 Layer,
12 10 PlatformError,
13it.layer(NodeServices.layer)("resolveEditorLaunch", (it) => {11 Scope,
14 it.effect("returns commands for command-based editors", () =>12 Sink,
15 Effect.gen(function* () {13 Stream,
16 const antigravityLaunch = yield* resolveEditorLaunch(14} from "effect";
17 { cwd: "/tmp/workspace", editor: "antigravity" },15import { ChildProcessSpawner } from "effect/unstable/process";
18 "darwin",
19 );
20 assert.deepEqual(antigravityLaunch, {
21 command: "agy",
22 args: ["/tmp/workspace"],
23 });
24 16
25 const cursorLaunch = yield* resolveEditorLaunch(17import { makeOpenLayer, Open } from "./open";
26 { cwd: "/tmp/workspace", editor: "cursor" },
27 "darwin",
28 );
29 assert.deepEqual(cursorLaunch, {
30 command: "cursor",
31 args: ["/tmp/workspace"],
32 });
33 18
34 const vscodeLaunch = yield* resolveEditorLaunch(19const encoder = new TextEncoder();
35 { cwd: "/tmp/workspace", editor: "vscode" },
36 "darwin",
37 );
38 assert.deepEqual(vscodeLaunch, {
39 command: "code",
40 args: ["/tmp/workspace"],
41 });
42 20
43 const vscodeInsidersLaunch = yield* resolveEditorLaunch(21interface SpawnCall {
44 { cwd: "/tmp/workspace", editor: "vscode-insiders" },22 readonly command: string;
45 "darwin",23 readonly args: ReadonlyArray<string>;
46 );24 readonly detached?: boolean | undefined;
47 assert.deepEqual(vscodeInsidersLaunch, {25 readonly shell?: boolean | string | undefined;
48 command: "code-insiders",26 readonly stdin?: unknown;
49 args: ["/tmp/workspace"],27 readonly stdout?: unknown;
50 });28 readonly stderr?: unknown;
29}
51 30
52 const vscodiumLaunch = yield* resolveEditorLaunch(31function decodePowerShellCommand(encoded: string): string {
53 { cwd: "/tmp/workspace", editor: "vscodium" },32 return Buffer.from(encoded, "base64").toString("utf16le");
54 "darwin",33}
55 );
56 assert.deepEqual(vscodiumLaunch, {
57 command: "codium",
58 args: ["/tmp/workspace"],
59 });
60 34
61 const zedLaunch = yield* resolveEditorLaunch(35function platformSpawnError(message: string) {
62 { cwd: "/tmp/workspace", editor: "zed" },36 return PlatformError.systemError({
63 "darwin",37 _tag: "Unknown",
38 module: "ChildProcess",
39 method: "spawn",
40 description: message,
41 });
42}
43
44function 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 }),
64 );119 );
65 assert.deepEqual(zedLaunch, {
66 command: "zed",
67 args: ["/tmp/workspace"],
68 });
69 }),120 }),
70 );121 );
122}
71 123
72 it.effect("uses --goto when editor supports line/column suffixes", () =>124const provideOpen = (
73 Effect.gen(function* () {125 options: Parameters<typeof makeOpenLayer>[0],
74 const lineOnly = yield* resolveEditorLaunch(126 spawnLayer: Layer.Layer<ChildProcessSpawner.ChildProcessSpawner>,
75 { cwd: "/tmp/workspace/AGENTS.md:48", editor: "cursor" },127) =>
76 "darwin",128 makeOpenLayer(options).pipe(
77 );129 Layer.provide(Layer.mergeAll(NodeFileSystem.layer, NodePath.layer, spawnLayer)),
78 assert.deepEqual(lineOnly, {130 );
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 });
91 131
92 const vscodeLineAndColumn = yield* resolveEditorLaunch(132const runOpen = <A>(
93 { cwd: "/tmp/workspace/src/open.ts:71:5", editor: "vscode" },133 options: Parameters<typeof makeOpenLayer>[0],
94 "darwin",134 spawnLayer: Layer.Layer<ChildProcessSpawner.ChildProcessSpawner>,
95 );135 effect: Effect.Effect<A, Error, Open>,
96 assert.deepEqual(vscodeLineAndColumn, {136) => effect.pipe(Effect.provide(provideOpen(options, spawnLayer)));
97 command: "code",
98 args: ["--goto", "/tmp/workspace/src/open.ts:71:5"],
99 });
100 137
101 const vscodeInsidersLineAndColumn = yield* resolveEditorLaunch(138const readOpen = Effect.service(Open);
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 });
109 139
110 const vscodiumLineAndColumn = yield* resolveEditorLaunch(140const writeExecutable = (filePath: string) =>
111 { cwd: "/tmp/workspace/src/open.ts:71:5", editor: "vscodium" },141 Effect.gen(function* () {
112 "darwin",142 const fs = yield* FileSystem.FileSystem;
113 );143 yield* fs.writeFileString(filePath, "#!/bin/sh\nexit 0\n");
114 assert.deepEqual(vscodiumLineAndColumn, {144 yield* fs.chmod(filePath, 0o755);
115 command: "codium",145 });
146
147it.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
174it.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
205it.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`,
116 args: ["--goto", "/tmp/workspace/src/open.ts:71:5"],229 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);
118 239
119 const zedLineAndColumn = yield* resolveEditorLaunch(240it.effect("openInEditor launches Windows batch shims through cmd.exe without shell mode", () =>
120 { cwd: "/tmp/workspace/src/open.ts:71:5", editor: "zed" },241 Effect.gen(function* () {
121 "darwin",242 const fs = yield* FileSystem.FileSystem;
122 );243 const dir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-open-vscode-win32-" });
123 assert.deepEqual(zedLineAndColumn, {244 yield* fs.writeFileString(`${dir}/code.cmd`, "@echo off\r\n");
124 command: "zed",
125 args: ["/tmp/workspace/src/open.ts:71:5"],
126 });
127 }),
128 );
129 245
130 it.effect("maps file-manager editor to OS open commands", () =>246 const calls: Array<SpawnCall> = [];
131 Effect.gen(function* () {247 const open = yield* runOpen(
132 const launch1 = yield* resolveEditorLaunch(248 {
133 { cwd: "/tmp/workspace", editor: "file-manager" },249 platform: "win32",
134 "darwin",250 env: {
135 );251 PATH: dir,
136 assert.deepEqual(launch1, {252 PATHEXT: ".COM;.EXE;.BAT;.CMD",
137 command: "open",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
278it.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`,
138 args: ["/tmp/workspace"],320 args: ["/tmp/workspace"],
139 });321 detached: true,
322 shell: false,
323 stdin: "ignore",
324 stdout: "ignore",
325 stderr: "ignore",
326 },
327 ]);
140 328
141 const launch2 = yield* resolveEditorLaunch(329 yield* Deferred.succeed(exitDeferred, void 0);
142 { cwd: "C:\\workspace", editor: "file-manager" },330 yield* Effect.yieldNow;
143 "win32",331 assert.equal(killCount, 0);
144 );332 }).pipe(Effect.provide(NodeFileSystem.layer)),
145 assert.deepEqual(launch2, {333);
146 command: "explorer",334
147 args: ["C:\\workspace"],335it.effect("openInEditor uses the default opener for file-manager on macOS", () =>
148 });336 Effect.gen(function* () {
149 337 const fs = yield* FileSystem.FileSystem;
150 const launch3 = yield* resolveEditorLaunch(338 const dir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-open-macos-" });
151 { cwd: "/tmp/workspace", editor: "file-manager" },339 yield* writeExecutable(`${dir}/open`);
152 "linux",340
153 );341 const calls: Array<SpawnCall> = [];
154 assert.deepEqual(launch3, {342 const open = yield* runOpen(
155 command: "xdg-open",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`,
156 args: ["/tmp/workspace"],359 args: ["/tmp/workspace"],
157 });360 detached: undefined,
158 }),361 shell: false,
159 );362 stdin: undefined,
160});363 stdout: undefined,
161 364 stderr: undefined,
162it.layer(NodeServices.layer)("launchDetached", (it) => {365 },
163 it.effect("resolves when command can be spawned", () =>366 ]);
164 Effect.gen(function* () {367 }).pipe(Effect.provide(NodeFileSystem.layer)),
165 const result = yield* launchDetached({368);
166 command: process.execPath,
167 args: ["-e", "process.exit(0)"],
168 }).pipe(Effect.result);
169 assertSuccess(result, undefined);
170 }),
171 );
172 369
173 it.effect("rejects when command does not exist", () =>370it.effect("openBrowser uses macOS open flags and app arguments", () =>
174 Effect.gen(function* () {371 Effect.gen(function* () {
175 const result = yield* launchDetached({372 const fs = yield* FileSystem.FileSystem;
176 command: `t3code-no-such-command-${Date.now()}`,373 const dir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-open-browser-macos-" });
177 args: [],374 yield* writeExecutable(`${dir}/open`);
178 }).pipe(Effect.result);
179 assert.equal(result._tag, "Failure");
180 }),
181 );
182});
183
184it.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 );
198 375
199 it("returns false when a command is not on PATH", () => {376 const calls: Array<SpawnCall> = [];
200 const env = {377 const open = yield* runOpen(
201 PATH: "",378 {
202 PATHEXT: ".COM;.EXE;.BAT;.CMD",379 platform: "darwin",
203 } satisfies NodeJS.ProcessEnv;380 env: { PATH: dir },
204 assert.equal(isCommandAvailable("definitely-not-installed", { platform: "win32", env }), false);381 },
205 });382 spawnerLayer(calls),
383 readOpen,
384 );
206 385
207 it.effect("does not treat bare files without executable extension as available on win32", () =>386 yield* open.openBrowser("https://example.com", {
208 Effect.gen(function* () {387 wait: true,
209 const fs = yield* FileSystem.FileSystem;388 background: true,
210 const path = yield* Path.Path;389 newInstance: true,
211 const dir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-open-test-" });390 app: {
212 yield* fs.writeFileString(path.join(dir, "npm"), "echo nope\r\n");391 name: "google chrome",
213 const env = {392 arguments: ["--profile-directory=Work"],
214 PATH: dir,393 },
215 PATHEXT: ".COM;.EXE;.BAT;.CMD",394 });
216 } satisfies NodeJS.ProcessEnv;
217 assert.equal(isCommandAvailable("npm", { platform: "win32", env }), false);
218 }),
219 );
220 395
221 it.effect("appends PATHEXT for commands with non-executable extensions on win32", () =>396 assert.deepEqual(calls, [
222 Effect.gen(function* () {397 {
223 const fs = yield* FileSystem.FileSystem;398 command: `${dir}/open`,
224 const path = yield* Path.Path;399 args: [
225 const dir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-open-test-" });400 "--wait-apps",
226 yield* fs.writeFileString(path.join(dir, "my.tool.CMD"), "@echo off\r\n");401 "--background",
227 const env = {402 "--new",
228 PATH: dir,403 "-a",
229 PATHEXT: ".COM;.EXE;.BAT;.CMD",404 "google chrome",
230 } satisfies NodeJS.ProcessEnv;405 "https://example.com",
231 assert.equal(isCommandAvailable("my.tool", { platform: "win32", env }), true);406 "--args",
232 }),407 "--profile-directory=Work",
233 );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);
234 418
235 it.effect("uses platform-specific PATH delimiter for platform overrides", () =>419it.effect("openBrowser uses PowerShell on win32", () =>
236 Effect.gen(function* () {420 Effect.gen(function* () {
237 const fs = yield* FileSystem.FileSystem;421 const fs = yield* FileSystem.FileSystem;
238 const path = yield* Path.Path;422 const dir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-open-browser-win32-" });
239 const firstDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-open-test-" });423 yield* fs.writeFileString(`${dir}/powershell.exe`, "MZ");
240 const secondDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-open-test-" });424
241 yield* fs.writeFileString(path.join(firstDir, "code.CMD"), "@echo off\r\n");425 const calls: Array<SpawnCall> = [];
242 yield* fs.writeFileString(path.join(secondDir, "code.CMD"), "MZ");426 const open = yield* runOpen(
243 const env = {427 {
244 PATH: `${firstDir};${secondDir}`,428 platform: "win32",
245 PATHEXT: ".COM;.EXE;.BAT;.CMD",429 env: {
246 } satisfies NodeJS.ProcessEnv;430 PATH: dir,
247 assert.equal(isCommandAvailable("code", { platform: "win32", env }), true);431 PATHEXT: ".COM;.EXE;.BAT;.CMD",
248 }),432 },
249 );433 },
250});434 spawnerLayer(calls),
251 435 readOpen,
252it.layer(NodeServices.layer)("resolveAvailableEditors", (it) => {436 );
253 it.effect("returns installed editors for command launches", () =>437
254 Effect.gen(function* () {438 yield* open.openBrowser("https://example.com");
255 const fs = yield* FileSystem.FileSystem;439
256 const path = yield* Path.Path;440 assert.equal(calls.length, 1);
257 const dir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-editors-" });441 assert.equal(calls[0]?.command, `${dir}/powershell.exe`);
258 442 assert.equal(calls[0]?.args.at(-2), "-EncodedCommand");
259 yield* fs.writeFileString(path.join(dir, "code-insiders.CMD"), "@echo off\r\n");443 assert.equal(
260 yield* fs.writeFileString(path.join(dir, "codium.CMD"), "@echo off\r\n");444 decodePowerShellCommand(calls[0]?.args.at(-1) ?? ""),
261 yield* fs.writeFileString(path.join(dir, "explorer.CMD"), "MZ");445 "Start 'https://example.com'",
262 const editors = resolveAvailableEditors("win32", {446 );
263 PATH: dir,447 }).pipe(Effect.provide(NodeFileSystem.layer)),
264 PATHEXT: ".COM;.EXE;.BAT;.CMD",448);
265 });449
266 assert.deepEqual(editors, ["vscode-insiders", "vscodium", "file-manager"]);450it.effect("openBrowser falls back from WSL PowerShell to xdg-open", () =>
267 }),451 Effect.gen(function* () {
268 );452 const fs = yield* FileSystem.FileSystem;
269});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
490it.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
535it.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 @@
1/**1/**
2 * Open - Browser/editor launch service interface.2 * Open - Browser/editor launch service interface.
3 *3 *
4 * Owns process launch helpers for opening URLs in a browser and workspace4 * Owns process launch helpers for opening URLs in a browser, workspace paths in
5 * paths in a configured editor.5 * a configured editor, and generic external targets through the platform's
6 * default opener.
6 *7 *
7 * @module Open8 * @module Open
8 */9 */
9import { spawn } from "node:child_process";10import os from "node:os";
10import { accessSync, constants, statSync } from "node:fs";
11import { extname, join } from "node:path";
12 11
13import { EDITORS, type EditorId } from "@t3tools/contracts";12import { EDITORS, type EditorId } from "@t3tools/contracts";
14import { ServiceMap, Schema, Effect, Layer } from "effect";13import { Effect, Exit, FileSystem, Layer, Option, Path, Schema, Scope, ServiceMap } from "effect";
15 14import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process";
16// ==============================
17// Definitions
18// ==============================
19 15
20export class OpenError extends Schema.TaggedErrorClass<OpenError>()("OpenError", {16export class OpenError extends Schema.TaggedErrorClass<OpenError>()("OpenError", {
21 message: Schema.String,17 message: Schema.String,
@@ -27,33 +23,66 @@ export interface OpenInEditorInput { · 5 unmodified lines
27 readonly editor: EditorId;23 readonly editor: EditorId;
28}24}
29 25
30interface EditorLaunch {26export interface OpenApplicationInput {
31 readonly command: string;27 readonly name: string | ReadonlyArray<string>;
32 readonly args: ReadonlyArray<string>;28 readonly arguments?: ReadonlyArray<string>;
29}
30
31export 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>;
33}38}
34 39
35interface CommandAvailabilityOptions {40export interface OpenRuntimeOptions {
36 readonly platform?: NodeJS.Platform;41 readonly platform?: NodeJS.Platform;
37 readonly env?: NodeJS.ProcessEnv;42 readonly env?: NodeJS.ProcessEnv;
43 readonly isWsl?: boolean;
44 readonly isInsideContainer?: boolean;
45 readonly powerShellCommand?: string;
46}
47
48interface 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
57interface 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
67interface ResolvedCommand {
68 readonly path: string;
69 readonly usesCmdWrapper: boolean;
38}70}
39 71
40const LINE_COLUMN_SUFFIX_PATTERN = /:\d+(?::\d+)?$/;72const LINE_COLUMN_SUFFIX_PATTERN = /:\d+(?::\d+)?$/;
73const WINDOWS_POWERSHELL_CANDIDATES = ["powershell.exe", "powershell", "pwsh.exe", "pwsh"] as const;
74const 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;
80const WINDOWS_BATCH_EXTENSIONS = [".CMD", ".BAT"] as const;
41 81
42function shouldUseGotoFlag(editor: (typeof EDITORS)[number], target: string): boolean {82function shouldUseGotoFlag(editor: (typeof EDITORS)[number], target: string): boolean {
43 return editor.supportsGoto && LINE_COLUMN_SUFFIX_PATTERN.test(target);83 return editor.supportsGoto && LINE_COLUMN_SUFFIX_PATTERN.test(target);
44}84}
45 85
46function 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
57function stripWrappingQuotes(value: string): string {86function stripWrappingQuotes(value: string): string {
58 return value.replace(/^"+|"+$/g, "");87 return value.replace(/^"+|"+$/g, "");
59}88}
@@ -72,19 +101,24 @@ function resolveWindowsPathExtensions(env: NodeJS.ProcessEnv): ReadonlyArray<str · 12 unmodified lines
72 .map((entry) => entry.trim())101 .map((entry) => entry.trim())
73 .filter((entry) => entry.length > 0)102 .filter((entry) => entry.length > 0)
74 .map((entry) => (entry.startsWith(".") ? entry.toUpperCase() : `.${entry.toUpperCase()}`));103 .map((entry) => (entry.startsWith(".") ? entry.toUpperCase() : `.${entry.toUpperCase()}`));
104
75 return parsed.length > 0 ? Array.from(new Set(parsed)) : fallback;105 return parsed.length > 0 ? Array.from(new Set(parsed)) : fallback;
76}106}
77 107
108function resolveWindowsCommandShell(env: NodeJS.ProcessEnv): string {
109 return env.ComSpec ?? env.COMSPEC ?? "cmd.exe";
110}
111
78function resolveCommandCandidates(112function resolveCommandCandidates(
79 command: string,113 command: string,
80 platform: NodeJS.Platform,114 runtime: OpenRuntime,
81 windowsPathExtensions: ReadonlyArray<string>,115 pathService: Path.Path,
82): ReadonlyArray<string> {116): ReadonlyArray<string> {
83 if (platform !== "win32") return [command];117 if (runtime.platform !== "win32") return [command];
84 const extension = extname(command);118 const extension = pathService.extname(command);
85 const normalizedExtension = extension.toUpperCase();119 const normalizedExtension = extension.toUpperCase();
86 120
87 if (extension.length > 0 && windowsPathExtensions.includes(normalizedExtension)) {121 if (extension.length > 0 && runtime.windowsPathExtensions.includes(normalizedExtension)) {
88 const commandWithoutExtension = command.slice(0, -extension.length);122 const commandWithoutExtension = command.slice(0, -extension.length);
89 return Array.from(123 return Array.from(
90 new Set([124 new Set([
@@ -96,179 +130,558 @@ function resolveCommandCandidates( · 5 unmodified lines
96 }130 }
97 131
98 const candidates: string[] = [];132 const candidates: string[] = [];
99 for (const extension of windowsPathExtensions) {133 for (const extensionName of runtime.windowsPathExtensions) {
100 candidates.push(`${command}${extension}`);134 candidates.push(`${command}${extensionName}`);
101 candidates.push(`${command}${extension.toLowerCase()}`);135 candidates.push(`${command}${extensionName.toLowerCase()}`);
102 }136 }
103 return Array.from(new Set(candidates));137 return Array.from(new Set(candidates));
104}138}
105 139
106function isExecutableFile(140function resolvePathDelimiter(platform: NodeJS.Platform): string {
107 filePath: string,141 return platform === "win32" ? ";" : ":";
108 platform: NodeJS.Platform,142}
109 windowsPathExtensions: ReadonlyArray<string>,143
110): boolean {144function detectWsl(platform: NodeJS.Platform, env: NodeJS.ProcessEnv): boolean {
111 try {145 if (platform !== "linux") return false;
112 const stat = statSync(filePath);146 if (typeof env.WSL_DISTRO_NAME === "string" || typeof env.WSL_INTEROP === "string") {
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);
120 return true;147 return true;
121 } catch {
122 return false;
123 }148 }
149 return os.release().toLowerCase().includes("microsoft");
124}150}
125 151
126function resolvePathDelimiter(platform: NodeJS.Platform): string {152function quotePowerShellValue(value: string): string {
127 return platform === "win32" ? ";" : ":";153 return `'${value.replaceAll("'", "''")}'`;
128}154}
129 155
130export function isCommandAvailable(156function encodePowerShellCommand(command: string): string {
157 return Buffer.from(command, "utf16le").toString("base64");
158}
159
160function 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
179function isUriLikeTarget(target: string): boolean {
180 return /^[A-Za-z][A-Za-z\d+.-]*:/.test(target);
181}
182
183function shouldPreferWindowsOpenerOnWsl(input: OpenExternalInput, runtime: OpenRuntime): boolean {
184 return runtime.isWsl && !runtime.isInsideContainer && isUriLikeTarget(input.target);
185}
186
187function makeLaunchPlan(
188 runtime: OpenRuntime,
131 command: string,189 command: string,
132 options: CommandAvailabilityOptions = {},190 args: ReadonlyArray<string>,
133): boolean {191 options: {
134 const platform = options.platform ?? process.platform;192 readonly wait: boolean;
135 const env = options.env ?? process.env;193 readonly allowNonzeroExitCode: boolean;
136 const windowsPathExtensions = platform === "win32" ? resolveWindowsPathExtensions(env) : [];194 readonly detached?: boolean;
137 const commandCandidates = resolveCommandCandidates(command, platform, windowsPathExtensions);195 readonly shell?: boolean;
138 196 readonly stdio?: "ignore";
139 if (command.includes("/") || command.includes("\\")) {197 },
140 return commandCandidates.some((candidate) =>198): LaunchPlan {
141 isExecutableFile(candidate, platform, windowsPathExtensions),199 return {
142 );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
210function 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
226function 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);
143 }241 }
144 242
145 const pathValue = resolvePathEnvironmentVariable(env);243 return makeLaunchPlan(runtime, "open", args, {
146 if (pathValue.length === 0) return false;244 wait,
147 const pathEntries = pathValue245 allowNonzeroExitCode: input.allowNonzeroExitCode ?? false,
148 .split(resolvePathDelimiter(platform))246 shell: false,
149 .map((entry) => stripWrappingQuotes(entry.trim()))247 });
150 .filter((entry) => entry.length > 0);248}
151 249
152 for (const pathEntry of pathEntries) {250function makePowerShellPlan(
153 for (const candidate of commandCandidates) {251 input: OpenExternalInput,
154 if (isExecutableFile(join(pathEntry, candidate), platform, windowsPathExtensions)) {252 runtime: OpenRuntime,
155 return true;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
280function 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
291function 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
299function 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
311function 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));
156 }325 }
326 continue;
157 }327 }
158 }
159 return false;
160}
161 328
162export function resolveAvailableEditors(329 if (runtime.platform === "darwin") {
163 platform: NodeJS.Platform = process.platform,330 plans.push(makeDarwinDefaultPlan(input, runtime));
164 env: NodeJS.ProcessEnv = process.env,331 continue;
165): ReadonlyArray<EditorId> {332 }
166 const available: EditorId[] = [];
167 333
168 for (const editor of EDITORS) {334 if (runtime.platform === "win32" || preferWindowsOpenerOnWsl) {
169 const command = editor.command ?? fileManagerCommandForPlatform(platform);335 for (const powerShellCommand of runtime.powerShellCandidates) {
170 if (isCommandAvailable(command, { platform, env })) {336 plans.push(makePowerShellPlan(input, runtime, powerShellCommand));
171 available.push(editor.id);337 }
172 }338 }
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));
173 }348 }
174 349
175 return available;350 return plans;
351}
352
353function toOpenError(message: string, cause: unknown): OpenError {
354 return new OpenError({ message, cause });
355}
356
357function isWindowsBatchShim(pathService: Path.Path, filePath: string): boolean {
358 return WINDOWS_BATCH_EXTENSIONS.includes(pathService.extname(filePath).toUpperCase() as never);
359}
360
361function quoteForWindowsCmd(value: string): string {
362 return `"${value.replaceAll("%", "%%").replaceAll('"', '""')}"`;
363}
364
365function makeWindowsCmdCommandLine(commandPath: string, args: ReadonlyArray<string>): string {
366 return `"${[commandPath, ...args].map(quoteForWindowsCmd).join(" ")}"`;
176}367}
177 368
178/**
179 * OpenShape - Service API for browser and editor launch actions.
180 */
181export interface OpenShape {369export interface OpenShape {
182 /**370 readonly getAvailableEditors: Effect.Effect<ReadonlyArray<EditorId>, OpenError>;
183 * Open a URL target in the default browser.371 readonly openExternal: (input: OpenExternalInput) => Effect.Effect<void, OpenError>;
184 */372 readonly openBrowser: (
185 readonly openBrowser: (target: string) => Effect.Effect<void, OpenError>;373 target: string,
186 374 options?: Omit<OpenExternalInput, "target">,
187 /**375 ) => Effect.Effect<void, OpenError>;
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 */
192 readonly openInEditor: (input: OpenInEditorInput) => Effect.Effect<void, OpenError>;376 readonly openInEditor: (input: OpenInEditorInput) => Effect.Effect<void, OpenError>;
193}377}
194 378
195/**
196 * Open - Service tag for browser/editor launch operations.
197 */
198export class Open extends ServiceMap.Service<Open, OpenShape>()("t3/open") {}379export class Open extends ServiceMap.Service<Open, OpenShape>()("t3/open") {}
199 380
200// ==============================381const makeOpen = (options: OpenRuntimeOptions = {}) =>
201// Implementations382 Effect.gen(function* () {
202// ==============================383 const fileSystem = yield* FileSystem.FileSystem;
203 384 const pathService = yield* Path.Path;
204export const resolveEditorLaunch = Effect.fnUntraced(function* (385 const spawner = yield* ChildProcessSpawner.ChildProcessSpawner;
205 input: OpenInEditorInput,386
206 platform: NodeJS.Platform = process.platform,387 const isInsideContainer =
207): Effect.fn.Return<EditorLaunch, OpenError> {388 options.isInsideContainer ??
208 const editorDef = EDITORS.find((editor) => editor.id === input.editor);389 (typeof options.env?.CONTAINER === "string" ||
209 if (!editorDef) {390 typeof options.env?.container === "string" ||
210 return yield* new OpenError({ message: `Unknown editor: ${input.editor}` });391 typeof options.env?.KUBERNETES_SERVICE_HOST === "string"
211 }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 }
212 453
213 if (editorDef.command) {454 const pathValue = resolvePathEnvironmentVariable(runtime.env);
214 return shouldUseGotoFlag(editorDef, input.cwd)455 if (pathValue.length === 0) return Option.none();
215 ? { command: editorDef.command, args: ["--goto", input.cwd] }456
216 : { command: editorDef.command, args: [input.cwd] };457 const pathEntries = pathValue
217 }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 }
218 470
219 if (editorDef.id !== "file-manager") {471 return Option.none();
220 return yield* new OpenError({ message: `Unsupported editor: ${input.editor}` });472 });
221 }
222 473
223 return { command: fileManagerCommandForPlatform(platform), args: [input.cwd] };474 const commandAvailable = (command: string) =>
224});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 }
225 490
226export const launchDetached = (launch: EditorLaunch) =>491 return false;
227 Effect.gen(function* () {492 });
228 if (!isCommandAvailable(launch.command)) {493
229 return yield* new OpenError({ message: `Editor command not found: ${launch.command}` });494 const getAvailableEditors = Effect.gen(function* () {
230 }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 );
231 596
232 yield* Effect.callback<void, OpenError>((resume) => {597 const releaseOnExit: Effect.Effect<void, never> = handle.exitCode.pipe(
233 let child;598 Effect.ignoreCause,
234 try {599 Effect.ensuring(Scope.close(childScope, Exit.void)),
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 })),
243 );600 );
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 }));
244 }617 }
245 618
246 const handleSpawn = () => {619 return resolveCommand(first.command).pipe(
247 child.unref();620 Effect.flatMap((resolvedCommand) => {
248 resume(Effect.void);621 if (Option.isNone(resolvedCommand)) {
249 };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 }
250 640
251 child.once("spawn", handleSpawn);641 return runFirstAvailablePlan(
252 child.once("error", (cause) =>642 resolveExternalPlans(input, runtime),
253 resume(Effect.fail(new OpenError({ message: "failed to spawn detached process", cause }))),643 `Failed to open ${input.target}`,
254 );644 );
255 });645 };
256 });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 }
257 672
258const make = Effect.gen(function* () {673 return openExternal({ target: input.cwd });
259 const open = yield* Effect.tryPromise({674 };
260 try: () => import("open"),675
261 catch: (cause) => new OpenError({ message: "failed to load browser opener", cause }),676 return {
677 getAvailableEditors,
678 openExternal,
679 openBrowser: (target, openOptions = {}) => openExternal({ ...openOptions, target }),
680 openInEditor,
681 } satisfies OpenShape;
262 });682 });
263 683
264 return {684export const makeOpenLayer = (options: OpenRuntimeOptions = {}) =>
265 openBrowser: (target) =>685 Layer.effect(Open, makeOpen(options));
266 Effect.tryPromise({686
267 try: () => open.default(target),687export const OpenLive = makeOpenLayer();
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
274export const OpenLive = Layer.effect(Open, make);
apps/server/src/wsServer.test.ts+6−0
@@ -63,6 +63,8 @@ const asThreadId = (value: string): ThreadId => ThreadId.makeUnsafe(value); · 62 unmodified lines
63const asTurnId = (value: string): TurnId => TurnId.makeUnsafe(value);63const asTurnId = (value: string): TurnId => TurnId.makeUnsafe(value);
64 64
65const defaultOpenService: OpenShape = {65const defaultOpenService: OpenShape = {
66 getAvailableEditors: Effect.succeed([]),
67 openExternal: () => Effect.void,
66 openBrowser: () => Effect.void,68 openBrowser: () => Effect.void,
67 openInEditor: () => Effect.void,69 openInEditor: () => Effect.void,
68};70};
@@ -1030,6 +1032,8 @@ describe("WebSocket Server", () => { · 961 unmodified lines
1030 it("routes shell.openInEditor through the injected open service", async () => {1032 it("routes shell.openInEditor through the injected open service", async () => {
1031 const openCalls: Array<{ cwd: string; editor: string }> = [];1033 const openCalls: Array<{ cwd: string; editor: string }> = [];
1032 const openService: OpenShape = {1034 const openService: OpenShape = {
1035 getAvailableEditors: Effect.succeed([]),
1036 openExternal: () => Effect.void,
1033 openBrowser: () => Effect.void,1037 openBrowser: () => Effect.void,
1034 openInEditor: (input) => {1038 openInEditor: (input) => {
1035 openCalls.push({ cwd: input.cwd, editor: input.editor });1039 openCalls.push({ cwd: input.cwd, editor: input.editor });
@@ -1531,6 +1535,8 @@ describe("WebSocket Server", () => { · 495 unmodified lines
1531 process.on("unhandledRejection", onUnhandledRejection);1535 process.on("unhandledRejection", onUnhandledRejection);
1532 1536
1533 const brokenOpenService: OpenShape = {1537 const brokenOpenService: OpenShape = {
1538 getAvailableEditors: Effect.succeed([]),
1539 openExternal: () => Effect.void,
1534 openBrowser: () => Effect.void,1540 openBrowser: () => Effect.void,
1535 openInEditor: () =>1541 openInEditor: () =>
1536 Effect.sync(() => BigInt(1)).pipe(Effect.map((result) => result as unknown as void)),1542 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
58import { ProviderRegistry } from "./provider/Services/ProviderRegistry";58import { ProviderRegistry } from "./provider/Services/ProviderRegistry";
59import { CheckpointDiffQuery } from "./checkpointing/Services/CheckpointDiffQuery";59import { CheckpointDiffQuery } from "./checkpointing/Services/CheckpointDiffQuery";
60import { clamp } from "effect/Number";60import { clamp } from "effect/Number";
61import { Open, resolveAvailableEditors } from "./open";61import { Open } from "./open";
62import { ServerConfig } from "./config";62import { ServerConfig } from "./config";
63import { GitCore } from "./git/Services/GitCore.ts";63import { GitCore } from "./git/Services/GitCore.ts";
64import { tryHandleProjectFaviconRequest } from "./projectFaviconRoute";64import { tryHandleProjectFaviconRequest } from "./projectFaviconRoute";
@@ -250,7 +250,10 @@ export const createServer = Effect.fn(function* (): Effect.fn.Return< · 185 unmodified lines
250 logWebSocketEvents,250 logWebSocketEvents,
251 autoBootstrapProjectFromCwd,251 autoBootstrapProjectFromCwd,
252 } = serverConfig;252 } = 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 );
254 257
255 const runtimeServices = yield* Effect.services<258 const runtimeServices = yield* Effect.services<
256 ServerRuntimeServices | ServerConfig | FileSystem.FileSystem | Path.Path259 ServerRuntimeServices | ServerConfig | FileSystem.FileSystem | Path.Path
@@ -615,7 +618,7 @@ export const createServer = Effect.fn(function* (): Effect.fn.Return< · 358 unmodified lines
615 const projectionReadModelQuery = yield* ProjectionSnapshotQuery;618 const projectionReadModelQuery = yield* ProjectionSnapshotQuery;
616 const checkpointDiffQuery = yield* CheckpointDiffQuery;619 const checkpointDiffQuery = yield* CheckpointDiffQuery;
617 const orchestrationReactor = yield* OrchestrationReactor;620 const orchestrationReactor = yield* OrchestrationReactor;
618 const { openInEditor } = yield* Open;621 const { openInEditor } = open;
619 622
620 const subscriptionsScope = yield* Scope.make("sequential");623 const subscriptionsScope = yield* Scope.make("sequential");
621 yield* Effect.addFinalizer(() => Scope.close(subscriptionsScope, Exit.void));624 yield* Effect.addFinalizer(() => Scope.close(subscriptionsScope, Exit.void));
bun.lock+0−15
@@ -54,7 +54,6 @@ · 53 unmodified lines
54 "@pierre/diffs": "^1.1.0-beta.16",54 "@pierre/diffs": "^1.1.0-beta.16",
55 "effect": "catalog:",55 "effect": "catalog:",
56 "node-pty": "^1.1.0",56 "node-pty": "^1.1.0",
57 "open": "^10.1.0",
58 "ws": "^8.18.0",57 "ws": "^8.18.0",
59 },58 },
60 "devDependencies": {59 "devDependencies": {
@@ -896,8 +895,6 @@ · 835 unmodified lines
896 895
897 "bun-types": ["bun-types@1.3.10", "", { "dependencies": { "@types/node": "*" } }, "sha512-tcpfCCl6XWo6nCVnpcVrxQ+9AYN1iqMIzgrSKYMB/fjLtV2eyAVEg7AxQJuCq/26R6HpKWykQXuSOq/21RYcbg=="],896 "bun-types": ["bun-types@1.3.10", "", { "dependencies": { "@types/node": "*" } }, "sha512-tcpfCCl6XWo6nCVnpcVrxQ+9AYN1iqMIzgrSKYMB/fjLtV2eyAVEg7AxQJuCq/26R6HpKWykQXuSOq/21RYcbg=="],
898 897
899 "bundle-name": ["bundle-name@4.1.0", "", { "dependencies": { "run-applescript": "^7.0.0" } }, "sha512-tjwM5exMg6BGRI+kNmTntNsvdZS1X8BFYS6tnJ2hdH0kVxM6/eVZ2xy+FqStSWvYmtfFMDLIxurorHwDKfDz5Q=="],
900
901 "cac": ["cac@6.7.14", "", {}, "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ=="],898 "cac": ["cac@6.7.14", "", {}, "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ=="],
902 899
903 "cacheable-lookup": ["cacheable-lookup@5.0.4", "", {}, "sha512-2/kNscPhpcxrOigMZzbiWF7dz8ilhb/nIHU3EyZiXWXpeq/au8qJ8VhdftMkty3n7Gj6HIGalQG8oiBNB3AJgA=="],900 "cacheable-lookup": ["cacheable-lookup@5.0.4", "", {}, "sha512-2/kNscPhpcxrOigMZzbiWF7dz8ilhb/nIHU3EyZiXWXpeq/au8qJ8VhdftMkty3n7Gj6HIGalQG8oiBNB3AJgA=="],
@@ -972,16 +969,10 @@ · 68 unmodified lines
972 969
973 "decompress-response": ["decompress-response@6.0.0", "", { "dependencies": { "mimic-response": "^3.1.0" } }, "sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ=="],970 "decompress-response": ["decompress-response@6.0.0", "", { "dependencies": { "mimic-response": "^3.1.0" } }, "sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ=="],
974 971
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
979 "defer-to-connect": ["defer-to-connect@2.0.1", "", {}, "sha512-4tvttepXG1VaYGrRibk5EwJd1t4udunSOVMdLSAL6mId1ix438oPwPZMALY41FCijukO1L0twNcGsdzS7dHgDg=="],972 "defer-to-connect": ["defer-to-connect@2.0.1", "", {}, "sha512-4tvttepXG1VaYGrRibk5EwJd1t4udunSOVMdLSAL6mId1ix438oPwPZMALY41FCijukO1L0twNcGsdzS7dHgDg=="],
980 973
981 "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=="],974 "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=="],
982 975
983 "define-lazy-prop": ["define-lazy-prop@3.0.0", "", {}, "sha512-N+MeXYoqr3pOgn8xfyRPREN7gHakLYjhsHhWGT3fWAiL4IkAt0iDw14QiiEm2bE30c5XX5q0FtAA3CK5f9/BUg=="],
984
985 "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=="],976 "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=="],
986 977
987 "defu": ["defu@6.1.4", "", {}, "sha512-mEQCMmwJu317oSz8CwdIOdwf3xMif1ttiM8LTufzc3g6kR+9Pe236twL8j3IYT1F7GfRgGcW6MWxzZjLIkuHIg=="],978 "defu": ["defu@6.1.4", "", {}, "sha512-mEQCMmwJu317oSz8CwdIOdwf3xMif1ttiM8LTufzc3g6kR+9Pe236twL8j3IYT1F7GfRgGcW6MWxzZjLIkuHIg=="],
@@ -1462,8 +1453,6 @@ · 474 unmodified lines
1462 1453
1463 "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=="],1454 "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=="],
1464 1455
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
1467 "outvariant": ["outvariant@1.4.3", "", {}, "sha512-+Sl2UErvtsoajRDKCE5/dBz4DIvHXQQnAxtQTF04OJxY0+DyZXSo5P5Bb7XYWOh81syohlYL24hbDwxedPUJCA=="],1456 "outvariant": ["outvariant@1.4.3", "", {}, "sha512-+Sl2UErvtsoajRDKCE5/dBz4DIvHXQQnAxtQTF04OJxY0+DyZXSo5P5Bb7XYWOh81syohlYL24hbDwxedPUJCA=="],
1468 1457
1469 "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=="],1458 "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
1598 1587
1599 "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=="],1588 "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=="],
1600 1589
1601 "run-applescript": ["run-applescript@7.1.0", "", {}, "sha512-DPe5pVFaAsinSaV6QjQ6gdiedWDcRCbUuiQfQa2wmWV7+xC9bGulGI8+TdRmoFkAPaBXk8CrAbnlY2ISniJ47Q=="],
1602
1603 "rxjs": ["rxjs@7.8.2", "", { "dependencies": { "tslib": "^2.1.0" } }, "sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA=="],1590 "rxjs": ["rxjs@7.8.2", "", { "dependencies": { "tslib": "^2.1.0" } }, "sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA=="],
1604 1591
1605 "sax": ["sax@1.5.0", "", {}, "sha512-21IYA3Q5cQf089Z6tgaUTr7lDAyzoTPx5HRtbhsME8Udispad8dC/+sziTNugOEx54ilvatQ9YCzl4KQLPcRHA=="],1592 "sax": ["sax@1.5.0", "", {}, "sha512-21IYA3Q5cQf089Z6tgaUTr7lDAyzoTPx5HRtbhsME8Udispad8dC/+sziTNugOEx54ilvatQ9YCzl4KQLPcRHA=="],
@@ -1856,8 +1843,6 @@ · 250 unmodified lines
1856 1843
1857 "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=="],1844 "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=="],
1858 1845
1859 "wsl-utils": ["wsl-utils@0.1.0", "", { "dependencies": { "is-wsl": "^3.1.0" } }, "sha512-h3Fbisa2nKGPxCpm89Hk33lBLsnaGBvctQopaBSOW/uIs6FTe1ATyAnKFJrzVs9vpGdsTe73WF3V4lIsk4Gacw=="],
1860
1861 "xxhash-wasm": ["xxhash-wasm@1.1.0", "", {}, "sha512-147y/6YNh+tlp6nd/2pWq38i9h6mz/EuQ6njIrmW8D1BS5nCqs0P6DG+m6zTGnNz5I+uhZ0SHxBs9BsPrwcKDA=="],1846 "xxhash-wasm": ["xxhash-wasm@1.1.0", "", {}, "sha512-147y/6YNh+tlp6nd/2pWq38i9h6mz/EuQ6njIrmW8D1BS5nCqs0P6DG+m6zTGnNz5I+uhZ0SHxBs9BsPrwcKDA=="],
1862 1847
1863 "y18n": ["y18n@5.0.8", "", {}, "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA=="],1848 "y18n": ["y18n@5.0.8", "", {}, "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA=="],