authorgravatar for 206951365+cursor[bot]@users.noreply.github.comcursor[bot] <206951365+cursor[bot]@users.noreply.github.com> 2026-05-12 20:15:11+02:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-05-12 20:15:11+02:00
logd15909af15602622c6bd1d10aa2e3e165117a1c8
tree7a1749e425107f0d1d6bfc977f40d30267841cab
parent447236d51f4d6835482f6707d627dbc8a39eb553
signature Signed by PGP key B5690EEEBB952194

Use Effect child process for editor launches (#2659)

Co-authored-by: Cursor Agent <cursoragent@cursor.com> Co-authored-by: Julius Marminge <juliusmarminge@users.noreply.github.com> Co-authored-by: Julius Marminge <julius0216@outlook.com>

12 files changed, 1205 insertions(+), 933 deletions(-)

apps/server/package.json+1-2
...@@ -31,8 +31,7 @@...@@ -31,8 +31,7 @@
31 "@opencode-ai/sdk": "^1.3.15",31 "@opencode-ai/sdk": "^1.3.15",
32 "@pierre/diffs": "catalog:",32 "@pierre/diffs": "catalog:",
33 "effect": "catalog:",33 "effect": "catalog:",
34 "node-pty": "^1.1.0",34 "node-pty": "^1.1.0"
35 "open": "^10.1.0"
36 },35 },
37 "devDependencies": {36 "devDependencies": {
38 "@effect/language-service": "catalog:",37 "@effect/language-service": "catalog:",
apps/server/src/open.test.ts deleted-642
...@@ -1,642 +0,0 @@
1import * as NodeServices from "@effect/platform-node/NodeServices";
2import { assert, it } from "@effect/vitest";
3import { assertSuccess } from "@effect/vitest/utils";
4import * as Effect from "effect/Effect";
5import * as FileSystem from "effect/FileSystem";
6import * as Path from "effect/Path";
7import * as Random from "effect/Random";
8
9import {
10 isCommandAvailable,
11 launchDetached,
12 resolveAvailableEditors,
13 resolveEditorLaunch,
14} from "./open.ts";
15
16it.layer(NodeServices.layer)("resolveEditorLaunch", (it) => {
17 it.effect("returns commands for command-based editors", () =>
18 Effect.gen(function* () {
19 const antigravityLaunch = yield* resolveEditorLaunch(
20 { cwd: "/tmp/workspace", editor: "antigravity" },
21 "darwin",
22 { PATH: "" },
23 );
24 assert.deepEqual(antigravityLaunch, {
25 command: "agy",
26 args: ["/tmp/workspace"],
27 });
28
29 const cursorLaunch = yield* resolveEditorLaunch(
30 { cwd: "/tmp/workspace", editor: "cursor" },
31 "darwin",
32 { PATH: "" },
33 );
34 assert.deepEqual(cursorLaunch, {
35 command: "cursor",
36 args: ["/tmp/workspace"],
37 });
38
39 const traeLaunch = yield* resolveEditorLaunch(
40 { cwd: "/tmp/workspace", editor: "trae" },
41 "darwin",
42 );
43 assert.deepEqual(traeLaunch, {
44 command: "trae",
45 args: ["/tmp/workspace"],
46 });
47
48 const kiroLaunch = yield* resolveEditorLaunch(
49 { cwd: "/tmp/workspace", editor: "kiro" },
50 "darwin",
51 { PATH: "" },
52 );
53 assert.deepEqual(kiroLaunch, {
54 command: "kiro",
55 args: ["ide", "/tmp/workspace"],
56 });
57
58 const vscodeLaunch = yield* resolveEditorLaunch(
59 { cwd: "/tmp/workspace", editor: "vscode" },
60 "darwin",
61 { PATH: "" },
62 );
63 assert.deepEqual(vscodeLaunch, {
64 command: "code",
65 args: ["/tmp/workspace"],
66 });
67
68 const vscodeInsidersLaunch = yield* resolveEditorLaunch(
69 { cwd: "/tmp/workspace", editor: "vscode-insiders" },
70 "darwin",
71 );
72 assert.deepEqual(vscodeInsidersLaunch, {
73 command: "code-insiders",
74 args: ["/tmp/workspace"],
75 });
76
77 const vscodiumLaunch = yield* resolveEditorLaunch(
78 { cwd: "/tmp/workspace", editor: "vscodium" },
79 "darwin",
80 );
81 assert.deepEqual(vscodiumLaunch, {
82 command: "codium",
83 args: ["/tmp/workspace"],
84 });
85
86 const zedLaunch = yield* resolveEditorLaunch(
87 { cwd: "/tmp/workspace", editor: "zed" },
88 "darwin",
89 { PATH: "" },
90 );
91 assert.deepEqual(zedLaunch, {
92 command: "zed",
93 args: ["/tmp/workspace"],
94 });
95
96 const ideaLaunch = yield* resolveEditorLaunch(
97 { cwd: "/tmp/workspace", editor: "idea" },
98 "darwin",
99 );
100 assert.deepEqual(ideaLaunch, {
101 command: "idea",
102 args: ["/tmp/workspace"],
103 });
104
105 const aquaLaunch = yield* resolveEditorLaunch(
106 { cwd: "/tmp/workspace", editor: "aqua" },
107 "darwin",
108 );
109 assert.deepEqual(aquaLaunch, {
110 command: "aqua",
111 args: ["/tmp/workspace"],
112 });
113
114 const clionLaunch = yield* resolveEditorLaunch(
115 { cwd: "/tmp/workspace", editor: "clion" },
116 "darwin",
117 );
118 assert.deepEqual(clionLaunch, {
119 command: "clion",
120 args: ["/tmp/workspace"],
121 });
122
123 const datagripLaunch = yield* resolveEditorLaunch(
124 { cwd: "/tmp/workspace", editor: "datagrip" },
125 "darwin",
126 );
127 assert.deepEqual(datagripLaunch, {
128 command: "datagrip",
129 args: ["/tmp/workspace"],
130 });
131
132 const dataspellLaunch = yield* resolveEditorLaunch(
133 { cwd: "/tmp/workspace", editor: "dataspell" },
134 "darwin",
135 );
136 assert.deepEqual(dataspellLaunch, {
137 command: "dataspell",
138 args: ["/tmp/workspace"],
139 });
140
141 const golandLaunch = yield* resolveEditorLaunch(
142 { cwd: "/tmp/workspace", editor: "goland" },
143 "darwin",
144 );
145 assert.deepEqual(golandLaunch, {
146 command: "goland",
147 args: ["/tmp/workspace"],
148 });
149
150 const phpstormLaunch = yield* resolveEditorLaunch(
151 { cwd: "/tmp/workspace", editor: "phpstorm" },
152 "darwin",
153 );
154 assert.deepEqual(phpstormLaunch, {
155 command: "phpstorm",
156 args: ["/tmp/workspace"],
157 });
158
159 const pycharmLaunch = yield* resolveEditorLaunch(
160 { cwd: "/tmp/workspace", editor: "pycharm" },
161 "darwin",
162 );
163 assert.deepEqual(pycharmLaunch, {
164 command: "pycharm",
165 args: ["/tmp/workspace"],
166 });
167
168 const riderLaunch = yield* resolveEditorLaunch(
169 { cwd: "/tmp/workspace", editor: "rider" },
170 "darwin",
171 );
172 assert.deepEqual(riderLaunch, {
173 command: "rider",
174 args: ["/tmp/workspace"],
175 });
176
177 const rubymineLaunch = yield* resolveEditorLaunch(
178 { cwd: "/tmp/workspace", editor: "rubymine" },
179 "darwin",
180 );
181 assert.deepEqual(rubymineLaunch, {
182 command: "rubymine",
183 args: ["/tmp/workspace"],
184 });
185
186 const rustroverLaunch = yield* resolveEditorLaunch(
187 { cwd: "/tmp/workspace", editor: "rustrover" },
188 "darwin",
189 );
190 assert.deepEqual(rustroverLaunch, {
191 command: "rustrover",
192 args: ["/tmp/workspace"],
193 });
194
195 const webstormLaunch = yield* resolveEditorLaunch(
196 { cwd: "/tmp/workspace", editor: "webstorm" },
197 "darwin",
198 );
199 assert.deepEqual(webstormLaunch, {
200 command: "webstorm",
201 args: ["/tmp/workspace"],
202 });
203 }),
204 );
205
206 it.effect("applies launch-style-specific navigation arguments", () =>
207 Effect.gen(function* () {
208 const lineOnly = yield* resolveEditorLaunch(
209 { cwd: "/tmp/workspace/AGENTS.md:48", editor: "cursor" },
210 "darwin",
211 { PATH: "" },
212 );
213 assert.deepEqual(lineOnly, {
214 command: "cursor",
215 args: ["--goto", "/tmp/workspace/AGENTS.md:48"],
216 });
217
218 const lineAndColumn = yield* resolveEditorLaunch(
219 { cwd: "/tmp/workspace/src/open.ts:71:5", editor: "cursor" },
220 "darwin",
221 { PATH: "" },
222 );
223 assert.deepEqual(lineAndColumn, {
224 command: "cursor",
225 args: ["--goto", "/tmp/workspace/src/open.ts:71:5"],
226 });
227
228 const traeLineAndColumn = yield* resolveEditorLaunch(
229 { cwd: "/tmp/workspace/src/open.ts:71:5", editor: "trae" },
230 "darwin",
231 );
232 assert.deepEqual(traeLineAndColumn, {
233 command: "trae",
234 args: ["--goto", "/tmp/workspace/src/open.ts:71:5"],
235 });
236
237 const kiroLineAndColumn = yield* resolveEditorLaunch(
238 { cwd: "/tmp/workspace/src/open.ts:71:5", editor: "kiro" },
239 "darwin",
240 { PATH: "" },
241 );
242 assert.deepEqual(kiroLineAndColumn, {
243 command: "kiro",
244 args: ["ide", "--goto", "/tmp/workspace/src/open.ts:71:5"],
245 });
246
247 const vscodeLineAndColumn = yield* resolveEditorLaunch(
248 { cwd: "/tmp/workspace/src/open.ts:71:5", editor: "vscode" },
249 "darwin",
250 { PATH: "" },
251 );
252 assert.deepEqual(vscodeLineAndColumn, {
253 command: "code",
254 args: ["--goto", "/tmp/workspace/src/open.ts:71:5"],
255 });
256
257 const vscodeInsidersLineAndColumn = yield* resolveEditorLaunch(
258 { cwd: "/tmp/workspace/src/open.ts:71:5", editor: "vscode-insiders" },
259 "darwin",
260 );
261 assert.deepEqual(vscodeInsidersLineAndColumn, {
262 command: "code-insiders",
263 args: ["--goto", "/tmp/workspace/src/open.ts:71:5"],
264 });
265
266 const vscodiumLineAndColumn = yield* resolveEditorLaunch(
267 { cwd: "/tmp/workspace/src/open.ts:71:5", editor: "vscodium" },
268 "darwin",
269 );
270 assert.deepEqual(vscodiumLineAndColumn, {
271 command: "codium",
272 args: ["--goto", "/tmp/workspace/src/open.ts:71:5"],
273 });
274
275 const zedLineAndColumn = yield* resolveEditorLaunch(
276 { cwd: "/tmp/workspace/src/open.ts:71:5", editor: "zed" },
277 "darwin",
278 { PATH: "" },
279 );
280 assert.deepEqual(zedLineAndColumn, {
281 command: "zed",
282 args: ["/tmp/workspace/src/open.ts:71:5"],
283 });
284
285 const zedLineOnly = yield* resolveEditorLaunch(
286 { cwd: "/tmp/workspace/AGENTS.md:48", editor: "zed" },
287 "darwin",
288 { PATH: "" },
289 );
290 assert.deepEqual(zedLineOnly, {
291 command: "zed",
292 args: ["/tmp/workspace/AGENTS.md:48"],
293 });
294
295 const ideaLineOnly = yield* resolveEditorLaunch(
296 { cwd: "/tmp/workspace/AGENTS.md:48", editor: "idea" },
297 "darwin",
298 );
299 assert.deepEqual(ideaLineOnly, {
300 command: "idea",
301 args: ["--line", "48", "/tmp/workspace/AGENTS.md"],
302 });
303
304 const ideaLineAndColumn = yield* resolveEditorLaunch(
305 { cwd: "/tmp/workspace/src/open.ts:71:5", editor: "idea" },
306 "darwin",
307 );
308 assert.deepEqual(ideaLineAndColumn, {
309 command: "idea",
310 args: ["--line", "71", "--column", "5", "/tmp/workspace/src/open.ts"],
311 });
312
313 const aquaLineAndColumn = yield* resolveEditorLaunch(
314 { cwd: "/tmp/workspace/src/open.ts:71:5", editor: "aqua" },
315 "darwin",
316 );
317 assert.deepEqual(aquaLineAndColumn, {
318 command: "aqua",
319 args: ["--line", "71", "--column", "5", "/tmp/workspace/src/open.ts"],
320 });
321
322 const clionLineAndColumn = yield* resolveEditorLaunch(
323 { cwd: "/tmp/workspace/src/open.ts:71:5", editor: "clion" },
324 "darwin",
325 );
326 assert.deepEqual(clionLineAndColumn, {
327 command: "clion",
328 args: ["--line", "71", "--column", "5", "/tmp/workspace/src/open.ts"],
329 });
330
331 const datagripLineAndColumn = yield* resolveEditorLaunch(
332 { cwd: "/tmp/workspace/src/open.ts:71:5", editor: "datagrip" },
333 "darwin",
334 );
335 assert.deepEqual(datagripLineAndColumn, {
336 command: "datagrip",
337 args: ["--line", "71", "--column", "5", "/tmp/workspace/src/open.ts"],
338 });
339
340 const dataspellLineAndColumn = yield* resolveEditorLaunch(
341 { cwd: "/tmp/workspace/src/open.ts:71:5", editor: "dataspell" },
342 "darwin",
343 );
344 assert.deepEqual(dataspellLineAndColumn, {
345 command: "dataspell",
346 args: ["--line", "71", "--column", "5", "/tmp/workspace/src/open.ts"],
347 });
348
349 const golandLineAndColumn = yield* resolveEditorLaunch(
350 { cwd: "/tmp/workspace/src/open.ts:71:5", editor: "goland" },
351 "darwin",
352 );
353 assert.deepEqual(golandLineAndColumn, {
354 command: "goland",
355 args: ["--line", "71", "--column", "5", "/tmp/workspace/src/open.ts"],
356 });
357
358 const phpstormLineAndColumn = yield* resolveEditorLaunch(
359 { cwd: "/tmp/workspace/src/open.ts:71:5", editor: "phpstorm" },
360 "darwin",
361 );
362 assert.deepEqual(phpstormLineAndColumn, {
363 command: "phpstorm",
364 args: ["--line", "71", "--column", "5", "/tmp/workspace/src/open.ts"],
365 });
366
367 const pycharmLineAndColumn = yield* resolveEditorLaunch(
368 { cwd: "/tmp/workspace/src/open.ts:71:5", editor: "pycharm" },
369 "darwin",
370 );
371 assert.deepEqual(pycharmLineAndColumn, {
372 command: "pycharm",
373 args: ["--line", "71", "--column", "5", "/tmp/workspace/src/open.ts"],
374 });
375
376 const riderLineAndColumn = yield* resolveEditorLaunch(
377 { cwd: "/tmp/workspace/src/open.ts:71:5", editor: "rider" },
378 "darwin",
379 );
380 assert.deepEqual(riderLineAndColumn, {
381 command: "rider",
382 args: ["--line", "71", "--column", "5", "/tmp/workspace/src/open.ts"],
383 });
384
385 const rubymineLineAndColumn = yield* resolveEditorLaunch(
386 { cwd: "/tmp/workspace/src/open.ts:71:5", editor: "rubymine" },
387 "darwin",
388 );
389 assert.deepEqual(rubymineLineAndColumn, {
390 command: "rubymine",
391 args: ["--line", "71", "--column", "5", "/tmp/workspace/src/open.ts"],
392 });
393
394 const rustroverLineAndColumn = yield* resolveEditorLaunch(
395 { cwd: "/tmp/workspace/src/open.ts:71:5", editor: "rustrover" },
396 "darwin",
397 );
398 assert.deepEqual(rustroverLineAndColumn, {
399 command: "rustrover",
400 args: ["--line", "71", "--column", "5", "/tmp/workspace/src/open.ts"],
401 });
402
403 const webstormLineOnly = yield* resolveEditorLaunch(
404 { cwd: "/tmp/workspace/AGENTS.md:48", editor: "webstorm" },
405 "darwin",
406 );
407 assert.deepEqual(webstormLineOnly, {
408 command: "webstorm",
409 args: ["--line", "48", "/tmp/workspace/AGENTS.md"],
410 });
411 }),
412 );
413
414 it.effect("falls back to zeditor when zed is not installed", () =>
415 Effect.gen(function* () {
416 const fs = yield* FileSystem.FileSystem;
417 const path = yield* Path.Path;
418 const dir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-open-test-" });
419 yield* fs.writeFileString(path.join(dir, "zeditor"), "#!/bin/sh\nexit 0\n");
420 yield* fs.chmod(path.join(dir, "zeditor"), 0o755);
421
422 const result = yield* resolveEditorLaunch({ cwd: "/tmp/workspace", editor: "zed" }, "linux", {
423 PATH: dir,
424 });
425
426 assert.deepEqual(result, {
427 command: "zeditor",
428 args: ["/tmp/workspace"],
429 });
430 }),
431 );
432
433 it.effect("falls back to the primary command when no alias is installed", () =>
434 Effect.gen(function* () {
435 const result = yield* resolveEditorLaunch({ cwd: "/tmp/workspace", editor: "zed" }, "linux", {
436 PATH: "",
437 });
438 assert.deepEqual(result, {
439 command: "zed",
440 args: ["/tmp/workspace"],
441 });
442 }),
443 );
444
445 it.effect("maps file-manager editor to OS open commands", () =>
446 Effect.gen(function* () {
447 const launch1 = yield* resolveEditorLaunch(
448 { cwd: "/tmp/workspace", editor: "file-manager" },
449 "darwin",
450 { PATH: "" },
451 );
452 assert.deepEqual(launch1, {
453 command: "open",
454 args: ["/tmp/workspace"],
455 });
456
457 const launch2 = yield* resolveEditorLaunch(
458 { cwd: "C:\\workspace", editor: "file-manager" },
459 "win32",
460 { PATH: "" },
461 );
462 assert.deepEqual(launch2, {
463 command: "explorer",
464 args: ["C:\\workspace"],
465 });
466
467 const launch3 = yield* resolveEditorLaunch(
468 { cwd: "/tmp/workspace", editor: "file-manager" },
469 "linux",
470 { PATH: "" },
471 );
472 assert.deepEqual(launch3, {
473 command: "xdg-open",
474 args: ["/tmp/workspace"],
475 });
476 }),
477 );
478});
479
480it.layer(NodeServices.layer)("launchDetached", (it) => {
481 it.effect("resolves when command can be spawned", () =>
482 Effect.gen(function* () {
483 const result = yield* launchDetached({
484 command: process.execPath,
485 args: ["-e", "process.exit(0)"],
486 }).pipe(Effect.result);
487 assertSuccess(result, undefined);
488 }),
489 );
490
491 it.effect("rejects when command does not exist", () =>
492 Effect.gen(function* () {
493 const result = yield* launchDetached({
494 command: `t3code-no-such-command-${yield* Random.nextUUIDv4}`,
495 args: [],
496 }).pipe(Effect.result);
497 assert.equal(result._tag, "Failure");
498 }),
499 );
500});
501
502it.layer(NodeServices.layer)("isCommandAvailable", (it) => {
503 it.effect("resolves win32 commands with PATHEXT", () =>
504 Effect.gen(function* () {
505 const fs = yield* FileSystem.FileSystem;
506 const path = yield* Path.Path;
507 const dir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-open-test-" });
508 yield* fs.writeFileString(path.join(dir, "code.CMD"), "@echo off\r\n");
509 const env = {
510 PATH: dir,
511 PATHEXT: ".COM;.EXE;.BAT;.CMD",
512 } satisfies NodeJS.ProcessEnv;
513 assert.equal(isCommandAvailable("code", { platform: "win32", env }), true);
514 }),
515 );
516
517 it("returns false when a command is not on PATH", () => {
518 const env = {
519 PATH: "",
520 PATHEXT: ".COM;.EXE;.BAT;.CMD",
521 } satisfies NodeJS.ProcessEnv;
522 assert.equal(isCommandAvailable("definitely-not-installed", { platform: "win32", env }), false);
523 });
524
525 it.effect("does not treat bare files without executable extension as available on win32", () =>
526 Effect.gen(function* () {
527 const fs = yield* FileSystem.FileSystem;
528 const path = yield* Path.Path;
529 const dir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-open-test-" });
530 yield* fs.writeFileString(path.join(dir, "npm"), "echo nope\r\n");
531 const env = {
532 PATH: dir,
533 PATHEXT: ".COM;.EXE;.BAT;.CMD",
534 } satisfies NodeJS.ProcessEnv;
535 assert.equal(isCommandAvailable("npm", { platform: "win32", env }), false);
536 }),
537 );
538
539 it.effect("appends PATHEXT for commands with non-executable extensions on win32", () =>
540 Effect.gen(function* () {
541 const fs = yield* FileSystem.FileSystem;
542 const path = yield* Path.Path;
543 const dir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-open-test-" });
544 yield* fs.writeFileString(path.join(dir, "my.tool.CMD"), "@echo off\r\n");
545 const env = {
546 PATH: dir,
547 PATHEXT: ".COM;.EXE;.BAT;.CMD",
548 } satisfies NodeJS.ProcessEnv;
549 assert.equal(isCommandAvailable("my.tool", { platform: "win32", env }), true);
550 }),
551 );
552
553 it.effect("uses platform-specific PATH delimiter for platform overrides", () =>
554 Effect.gen(function* () {
555 const fs = yield* FileSystem.FileSystem;
556 const path = yield* Path.Path;
557 const firstDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-open-test-" });
558 const secondDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-open-test-" });
559 yield* fs.writeFileString(path.join(firstDir, "code.CMD"), "@echo off\r\n");
560 yield* fs.writeFileString(path.join(secondDir, "code.CMD"), "MZ");
561 const env = {
562 PATH: `${firstDir};${secondDir}`,
563 PATHEXT: ".COM;.EXE;.BAT;.CMD",
564 } satisfies NodeJS.ProcessEnv;
565 assert.equal(isCommandAvailable("code", { platform: "win32", env }), true);
566 }),
567 );
568});
569
570it.layer(NodeServices.layer)("resolveAvailableEditors", (it) => {
571 it.effect("returns installed editors for command launches", () =>
572 Effect.gen(function* () {
573 const fs = yield* FileSystem.FileSystem;
574 const path = yield* Path.Path;
575 const dir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-editors-" });
576
577 yield* fs.writeFileString(path.join(dir, "trae.CMD"), "@echo off\r\n");
578 yield* fs.writeFileString(path.join(dir, "kiro.CMD"), "@echo off\r\n");
579 yield* fs.writeFileString(path.join(dir, "code-insiders.CMD"), "@echo off\r\n");
580 yield* fs.writeFileString(path.join(dir, "codium.CMD"), "@echo off\r\n");
581 yield* fs.writeFileString(path.join(dir, "aqua.CMD"), "@echo off\r\n");
582 yield* fs.writeFileString(path.join(dir, "clion.CMD"), "@echo off\r\n");
583 yield* fs.writeFileString(path.join(dir, "datagrip.CMD"), "@echo off\r\n");
584 yield* fs.writeFileString(path.join(dir, "dataspell.CMD"), "@echo off\r\n");
585 yield* fs.writeFileString(path.join(dir, "goland.CMD"), "@echo off\r\n");
586 yield* fs.writeFileString(path.join(dir, "phpstorm.CMD"), "@echo off\r\n");
587 yield* fs.writeFileString(path.join(dir, "pycharm.CMD"), "@echo off\r\n");
588 yield* fs.writeFileString(path.join(dir, "rider.CMD"), "@echo off\r\n");
589 yield* fs.writeFileString(path.join(dir, "rubymine.CMD"), "@echo off\r\n");
590 yield* fs.writeFileString(path.join(dir, "rustrover.CMD"), "@echo off\r\n");
591 yield* fs.writeFileString(path.join(dir, "webstorm.CMD"), "@echo off\r\n");
592 yield* fs.writeFileString(path.join(dir, "explorer.CMD"), "MZ");
593 const editors = resolveAvailableEditors("win32", {
594 PATH: dir,
595 PATHEXT: ".COM;.EXE;.BAT;.CMD",
596 });
597 assert.deepEqual(editors, [
598 "trae",
599 "kiro",
600 "vscode-insiders",
601 "vscodium",
602 "aqua",
603 "clion",
604 "datagrip",
605 "dataspell",
606 "goland",
607 "phpstorm",
608 "pycharm",
609 "rider",
610 "rubymine",
611 "rustrover",
612 "webstorm",
613 "file-manager",
614 ]);
615 }),
616 );
617
618 it.effect("includes zed when only the zeditor command is installed", () =>
619 Effect.gen(function* () {
620 const fs = yield* FileSystem.FileSystem;
621 const path = yield* Path.Path;
622 const dir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-editors-" });
623
624 yield* fs.writeFileString(path.join(dir, "zeditor"), "#!/bin/sh\nexit 0\n");
625 yield* fs.writeFileString(path.join(dir, "xdg-open"), "#!/bin/sh\nexit 0\n");
626 yield* fs.chmod(path.join(dir, "zeditor"), 0o755);
627 yield* fs.chmod(path.join(dir, "xdg-open"), 0o755);
628
629 const editors = resolveAvailableEditors("linux", {
630 PATH: dir,
631 });
632 assert.deepEqual(editors, ["zed", "file-manager"]);
633 }),
634 );
635
636 it("omits file-manager when the platform opener is unavailable", () => {
637 const editors = resolveAvailableEditors("linux", {
638 PATH: "",
639 });
640 assert.deepEqual(editors, []);
641 });
642});
apps/server/src/open.ts deleted-241
...@@ -1,241 +0,0 @@
1// @effect-diagnostics nodeBuiltinImport:off
2/**
3 * Open - Browser/editor launch service interface.
4 *
5 * Owns process launch helpers for opening URLs in a browser and workspace
6 * paths in a configured editor.
7 *
8 * @module Open
9 */
10import { spawn } from "node:child_process";
11
12import { EDITORS, OpenError, type EditorId } from "@t3tools/contracts";
13import { isCommandAvailable, type CommandAvailabilityOptions } from "@t3tools/shared/shell";
14import * as Context from "effect/Context";
15import * as Effect from "effect/Effect";
16import * as Layer from "effect/Layer";
17
18// ==============================
19// Definitions
20// ==============================
21
22export { OpenError };
23export { isCommandAvailable } from "@t3tools/shared/shell";
24
25export interface OpenInEditorInput {
26 readonly cwd: string;
27 readonly editor: EditorId;
28}
29
30interface EditorLaunch {
31 readonly command: string;
32 readonly args: ReadonlyArray<string>;
33}
34
35const TARGET_WITH_POSITION_PATTERN = /^(.*?):(\d+)(?::(\d+))?$/;
36
37function parseTargetPathAndPosition(target: string): {
38 path: string;
39 line: string | undefined;
40 column: string | undefined;
41} | null {
42 const match = TARGET_WITH_POSITION_PATTERN.exec(target);
43 if (!match?.[1] || !match[2]) {
44 return null;
45 }
46
47 return {
48 path: match[1],
49 line: match[2],
50 column: match[3],
51 };
52}
53
54function resolveCommandEditorArgs(
55 editor: (typeof EDITORS)[number],
56 target: string,
57): ReadonlyArray<string> {
58 const parsedTarget = parseTargetPathAndPosition(target);
59
60 switch (editor.launchStyle) {
61 case "direct-path":
62 return [target];
63 case "goto":
64 return parsedTarget ? ["--goto", target] : [target];
65 case "line-column": {
66 if (!parsedTarget) {
67 return [target];
68 }
69
70 const { path, line, column } = parsedTarget;
71 return [...(line ? ["--line", line] : []), ...(column ? ["--column", column] : []), path];
72 }
73 }
74}
75
76function resolveEditorArgs(
77 editor: (typeof EDITORS)[number],
78 target: string,
79): ReadonlyArray<string> {
80 const baseArgs = "baseArgs" in editor ? editor.baseArgs : [];
81 return [...baseArgs, ...resolveCommandEditorArgs(editor, target)];
82}
83
84function resolveAvailableCommand(
85 commands: ReadonlyArray<string>,
86 options: CommandAvailabilityOptions = {},
87): string | null {
88 for (const command of commands) {
89 if (isCommandAvailable(command, options)) {
90 return command;
91 }
92 }
93 return null;
94}
95
96function fileManagerCommandForPlatform(platform: NodeJS.Platform): string {
97 switch (platform) {
98 case "darwin":
99 return "open";
100 case "win32":
101 return "explorer";
102 default:
103 return "xdg-open";
104 }
105}
106
107export function resolveAvailableEditors(
108 platform: NodeJS.Platform = process.platform,
109 env: NodeJS.ProcessEnv = process.env,
110): ReadonlyArray<EditorId> {
111 const available: EditorId[] = [];
112
113 for (const editor of EDITORS) {
114 if (editor.commands === null) {
115 const command = fileManagerCommandForPlatform(platform);
116 if (isCommandAvailable(command, { platform, env })) {
117 available.push(editor.id);
118 }
119 continue;
120 }
121
122 const command = resolveAvailableCommand(editor.commands, { platform, env });
123 if (command !== null) {
124 available.push(editor.id);
125 }
126 }
127
128 return available;
129}
130
131/**
132 * OpenShape - Service API for browser and editor launch actions.
133 */
134export interface OpenShape {
135 /**
136 * Open a URL target in the default browser.
137 */
138 readonly openBrowser: (target: string) => Effect.Effect<void, OpenError>;
139
140 /**
141 * Open a workspace path in a selected editor integration.
142 *
143 * Launches the editor as a detached process so server startup is not blocked.
144 */
145 readonly openInEditor: (input: OpenInEditorInput) => Effect.Effect<void, OpenError>;
146}
147
148/**
149 * Open - Service tag for browser/editor launch operations.
150 */
151export class Open extends Context.Service<Open, OpenShape>()("t3/open") {}
152
153// ==============================
154// Implementations
155// ==============================
156
157export const resolveEditorLaunch = Effect.fn("resolveEditorLaunch")(function* (
158 input: OpenInEditorInput,
159 platform: NodeJS.Platform = process.platform,
160 env: NodeJS.ProcessEnv = process.env,
161): Effect.fn.Return<EditorLaunch, OpenError> {
162 yield* Effect.annotateCurrentSpan({
163 "open.editor": input.editor,
164 "open.cwd": input.cwd,
165 "open.platform": platform,
166 });
167 const editorDef = EDITORS.find((editor) => editor.id === input.editor);
168 if (!editorDef) {
169 return yield* new OpenError({ message: `Unknown editor: ${input.editor}` });
170 }
171
172 if (editorDef.commands) {
173 const command =
174 resolveAvailableCommand(editorDef.commands, { platform, env }) ?? editorDef.commands[0];
175 return {
176 command,
177 args: resolveEditorArgs(editorDef, input.cwd),
178 };
179 }
180
181 if (editorDef.id !== "file-manager") {
182 return yield* new OpenError({ message: `Unsupported editor: ${input.editor}` });
183 }
184
185 return { command: fileManagerCommandForPlatform(platform), args: [input.cwd] };
186});
187
188export const launchDetached = (launch: EditorLaunch) =>
189 Effect.gen(function* () {
190 if (!isCommandAvailable(launch.command)) {
191 return yield* new OpenError({ message: `Editor command not found: ${launch.command}` });
192 }
193
194 yield* Effect.callback<void, OpenError>((resume) => {
195 let child;
196 try {
197 const isWin32 = process.platform === "win32";
198 child = spawn(
199 launch.command,
200 isWin32 ? launch.args.map((a) => `"${a}"`) : [...launch.args],
201 {
202 detached: true,
203 stdio: "ignore",
204 shell: isWin32,
205 },
206 );
207 } catch (error) {
208 return resume(
209 Effect.fail(new OpenError({ message: "failed to spawn detached process", cause: error })),
210 );
211 }
212
213 const handleSpawn = () => {
214 child.unref();
215 resume(Effect.void);
216 };
217
218 child.once("spawn", handleSpawn);
219 child.once("error", (cause) =>
220 resume(Effect.fail(new OpenError({ message: "failed to spawn detached process", cause }))),
221 );
222 });
223 });
224
225const make = Effect.gen(function* () {
226 const open = yield* Effect.tryPromise({
227 try: () => import("open"),
228 catch: (cause) => new OpenError({ message: "failed to load browser opener", cause }),
229 });
230
231 return {
232 openBrowser: (target) =>
233 Effect.tryPromise({
234 try: () => open.default(target),
235 catch: (cause) => new OpenError({ message: "Browser auto-open failed", cause }),
236 }),
237 openInEditor: (input) => Effect.flatMap(resolveEditorLaunch(input), launchDetached),
238 } satisfies OpenShape;
239});
240
241export const OpenLive = Layer.effect(Open, make);
apps/server/src/process/externalLauncher.test.ts created+802
...@@ -0,0 +1,802 @@
1import * as NodeServices from "@effect/platform-node/NodeServices";
2import { assert, it } from "@effect/vitest";
3import { assertSuccess } from "@effect/vitest/utils";
4import * as Effect from "effect/Effect";
5import * as Encoding from "effect/Encoding";
6import * as FileSystem from "effect/FileSystem";
7import * as Layer from "effect/Layer";
8import * as Path from "effect/Path";
9import * as Random from "effect/Random";
10import * as Sink from "effect/Sink";
11import * as Stream from "effect/Stream";
12import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process";
13
14import {
15 isCommandAvailable,
16 launchBrowser,
17 launchEditorProcess,
18 resolveAvailableEditors,
19 resolveBrowserLaunch,
20 resolveEditorLaunch,
21} from "./externalLauncher.ts";
22
23function encodeUtf16LeBase64(input: string): string {
24 const bytes = new Uint8Array(input.length * 2);
25 for (let index = 0; index < input.length; index += 1) {
26 const code = input.charCodeAt(index);
27 bytes[index * 2] = code & 0xff;
28 bytes[index * 2 + 1] = code >>> 8;
29 }
30 return Encoding.encodeBase64(bytes);
31}
32
33function makeMockDetachedHandle(onUnref: () => void = () => undefined) {
34 return ChildProcessSpawner.makeHandle({
35 pid: ChildProcessSpawner.ProcessId(1),
36 exitCode: Effect.succeed(ChildProcessSpawner.ExitCode(0)),
37 isRunning: Effect.succeed(true),
38 kill: () => Effect.void,
39 unref: Effect.sync(() => {
40 onUnref();
41 return Effect.void;
42 }),
43 stdin: Sink.drain,
44 stdout: Stream.empty,
45 stderr: Stream.empty,
46 all: Stream.empty,
47 getInputFd: () => Sink.drain,
48 getOutputFd: () => Stream.empty,
49 });
50}
51
52it.layer(NodeServices.layer)("resolveEditorLaunch", (it) => {
53 it.effect("returns commands for command-based editors", () =>
54 Effect.gen(function* () {
55 const antigravityLaunch = yield* resolveEditorLaunch(
56 { cwd: "/tmp/workspace", editor: "antigravity" },
57 "darwin",
58 { PATH: "" },
59 );
60 assert.deepEqual(antigravityLaunch, {
61 command: "agy",
62 args: ["/tmp/workspace"],
63 });
64
65 const cursorLaunch = yield* resolveEditorLaunch(
66 { cwd: "/tmp/workspace", editor: "cursor" },
67 "darwin",
68 { PATH: "" },
69 );
70 assert.deepEqual(cursorLaunch, {
71 command: "cursor",
72 args: ["/tmp/workspace"],
73 });
74
75 const traeLaunch = yield* resolveEditorLaunch(
76 { cwd: "/tmp/workspace", editor: "trae" },
77 "darwin",
78 );
79 assert.deepEqual(traeLaunch, {
80 command: "trae",
81 args: ["/tmp/workspace"],
82 });
83
84 const kiroLaunch = yield* resolveEditorLaunch(
85 { cwd: "/tmp/workspace", editor: "kiro" },
86 "darwin",
87 { PATH: "" },
88 );
89 assert.deepEqual(kiroLaunch, {
90 command: "kiro",
91 args: ["ide", "/tmp/workspace"],
92 });
93
94 const vscodeLaunch = yield* resolveEditorLaunch(
95 { cwd: "/tmp/workspace", editor: "vscode" },
96 "darwin",
97 { PATH: "" },
98 );
99 assert.deepEqual(vscodeLaunch, {
100 command: "code",
101 args: ["/tmp/workspace"],
102 });
103
104 const vscodeInsidersLaunch = yield* resolveEditorLaunch(
105 { cwd: "/tmp/workspace", editor: "vscode-insiders" },
106 "darwin",
107 );
108 assert.deepEqual(vscodeInsidersLaunch, {
109 command: "code-insiders",
110 args: ["/tmp/workspace"],
111 });
112
113 const vscodiumLaunch = yield* resolveEditorLaunch(
114 { cwd: "/tmp/workspace", editor: "vscodium" },
115 "darwin",
116 );
117 assert.deepEqual(vscodiumLaunch, {
118 command: "codium",
119 args: ["/tmp/workspace"],
120 });
121
122 const zedLaunch = yield* resolveEditorLaunch(
123 { cwd: "/tmp/workspace", editor: "zed" },
124 "darwin",
125 { PATH: "" },
126 );
127 assert.deepEqual(zedLaunch, {
128 command: "zed",
129 args: ["/tmp/workspace"],
130 });
131
132 const ideaLaunch = yield* resolveEditorLaunch(
133 { cwd: "/tmp/workspace", editor: "idea" },
134 "darwin",
135 );
136 assert.deepEqual(ideaLaunch, {
137 command: "idea",
138 args: ["/tmp/workspace"],
139 });
140
141 const aquaLaunch = yield* resolveEditorLaunch(
142 { cwd: "/tmp/workspace", editor: "aqua" },
143 "darwin",
144 );
145 assert.deepEqual(aquaLaunch, {
146 command: "aqua",
147 args: ["/tmp/workspace"],
148 });
149
150 const clionLaunch = yield* resolveEditorLaunch(
151 { cwd: "/tmp/workspace", editor: "clion" },
152 "darwin",
153 );
154 assert.deepEqual(clionLaunch, {
155 command: "clion",
156 args: ["/tmp/workspace"],
157 });
158
159 const datagripLaunch = yield* resolveEditorLaunch(
160 { cwd: "/tmp/workspace", editor: "datagrip" },
161 "darwin",
162 );
163 assert.deepEqual(datagripLaunch, {
164 command: "datagrip",
165 args: ["/tmp/workspace"],
166 });
167
168 const dataspellLaunch = yield* resolveEditorLaunch(
169 { cwd: "/tmp/workspace", editor: "dataspell" },
170 "darwin",
171 );
172 assert.deepEqual(dataspellLaunch, {
173 command: "dataspell",
174 args: ["/tmp/workspace"],
175 });
176
177 const golandLaunch = yield* resolveEditorLaunch(
178 { cwd: "/tmp/workspace", editor: "goland" },
179 "darwin",
180 );
181 assert.deepEqual(golandLaunch, {
182 command: "goland",
183 args: ["/tmp/workspace"],
184 });
185
186 const phpstormLaunch = yield* resolveEditorLaunch(
187 { cwd: "/tmp/workspace", editor: "phpstorm" },
188 "darwin",
189 );
190 assert.deepEqual(phpstormLaunch, {
191 command: "phpstorm",
192 args: ["/tmp/workspace"],
193 });
194
195 const pycharmLaunch = yield* resolveEditorLaunch(
196 { cwd: "/tmp/workspace", editor: "pycharm" },
197 "darwin",
198 );
199 assert.deepEqual(pycharmLaunch, {
200 command: "pycharm",
201 args: ["/tmp/workspace"],
202 });
203
204 const riderLaunch = yield* resolveEditorLaunch(
205 { cwd: "/tmp/workspace", editor: "rider" },
206 "darwin",
207 );
208 assert.deepEqual(riderLaunch, {
209 command: "rider",
210 args: ["/tmp/workspace"],
211 });
212
213 const rubymineLaunch = yield* resolveEditorLaunch(
214 { cwd: "/tmp/workspace", editor: "rubymine" },
215 "darwin",
216 );
217 assert.deepEqual(rubymineLaunch, {
218 command: "rubymine",
219 args: ["/tmp/workspace"],
220 });
221
222 const rustroverLaunch = yield* resolveEditorLaunch(
223 { cwd: "/tmp/workspace", editor: "rustrover" },
224 "darwin",
225 );
226 assert.deepEqual(rustroverLaunch, {
227 command: "rustrover",
228 args: ["/tmp/workspace"],
229 });
230
231 const webstormLaunch = yield* resolveEditorLaunch(
232 { cwd: "/tmp/workspace", editor: "webstorm" },
233 "darwin",
234 );
235 assert.deepEqual(webstormLaunch, {
236 command: "webstorm",
237 args: ["/tmp/workspace"],
238 });
239 }),
240 );
241
242 it.effect("applies launch-style-specific navigation arguments", () =>
243 Effect.gen(function* () {
244 const lineOnly = yield* resolveEditorLaunch(
245 { cwd: "/tmp/workspace/AGENTS.md:48", editor: "cursor" },
246 "darwin",
247 { PATH: "" },
248 );
249 assert.deepEqual(lineOnly, {
250 command: "cursor",
251 args: ["--goto", "/tmp/workspace/AGENTS.md:48"],
252 });
253
254 const lineAndColumn = yield* resolveEditorLaunch(
255 { cwd: "/tmp/workspace/src/process/externalLauncher.ts:71:5", editor: "cursor" },
256 "darwin",
257 { PATH: "" },
258 );
259 assert.deepEqual(lineAndColumn, {
260 command: "cursor",
261 args: ["--goto", "/tmp/workspace/src/process/externalLauncher.ts:71:5"],
262 });
263
264 const traeLineAndColumn = yield* resolveEditorLaunch(
265 { cwd: "/tmp/workspace/src/process/externalLauncher.ts:71:5", editor: "trae" },
266 "darwin",
267 );
268 assert.deepEqual(traeLineAndColumn, {
269 command: "trae",
270 args: ["--goto", "/tmp/workspace/src/process/externalLauncher.ts:71:5"],
271 });
272
273 const kiroLineAndColumn = yield* resolveEditorLaunch(
274 { cwd: "/tmp/workspace/src/process/externalLauncher.ts:71:5", editor: "kiro" },
275 "darwin",
276 { PATH: "" },
277 );
278 assert.deepEqual(kiroLineAndColumn, {
279 command: "kiro",
280 args: ["ide", "--goto", "/tmp/workspace/src/process/externalLauncher.ts:71:5"],
281 });
282
283 const vscodeLineAndColumn = yield* resolveEditorLaunch(
284 { cwd: "/tmp/workspace/src/process/externalLauncher.ts:71:5", editor: "vscode" },
285 "darwin",
286 { PATH: "" },
287 );
288 assert.deepEqual(vscodeLineAndColumn, {
289 command: "code",
290 args: ["--goto", "/tmp/workspace/src/process/externalLauncher.ts:71:5"],
291 });
292
293 const vscodeInsidersLineAndColumn = yield* resolveEditorLaunch(
294 { cwd: "/tmp/workspace/src/process/externalLauncher.ts:71:5", editor: "vscode-insiders" },
295 "darwin",
296 );
297 assert.deepEqual(vscodeInsidersLineAndColumn, {
298 command: "code-insiders",
299 args: ["--goto", "/tmp/workspace/src/process/externalLauncher.ts:71:5"],
300 });
301
302 const vscodiumLineAndColumn = yield* resolveEditorLaunch(
303 { cwd: "/tmp/workspace/src/process/externalLauncher.ts:71:5", editor: "vscodium" },
304 "darwin",
305 );
306 assert.deepEqual(vscodiumLineAndColumn, {
307 command: "codium",
308 args: ["--goto", "/tmp/workspace/src/process/externalLauncher.ts:71:5"],
309 });
310
311 const zedLineAndColumn = yield* resolveEditorLaunch(
312 { cwd: "/tmp/workspace/src/process/externalLauncher.ts:71:5", editor: "zed" },
313 "darwin",
314 { PATH: "" },
315 );
316 assert.deepEqual(zedLineAndColumn, {
317 command: "zed",
318 args: ["/tmp/workspace/src/process/externalLauncher.ts:71:5"],
319 });
320
321 const zedLineOnly = yield* resolveEditorLaunch(
322 { cwd: "/tmp/workspace/AGENTS.md:48", editor: "zed" },
323 "darwin",
324 { PATH: "" },
325 );
326 assert.deepEqual(zedLineOnly, {
327 command: "zed",
328 args: ["/tmp/workspace/AGENTS.md:48"],
329 });
330
331 const ideaLineOnly = yield* resolveEditorLaunch(
332 { cwd: "/tmp/workspace/AGENTS.md:48", editor: "idea" },
333 "darwin",
334 );
335 assert.deepEqual(ideaLineOnly, {
336 command: "idea",
337 args: ["--line", "48", "/tmp/workspace/AGENTS.md"],
338 });
339
340 const ideaLineAndColumn = yield* resolveEditorLaunch(
341 { cwd: "/tmp/workspace/src/process/externalLauncher.ts:71:5", editor: "idea" },
342 "darwin",
343 );
344 assert.deepEqual(ideaLineAndColumn, {
345 command: "idea",
346 args: ["--line", "71", "--column", "5", "/tmp/workspace/src/process/externalLauncher.ts"],
347 });
348
349 const aquaLineAndColumn = yield* resolveEditorLaunch(
350 { cwd: "/tmp/workspace/src/process/externalLauncher.ts:71:5", editor: "aqua" },
351 "darwin",
352 );
353 assert.deepEqual(aquaLineAndColumn, {
354 command: "aqua",
355 args: ["--line", "71", "--column", "5", "/tmp/workspace/src/process/externalLauncher.ts"],
356 });
357
358 const clionLineAndColumn = yield* resolveEditorLaunch(
359 { cwd: "/tmp/workspace/src/process/externalLauncher.ts:71:5", editor: "clion" },
360 "darwin",
361 );
362 assert.deepEqual(clionLineAndColumn, {
363 command: "clion",
364 args: ["--line", "71", "--column", "5", "/tmp/workspace/src/process/externalLauncher.ts"],
365 });
366
367 const datagripLineAndColumn = yield* resolveEditorLaunch(
368 { cwd: "/tmp/workspace/src/process/externalLauncher.ts:71:5", editor: "datagrip" },
369 "darwin",
370 );
371 assert.deepEqual(datagripLineAndColumn, {
372 command: "datagrip",
373 args: ["--line", "71", "--column", "5", "/tmp/workspace/src/process/externalLauncher.ts"],
374 });
375
376 const dataspellLineAndColumn = yield* resolveEditorLaunch(
377 { cwd: "/tmp/workspace/src/process/externalLauncher.ts:71:5", editor: "dataspell" },
378 "darwin",
379 );
380 assert.deepEqual(dataspellLineAndColumn, {
381 command: "dataspell",
382 args: ["--line", "71", "--column", "5", "/tmp/workspace/src/process/externalLauncher.ts"],
383 });
384
385 const golandLineAndColumn = yield* resolveEditorLaunch(
386 { cwd: "/tmp/workspace/src/process/externalLauncher.ts:71:5", editor: "goland" },
387 "darwin",
388 );
389 assert.deepEqual(golandLineAndColumn, {
390 command: "goland",
391 args: ["--line", "71", "--column", "5", "/tmp/workspace/src/process/externalLauncher.ts"],
392 });
393
394 const phpstormLineAndColumn = yield* resolveEditorLaunch(
395 { cwd: "/tmp/workspace/src/process/externalLauncher.ts:71:5", editor: "phpstorm" },
396 "darwin",
397 );
398 assert.deepEqual(phpstormLineAndColumn, {
399 command: "phpstorm",
400 args: ["--line", "71", "--column", "5", "/tmp/workspace/src/process/externalLauncher.ts"],
401 });
402
403 const pycharmLineAndColumn = yield* resolveEditorLaunch(
404 { cwd: "/tmp/workspace/src/process/externalLauncher.ts:71:5", editor: "pycharm" },
405 "darwin",
406 );
407 assert.deepEqual(pycharmLineAndColumn, {
408 command: "pycharm",
409 args: ["--line", "71", "--column", "5", "/tmp/workspace/src/process/externalLauncher.ts"],
410 });
411
412 const riderLineAndColumn = yield* resolveEditorLaunch(
413 { cwd: "/tmp/workspace/src/process/externalLauncher.ts:71:5", editor: "rider" },
414 "darwin",
415 );
416 assert.deepEqual(riderLineAndColumn, {
417 command: "rider",
418 args: ["--line", "71", "--column", "5", "/tmp/workspace/src/process/externalLauncher.ts"],
419 });
420
421 const rubymineLineAndColumn = yield* resolveEditorLaunch(
422 { cwd: "/tmp/workspace/src/process/externalLauncher.ts:71:5", editor: "rubymine" },
423 "darwin",
424 );
425 assert.deepEqual(rubymineLineAndColumn, {
426 command: "rubymine",
427 args: ["--line", "71", "--column", "5", "/tmp/workspace/src/process/externalLauncher.ts"],
428 });
429
430 const rustroverLineAndColumn = yield* resolveEditorLaunch(
431 { cwd: "/tmp/workspace/src/process/externalLauncher.ts:71:5", editor: "rustrover" },
432 "darwin",
433 );
434 assert.deepEqual(rustroverLineAndColumn, {
435 command: "rustrover",
436 args: ["--line", "71", "--column", "5", "/tmp/workspace/src/process/externalLauncher.ts"],
437 });
438
439 const webstormLineOnly = yield* resolveEditorLaunch(
440 { cwd: "/tmp/workspace/AGENTS.md:48", editor: "webstorm" },
441 "darwin",
442 );
443 assert.deepEqual(webstormLineOnly, {
444 command: "webstorm",
445 args: ["--line", "48", "/tmp/workspace/AGENTS.md"],
446 });
447 }),
448 );
449
450 it.effect("falls back to zeditor when zed is not installed", () =>
451 Effect.gen(function* () {
452 const fs = yield* FileSystem.FileSystem;
453 const path = yield* Path.Path;
454 const dir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-external-launcher-test-" });
455 yield* fs.writeFileString(path.join(dir, "zeditor"), "#!/bin/sh\nexit 0\n");
456 yield* fs.chmod(path.join(dir, "zeditor"), 0o755);
457
458 const result = yield* resolveEditorLaunch({ cwd: "/tmp/workspace", editor: "zed" }, "linux", {
459 PATH: dir,
460 });
461
462 assert.deepEqual(result, {
463 command: "zeditor",
464 args: ["/tmp/workspace"],
465 });
466 }),
467 );
468
469 it.effect("falls back to the primary command when no alias is installed", () =>
470 Effect.gen(function* () {
471 const result = yield* resolveEditorLaunch({ cwd: "/tmp/workspace", editor: "zed" }, "linux", {
472 PATH: "",
473 });
474 assert.deepEqual(result, {
475 command: "zed",
476 args: ["/tmp/workspace"],
477 });
478 }),
479 );
480
481 it.effect("maps file-manager editor to OS open commands", () =>
482 Effect.gen(function* () {
483 const launch1 = yield* resolveEditorLaunch(
484 { cwd: "/tmp/workspace", editor: "file-manager" },
485 "darwin",
486 { PATH: "" },
487 );
488 assert.deepEqual(launch1, {
489 command: "open",
490 args: ["/tmp/workspace"],
491 });
492
493 const launch2 = yield* resolveEditorLaunch(
494 { cwd: "C:\\workspace", editor: "file-manager" },
495 "win32",
496 { PATH: "" },
497 );
498 assert.deepEqual(launch2, {
499 command: "explorer",
500 args: ["C:\\workspace"],
501 });
502
503 const launch3 = yield* resolveEditorLaunch(
504 { cwd: "/tmp/workspace", editor: "file-manager" },
505 "linux",
506 { PATH: "" },
507 );
508 assert.deepEqual(launch3, {
509 command: "xdg-open",
510 args: ["/tmp/workspace"],
511 });
512 }),
513 );
514});
515
516it("resolveBrowserLaunch maps default browser launchers by platform", () => {
517 const target = "https://example.com/some path?name=o'hara";
518
519 assert.deepEqual(resolveBrowserLaunch(target, "darwin").command, "open");
520 assert.deepEqual(resolveBrowserLaunch(target, "darwin").args, [target]);
521 assert.deepEqual(resolveBrowserLaunch(target, "darwin").options, {
522 detached: true,
523 stdin: "ignore",
524 stdout: "ignore",
525 stderr: "ignore",
526 });
527
528 assert.deepEqual(resolveBrowserLaunch(target, "linux", {}).command, "xdg-open");
529 assert.deepEqual(resolveBrowserLaunch(target, "linux", {}).args, [target]);
530
531 const windows = resolveBrowserLaunch(target, "win32", {
532 SYSTEMROOT: "C:\\Windows",
533 });
534 assert.equal(windows.command, "C:\\Windows\\System32\\WindowsPowerShell\\v1.0\\powershell.exe");
535 assert.deepEqual(windows.args, [
536 "-NoProfile",
537 "-NonInteractive",
538 "-ExecutionPolicy",
539 "Bypass",
540 "-EncodedCommand",
541 encodeUtf16LeBase64(
542 "$ProgressPreference = 'SilentlyContinue'; Start 'https://example.com/some path?name=o''hara'",
543 ),
544 ]);
545 assert.deepEqual(windows.options, {
546 detached: true,
547 shell: false,
548 stdin: "ignore",
549 stdout: "ignore",
550 stderr: "ignore",
551 });
552});
553
554it("resolveBrowserLaunch opens through Windows from WSL when not remote", () => {
555 const launch = resolveBrowserLaunch("https://example.com", "linux", {
556 WSL_DISTRO_NAME: "Ubuntu",
557 });
558 assert.equal(launch.command, "/mnt/c/Windows/System32/WindowsPowerShell/v1.0/powershell.exe");
559 assert.equal(launch.options.detached, true);
560});
561
562it("resolveBrowserLaunch keeps xdg-open for WSL over SSH", () => {
563 const launch = resolveBrowserLaunch("https://example.com", "linux", {
564 WSL_DISTRO_NAME: "Ubuntu",
565 SSH_CONNECTION: "client server",
566 });
567 assert.equal(launch.command, "xdg-open");
568});
569
570it.layer(NodeServices.layer)("launchBrowser", (it) => {
571 it.effect("spawns through the ChildProcessSpawner service and unrefs the handle", () =>
572 Effect.gen(function* () {
573 let spawnedCommand: ChildProcess.StandardCommand | undefined;
574 let didUnref = false;
575
576 const spawnerLayer = Layer.mock(ChildProcessSpawner.ChildProcessSpawner, {
577 spawn: (command) =>
578 Effect.sync(() => {
579 assert.equal(ChildProcess.isStandardCommand(command), true);
580 if (!ChildProcess.isStandardCommand(command)) {
581 throw new Error("Expected a standard command");
582 }
583 spawnedCommand = command;
584 return makeMockDetachedHandle(() => {
585 didUnref = true;
586 });
587 }),
588 });
589
590 const result = yield* launchBrowser("https://example.com").pipe(
591 Effect.provide(spawnerLayer),
592 Effect.result,
593 );
594
595 assertSuccess(result, undefined);
596 assert.ok(spawnedCommand);
597 const expectedLaunch = resolveBrowserLaunch("https://example.com");
598 assert.equal(spawnedCommand.command, expectedLaunch.command);
599 assert.deepEqual(spawnedCommand.args, expectedLaunch.args);
600 assert.deepEqual(spawnedCommand.options, expectedLaunch.options);
601 assert.equal(didUnref, true);
602 }),
603 );
604});
605
606it.layer(NodeServices.layer)("launchEditorProcess", (it) => {
607 it.effect("spawns through the ChildProcessSpawner service and unrefs the handle", () =>
608 Effect.gen(function* () {
609 let spawnedCommand: ChildProcess.StandardCommand | undefined;
610 let didUnref = false;
611 const expectedArgs = ["-e", "process.exit(0)"];
612
613 const spawnerLayer = Layer.mock(ChildProcessSpawner.ChildProcessSpawner, {
614 spawn: (command) =>
615 Effect.sync(() => {
616 assert.equal(ChildProcess.isStandardCommand(command), true);
617 if (!ChildProcess.isStandardCommand(command)) {
618 throw new Error("Expected a standard command");
619 }
620 spawnedCommand = command;
621 return makeMockDetachedHandle(() => {
622 didUnref = true;
623 });
624 }),
625 });
626
627 const result = yield* launchEditorProcess({
628 command: process.execPath,
629 args: expectedArgs,
630 }).pipe(Effect.provide(spawnerLayer), Effect.result);
631
632 assertSuccess(result, undefined);
633 assert.ok(spawnedCommand);
634 assert.equal(spawnedCommand.command, process.execPath);
635 assert.deepEqual(
636 spawnedCommand.args,
637 process.platform === "win32" ? expectedArgs.map((arg) => `"${arg}"`) : expectedArgs,
638 );
639 assert.deepEqual(spawnedCommand.options, {
640 detached: true,
641 shell: process.platform === "win32",
642 stdin: "ignore",
643 stdout: "ignore",
644 stderr: "ignore",
645 });
646 assert.equal(didUnref, true);
647 }),
648 );
649
650 it.effect("rejects when command does not exist", () =>
651 Effect.gen(function* () {
652 const spawnerLayer = Layer.mock(ChildProcessSpawner.ChildProcessSpawner, {});
653 const result = yield* launchEditorProcess({
654 command: `t3code-no-such-command-${yield* Random.nextUUIDv4}`,
655 args: [],
656 }).pipe(Effect.provide(spawnerLayer), Effect.result);
657 assert.equal(result._tag, "Failure");
658 }),
659 );
660});
661
662it.layer(NodeServices.layer)("isCommandAvailable", (it) => {
663 it.effect("resolves win32 commands with PATHEXT", () =>
664 Effect.gen(function* () {
665 const fs = yield* FileSystem.FileSystem;
666 const path = yield* Path.Path;
667 const dir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-external-launcher-test-" });
668 yield* fs.writeFileString(path.join(dir, "code.CMD"), "@echo off\r\n");
669 const env = {
670 PATH: dir,
671 PATHEXT: ".COM;.EXE;.BAT;.CMD",
672 } satisfies NodeJS.ProcessEnv;
673 assert.equal(isCommandAvailable("code", { platform: "win32", env }), true);
674 }),
675 );
676
677 it("returns false when a command is not on PATH", () => {
678 const env = {
679 PATH: "",
680 PATHEXT: ".COM;.EXE;.BAT;.CMD",
681 } satisfies NodeJS.ProcessEnv;
682 assert.equal(isCommandAvailable("definitely-not-installed", { platform: "win32", env }), false);
683 });
684
685 it.effect("does not treat bare files without executable extension as available on win32", () =>
686 Effect.gen(function* () {
687 const fs = yield* FileSystem.FileSystem;
688 const path = yield* Path.Path;
689 const dir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-external-launcher-test-" });
690 yield* fs.writeFileString(path.join(dir, "npm"), "echo nope\r\n");
691 const env = {
692 PATH: dir,
693 PATHEXT: ".COM;.EXE;.BAT;.CMD",
694 } satisfies NodeJS.ProcessEnv;
695 assert.equal(isCommandAvailable("npm", { platform: "win32", env }), false);
696 }),
697 );
698
699 it.effect("appends PATHEXT for commands with non-executable extensions on win32", () =>
700 Effect.gen(function* () {
701 const fs = yield* FileSystem.FileSystem;
702 const path = yield* Path.Path;
703 const dir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-external-launcher-test-" });
704 yield* fs.writeFileString(path.join(dir, "my.tool.CMD"), "@echo off\r\n");
705 const env = {
706 PATH: dir,
707 PATHEXT: ".COM;.EXE;.BAT;.CMD",
708 } satisfies NodeJS.ProcessEnv;
709 assert.equal(isCommandAvailable("my.tool", { platform: "win32", env }), true);
710 }),
711 );
712
713 it.effect("uses platform-specific PATH delimiter for platform overrides", () =>
714 Effect.gen(function* () {
715 const fs = yield* FileSystem.FileSystem;
716 const path = yield* Path.Path;
717 const firstDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-external-launcher-test-" });
718 const secondDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-external-launcher-test-" });
719 yield* fs.writeFileString(path.join(firstDir, "code.CMD"), "@echo off\r\n");
720 yield* fs.writeFileString(path.join(secondDir, "code.CMD"), "MZ");
721 const env = {
722 PATH: `${firstDir};${secondDir}`,
723 PATHEXT: ".COM;.EXE;.BAT;.CMD",
724 } satisfies NodeJS.ProcessEnv;
725 assert.equal(isCommandAvailable("code", { platform: "win32", env }), true);
726 }),
727 );
728});
729
730it.layer(NodeServices.layer)("resolveAvailableEditors", (it) => {
731 it.effect("returns installed editors for command launches", () =>
732 Effect.gen(function* () {
733 const fs = yield* FileSystem.FileSystem;
734 const path = yield* Path.Path;
735 const dir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-editors-" });
736
737 yield* fs.writeFileString(path.join(dir, "trae.CMD"), "@echo off\r\n");
738 yield* fs.writeFileString(path.join(dir, "kiro.CMD"), "@echo off\r\n");
739 yield* fs.writeFileString(path.join(dir, "code-insiders.CMD"), "@echo off\r\n");
740 yield* fs.writeFileString(path.join(dir, "codium.CMD"), "@echo off\r\n");
741 yield* fs.writeFileString(path.join(dir, "aqua.CMD"), "@echo off\r\n");
742 yield* fs.writeFileString(path.join(dir, "clion.CMD"), "@echo off\r\n");
743 yield* fs.writeFileString(path.join(dir, "datagrip.CMD"), "@echo off\r\n");
744 yield* fs.writeFileString(path.join(dir, "dataspell.CMD"), "@echo off\r\n");
745 yield* fs.writeFileString(path.join(dir, "goland.CMD"), "@echo off\r\n");
746 yield* fs.writeFileString(path.join(dir, "phpstorm.CMD"), "@echo off\r\n");
747 yield* fs.writeFileString(path.join(dir, "pycharm.CMD"), "@echo off\r\n");
748 yield* fs.writeFileString(path.join(dir, "rider.CMD"), "@echo off\r\n");
749 yield* fs.writeFileString(path.join(dir, "rubymine.CMD"), "@echo off\r\n");
750 yield* fs.writeFileString(path.join(dir, "rustrover.CMD"), "@echo off\r\n");
751 yield* fs.writeFileString(path.join(dir, "webstorm.CMD"), "@echo off\r\n");
752 yield* fs.writeFileString(path.join(dir, "explorer.CMD"), "MZ");
753 const editors = resolveAvailableEditors("win32", {
754 PATH: dir,
755 PATHEXT: ".COM;.EXE;.BAT;.CMD",
756 });
757 assert.deepEqual(editors, [
758 "trae",
759 "kiro",
760 "vscode-insiders",
761 "vscodium",
762 "aqua",
763 "clion",
764 "datagrip",
765 "dataspell",
766 "goland",
767 "phpstorm",
768 "pycharm",
769 "rider",
770 "rubymine",
771 "rustrover",
772 "webstorm",
773 "file-manager",
774 ]);
775 }),
776 );
777
778 it.effect("includes zed when only the zeditor command is installed", () =>
779 Effect.gen(function* () {
780 const fs = yield* FileSystem.FileSystem;
781 const path = yield* Path.Path;
782 const dir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-editors-" });
783
784 yield* fs.writeFileString(path.join(dir, "zeditor"), "#!/bin/sh\nexit 0\n");
785 yield* fs.writeFileString(path.join(dir, "xdg-open"), "#!/bin/sh\nexit 0\n");
786 yield* fs.chmod(path.join(dir, "zeditor"), 0o755);
787 yield* fs.chmod(path.join(dir, "xdg-open"), 0o755);
788
789 const editors = resolveAvailableEditors("linux", {
790 PATH: dir,
791 });
792 assert.deepEqual(editors, ["zed", "file-manager"]);
793 }),
794 );
795
796 it("omits file-manager when the platform opener is unavailable", () => {
797 const editors = resolveAvailableEditors("linux", {
798 PATH: "",
799 });
800 assert.deepEqual(editors, []);
801 });
802});
apps/server/src/process/externalLauncher.ts created+364
...@@ -0,0 +1,364 @@
1/**
2 * ExternalLauncher - external application launch service interface.
3 *
4 * Owns process launch helpers for browser URLs and workspace paths
5 * in configured editor integrations.
6 *
7 * @module ExternalLauncher
8 */
9import {
10 EDITORS,
11 ExternalLauncherError,
12 type EditorId,
13 type LaunchEditorInput,
14} from "@t3tools/contracts";
15import { isCommandAvailable, type CommandAvailabilityOptions } from "@t3tools/shared/shell";
16import * as Context from "effect/Context";
17import * as Effect from "effect/Effect";
18import * as Encoding from "effect/Encoding";
19import * as Layer from "effect/Layer";
20import * as Option from "effect/Option";
21import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process";
22
23// ==============================
24// Definitions
25// ==============================
26
27export { ExternalLauncherError };
28export type { LaunchEditorInput };
29export { isCommandAvailable } from "@t3tools/shared/shell";
30
31interface EditorLaunch {
32 readonly command: string;
33 readonly args: ReadonlyArray<string>;
34}
35
36interface ProcessLaunch {
37 readonly command: string;
38 readonly args: ReadonlyArray<string>;
39 readonly options: ChildProcess.CommandOptions;
40}
41
42interface TargetPathAndPosition {
43 readonly path: string;
44 readonly line: string;
45 readonly column: Option.Option<string>;
46}
47
48const TARGET_WITH_POSITION_PATTERN = /^(.*?):(\d+)(?::(\d+))?$/;
49const POWERSHELL_ARGUMENTS_PREFIX = [
50 "-NoProfile",
51 "-NonInteractive",
52 "-ExecutionPolicy",
53 "Bypass",
54 "-EncodedCommand",
55] as const;
56
57const DETACHED_IGNORE_STDIO_OPTIONS = {
58 detached: true,
59 stdin: "ignore",
60 stdout: "ignore",
61 stderr: "ignore",
62} as const satisfies ChildProcess.CommandOptions;
63
64function parseTargetPathAndPosition(target: string): Option.Option<TargetPathAndPosition> {
65 const match = TARGET_WITH_POSITION_PATTERN.exec(target);
66 if (!match?.[1] || !match[2]) {
67 return Option.none();
68 }
69
70 return Option.some({
71 path: match[1],
72 line: match[2],
73 column: Option.fromUndefinedOr(match[3]),
74 });
75}
76
77function resolveCommandEditorArgs(
78 editor: (typeof EDITORS)[number],
79 target: string,
80): ReadonlyArray<string> {
81 const parsedTarget = parseTargetPathAndPosition(target);
82
83 switch (editor.launchStyle) {
84 case "direct-path":
85 return [target];
86 case "goto":
87 return Option.isSome(parsedTarget) ? ["--goto", target] : [target];
88 case "line-column":
89 return Option.match(parsedTarget, {
90 onNone: () => [target],
91 onSome: ({ path, line, column }) => [
92 "--line",
93 line,
94 ...Option.match(column, {
95 onNone: () => [],
96 onSome: (value) => ["--column", value],
97 }),
98 path,
99 ],
100 });
101 }
102}
103
104function resolveEditorArgs(
105 editor: (typeof EDITORS)[number],
106 target: string,
107): ReadonlyArray<string> {
108 const baseArgs = "baseArgs" in editor ? editor.baseArgs : [];
109 return [...baseArgs, ...resolveCommandEditorArgs(editor, target)];
110}
111
112function resolveAvailableCommand(
113 commands: ReadonlyArray<string>,
114 options: CommandAvailabilityOptions = {},
115): Option.Option<string> {
116 for (const command of commands) {
117 if (isCommandAvailable(command, options)) {
118 return Option.some(command);
119 }
120 }
121 return Option.none();
122}
123
124function encodeUtf16LeBase64(input: string): string {
125 const bytes = new Uint8Array(input.length * 2);
126 for (let index = 0; index < input.length; index += 1) {
127 const code = input.charCodeAt(index);
128 bytes[index * 2] = code & 0xff;
129 bytes[index * 2 + 1] = code >>> 8;
130 }
131 return Encoding.encodeBase64(bytes);
132}
133
134function escapePowerShellStringLiteral(input: string): string {
135 return `'${input.replaceAll("'", "''")}'`;
136}
137
138function resolvePowerShellPath(env: NodeJS.ProcessEnv = process.env): string {
139 return `${env.SYSTEMROOT || env.windir || String.raw`C:\Windows`}\\System32\\WindowsPowerShell\\v1.0\\powershell.exe`;
140}
141
142function resolveWslPowerShellPath(): string {
143 return "/mnt/c/Windows/System32/WindowsPowerShell/v1.0/powershell.exe";
144}
145
146function shouldUseWindowsBrowserFromWsl(
147 platform: NodeJS.Platform,
148 env: NodeJS.ProcessEnv = process.env,
149): boolean {
150 return (
151 platform === "linux" &&
152 (env.WSL_DISTRO_NAME !== undefined || env.WSL_INTEROP !== undefined) &&
153 env.SSH_CONNECTION === undefined &&
154 env.SSH_TTY === undefined &&
155 env.container === undefined
156 );
157}
158
159function resolveWindowsBrowserLaunch(target: string, command: string): ProcessLaunch {
160 const encodedCommand = encodeUtf16LeBase64(
161 `$ProgressPreference = 'SilentlyContinue'; Start ${escapePowerShellStringLiteral(target)}`,
162 );
163 return {
164 command,
165 args: [...POWERSHELL_ARGUMENTS_PREFIX, encodedCommand],
166 options: {
167 detached: true,
168 shell: false,
169 stdin: "ignore",
170 stdout: "ignore",
171 stderr: "ignore",
172 },
173 };
174}
175
176function fileManagerCommandForPlatform(platform: NodeJS.Platform): string {
177 switch (platform) {
178 case "darwin":
179 return "open";
180 case "win32":
181 return "explorer";
182 default:
183 return "xdg-open";
184 }
185}
186
187export function resolveBrowserLaunch(
188 target: string,
189 platform: NodeJS.Platform = process.platform,
190 env: NodeJS.ProcessEnv = process.env,
191): ProcessLaunch {
192 if (platform === "darwin") {
193 return {
194 command: "open",
195 args: [target],
196 options: DETACHED_IGNORE_STDIO_OPTIONS,
197 };
198 }
199
200 if (platform === "win32") {
201 return resolveWindowsBrowserLaunch(target, resolvePowerShellPath(env));
202 }
203
204 if (shouldUseWindowsBrowserFromWsl(platform, env)) {
205 return resolveWindowsBrowserLaunch(target, resolveWslPowerShellPath());
206 }
207
208 return {
209 command: "xdg-open",
210 args: [target],
211 options: DETACHED_IGNORE_STDIO_OPTIONS,
212 };
213}
214
215export function resolveAvailableEditors(
216 platform: NodeJS.Platform = process.platform,
217 env: NodeJS.ProcessEnv = process.env,
218): ReadonlyArray<EditorId> {
219 const available: EditorId[] = [];
220
221 for (const editor of EDITORS) {
222 if (editor.commands === null) {
223 const command = fileManagerCommandForPlatform(platform);
224 if (isCommandAvailable(command, { platform, env })) {
225 available.push(editor.id);
226 }
227 continue;
228 }
229
230 const command = resolveAvailableCommand(editor.commands, { platform, env });
231 if (Option.isSome(command)) {
232 available.push(editor.id);
233 }
234 }
235
236 return available;
237}
238
239/**
240 * ExternalLauncherShape - Service API for browser and editor launch actions.
241 */
242export interface ExternalLauncherShape {
243 /**
244 * Launch a URL target in the default browser.
245 */
246 readonly launchBrowser: (target: string) => Effect.Effect<void, ExternalLauncherError>;
247
248 /**
249 * Launch a workspace path in a selected editor integration.
250 *
251 * Launches the editor as a detached process so server startup is not blocked.
252 */
253 readonly launchEditor: (input: LaunchEditorInput) => Effect.Effect<void, ExternalLauncherError>;
254}
255
256/**
257 * ExternalLauncher - Service tag for browser/editor launch operations.
258 */
259export class ExternalLauncher extends Context.Service<ExternalLauncher, ExternalLauncherShape>()(
260 "t3/process/ExternalLauncher",
261) {}
262
263// ==============================
264// Implementations
265// ==============================
266
267export const resolveEditorLaunch = Effect.fn("resolveEditorLaunch")(function* (
268 input: LaunchEditorInput,
269 platform: NodeJS.Platform = process.platform,
270 env: NodeJS.ProcessEnv = process.env,
271): Effect.fn.Return<EditorLaunch, ExternalLauncherError> {
272 yield* Effect.annotateCurrentSpan({
273 "externalLauncher.editor": input.editor,
274 "externalLauncher.cwd": input.cwd,
275 "externalLauncher.platform": platform,
276 });
277 const editorDef = EDITORS.find((editor) => editor.id === input.editor);
278 if (!editorDef) {
279 return yield* new ExternalLauncherError({ message: `Unknown editor: ${input.editor}` });
280 }
281
282 if (editorDef.commands) {
283 const command = Option.getOrElse(
284 resolveAvailableCommand(editorDef.commands, { platform, env }),
285 () => editorDef.commands[0],
286 );
287 return {
288 command,
289 args: resolveEditorArgs(editorDef, input.cwd),
290 };
291 }
292
293 if (editorDef.id !== "file-manager") {
294 return yield* new ExternalLauncherError({ message: `Unsupported editor: ${input.editor}` });
295 }
296
297 return { command: fileManagerCommandForPlatform(platform), args: [input.cwd] };
298});
299
300const launchAndUnref = Effect.fn("externalLauncher.launchAndUnref")(function* (
301 launch: ProcessLaunch,
302 errorMessage: string,
303): Effect.fn.Return<void, ExternalLauncherError, ChildProcessSpawner.ChildProcessSpawner> {
304 const spawner = yield* ChildProcessSpawner.ChildProcessSpawner;
305 const command = ChildProcess.make(launch.command, launch.args, launch.options);
306
307 yield* spawner.spawn(command).pipe(
308 Effect.flatMap((handle) => handle.unref),
309 Effect.asVoid,
310 Effect.scoped,
311 Effect.mapError((cause) => new ExternalLauncherError({ message: errorMessage, cause })),
312 );
313});
314
315export const launchBrowser = Effect.fn("externalLauncher.launchBrowser")(function* (
316 target: string,
317): Effect.fn.Return<void, ExternalLauncherError, ChildProcessSpawner.ChildProcessSpawner> {
318 return yield* launchAndUnref(resolveBrowserLaunch(target), "Browser auto-open failed");
319});
320
321export const launchEditorProcess = Effect.fn("externalLauncher.launchEditorProcess")(function* (
322 launch: EditorLaunch,
323): Effect.fn.Return<void, ExternalLauncherError, ChildProcessSpawner.ChildProcessSpawner> {
324 if (!isCommandAvailable(launch.command)) {
325 return yield* new ExternalLauncherError({
326 message: `Editor command not found: ${launch.command}`,
327 });
328 }
329
330 const isWin32 = process.platform === "win32";
331 yield* launchAndUnref(
332 {
333 command: launch.command,
334 args: isWin32 ? launch.args.map((arg) => `"${arg}"`) : [...launch.args],
335 options: {
336 detached: true,
337 shell: isWin32,
338 stdin: "ignore",
339 stdout: "ignore",
340 stderr: "ignore",
341 },
342 },
343 "failed to spawn detached process",
344 );
345});
346
347const make = Effect.gen(function* () {
348 const spawner = yield* ChildProcessSpawner.ChildProcessSpawner;
349
350 return {
351 launchBrowser: (target) =>
352 launchBrowser(target).pipe(
353 Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner),
354 ),
355 launchEditor: (input) =>
356 Effect.flatMap(resolveEditorLaunch(input), (launch) =>
357 launchEditorProcess(launch).pipe(
358 Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner),
359 ),
360 ),
361 } satisfies ExternalLauncherShape;
362});
363
364export const layer = Layer.effect(ExternalLauncher, make);
apps/server/src/server.test.ts+13-11
...@@ -10,7 +10,7 @@ import {...@@ -10,7 +10,7 @@ import {
10 GitCommandError,10 GitCommandError,
11 KeybindingRule,11 KeybindingRule,
12 MessageId,12 MessageId,
13 OpenError,13 ExternalLauncherError,
14 type OrchestrationThreadShell,14 type OrchestrationThreadShell,
15 TerminalNotRunningError,15 TerminalNotRunningError,
16 type OrchestrationCommand,16 type OrchestrationCommand,
...@@ -63,7 +63,7 @@ import {...@@ -63,7 +63,7 @@ import {
63} from "./checkpointing/Services/CheckpointDiffQuery.ts";63} from "./checkpointing/Services/CheckpointDiffQuery.ts";
64import { GitManager, type GitManagerShape } from "./git/GitManager.ts";64import { GitManager, type GitManagerShape } from "./git/GitManager.ts";
65import { Keybindings, type KeybindingsShape } from "./keybindings.ts";65import { Keybindings, type KeybindingsShape } from "./keybindings.ts";
66import { Open, type OpenShape } from "./open.ts";66import * as ExternalLauncher from "./process/externalLauncher.ts";
67import {67import {
68 OrchestrationEngineService,68 OrchestrationEngineService,
69 type OrchestrationEngineShape,69 type OrchestrationEngineShape,
...@@ -319,7 +319,7 @@ const buildAppUnderTest = (options?: {...@@ -319,7 +319,7 @@ const buildAppUnderTest = (options?: {
319 keybindings?: Partial<KeybindingsShape>;319 keybindings?: Partial<KeybindingsShape>;
320 providerRegistry?: Partial<ProviderRegistryShape>;320 providerRegistry?: Partial<ProviderRegistryShape>;
321 serverSettings?: Partial<ServerSettingsShape>;321 serverSettings?: Partial<ServerSettingsShape>;
322 open?: Partial<OpenShape>;322 externalLauncher?: Partial<ExternalLauncher.ExternalLauncherShape>;
323 vcsDriver?: Partial<VcsDriver.VcsDriverShape>;323 vcsDriver?: Partial<VcsDriver.VcsDriverShape>;
324 vcsDriverRegistry?: Partial<VcsDriverRegistry.VcsDriverRegistryShape>;324 vcsDriverRegistry?: Partial<VcsDriverRegistry.VcsDriverRegistryShape>;
325 gitVcsDriver?: Partial<GitVcsDriver.GitVcsDriverShape>;325 gitVcsDriver?: Partial<GitVcsDriver.GitVcsDriverShape>;
...@@ -540,8 +540,8 @@ const buildAppUnderTest = (options?: {...@@ -540,8 +540,8 @@ const buildAppUnderTest = (options?: {
540 }),540 }),
541 ),541 ),
542 Layer.provide(542 Layer.provide(
543 Layer.mock(Open)({543 Layer.mock(ExternalLauncher.ExternalLauncher)({
544 ...options?.layers?.open,544 ...options?.layers?.externalLauncher,
545 }),545 }),
546 ),546 ),
547 Layer.provide(547 Layer.provide(
...@@ -2517,8 +2517,8 @@ it.layer(NodeServices.layer)("server router seam", (it) => {...@@ -2517,8 +2517,8 @@ it.layer(NodeServices.layer)("server router seam", (it) => {
2517 let openedInput: { cwd: string; editor: EditorId } | null = null;2517 let openedInput: { cwd: string; editor: EditorId } | null = null;
2518 yield* buildAppUnderTest({2518 yield* buildAppUnderTest({
2519 layers: {2519 layers: {
2520 open: {2520 externalLauncher: {
2521 openInEditor: (input) =>2521 launchEditor: (input) =>
2522 Effect.sync(() => {2522 Effect.sync(() => {
2523 openedInput = input;2523 openedInput = input;
2524 }),2524 }),
...@@ -2542,11 +2542,13 @@ it.layer(NodeServices.layer)("server router seam", (it) => {...@@ -2542,11 +2542,13 @@ it.layer(NodeServices.layer)("server router seam", (it) => {
25422542
2543 it.effect("routes websocket rpc shell.openInEditor errors", () =>2543 it.effect("routes websocket rpc shell.openInEditor errors", () =>
2544 Effect.gen(function* () {2544 Effect.gen(function* () {
2545 const openError = new OpenError({ message: "Editor command not found: cursor" });2545 const externalLauncherError = new ExternalLauncherError({
2546 message: "Editor command not found: cursor",
2547 });
2546 yield* buildAppUnderTest({2548 yield* buildAppUnderTest({
2547 layers: {2549 layers: {
2548 open: {2550 externalLauncher: {
2549 openInEditor: () => Effect.fail(openError),2551 launchEditor: () => Effect.fail(externalLauncherError),
2550 },2552 },
2551 },2553 },
2552 });2554 });
...@@ -2561,7 +2563,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => {...@@ -2561,7 +2563,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => {
2561 ).pipe(Effect.result),2563 ).pipe(Effect.result),
2562 );2564 );
25632565
2564 assertFailure(result, openError);2566 assertFailure(result, externalLauncherError);
2565 }).pipe(Effect.provide(NodeHttpServer.layerTest)),2567 }).pipe(Effect.provide(NodeHttpServer.layerTest)),
2566 );2568 );
25672569
apps/server/src/server.ts+2-2
...@@ -13,7 +13,7 @@ import {...@@ -13,7 +13,7 @@ import {
13} from "./http.ts";13} from "./http.ts";
14import { fixPath } from "./os-jank.ts";14import { fixPath } from "./os-jank.ts";
15import { websocketRpcRouteLayer } from "./ws.ts";15import { websocketRpcRouteLayer } from "./ws.ts";
16import { OpenLive } from "./open.ts";16import * as ExternalLauncher from "./process/externalLauncher.ts";
17import { layerConfig as SqlitePersistenceLayerLive } from "./persistence/Layers/Sqlite.ts";17import { layerConfig as SqlitePersistenceLayerLive } from "./persistence/Layers/Sqlite.ts";
18import { ServerLifecycleEventsLive } from "./serverLifecycleEvents.ts";18import { ServerLifecycleEventsLive } from "./serverLifecycleEvents.ts";
19import { AnalyticsServiceLayerLive } from "./telemetry/Layers/AnalyticsService.ts";19import { AnalyticsServiceLayerLive } from "./telemetry/Layers/AnalyticsService.ts";
...@@ -282,7 +282,7 @@ const RuntimeDependenciesLive = RuntimeCoreDependenciesLive.pipe(...@@ -282,7 +282,7 @@ const RuntimeDependenciesLive = RuntimeCoreDependenciesLive.pipe(
282 Layer.provideMerge(ProcessDiagnostics.layer),282 Layer.provideMerge(ProcessDiagnostics.layer),
283 Layer.provideMerge(TraceDiagnostics.layer),283 Layer.provideMerge(TraceDiagnostics.layer),
284 Layer.provideMerge(AnalyticsServiceLayerLive),284 Layer.provideMerge(AnalyticsServiceLayerLive),
285 Layer.provideMerge(OpenLive),285 Layer.provideMerge(ExternalLauncher.layer),
286 Layer.provideMerge(ServerLifecycleEventsLive),286 Layer.provideMerge(ServerLifecycleEventsLive),
287 Layer.provide(NetService.layer),287 Layer.provide(NetService.layer),
288);288);
apps/server/src/serverRuntimeStartup.ts+3-3
...@@ -23,7 +23,7 @@ import * as DateTime from "effect/DateTime";...@@ -23,7 +23,7 @@ import * as DateTime from "effect/DateTime";
2323
24import { ServerConfig } from "./config.ts";24import { ServerConfig } from "./config.ts";
25import { Keybindings } from "./keybindings.ts";25import { Keybindings } from "./keybindings.ts";
26import { Open } from "./open.ts";26import * as ExternalLauncher from "./process/externalLauncher.ts";
27import { OrchestrationEngineService } from "./orchestration/Services/OrchestrationEngine.ts";27import { OrchestrationEngineService } from "./orchestration/Services/OrchestrationEngine.ts";
28import { ProjectionSnapshotQuery } from "./orchestration/Services/ProjectionSnapshotQuery.ts";28import { ProjectionSnapshotQuery } from "./orchestration/Services/ProjectionSnapshotQuery.ts";
29import { OrchestrationReactor } from "./orchestration/Services/OrchestrationReactor.ts";29import { OrchestrationReactor } from "./orchestration/Services/OrchestrationReactor.ts";
...@@ -261,9 +261,9 @@ const maybeOpenBrowser = (target: string) =>...@@ -261,9 +261,9 @@ const maybeOpenBrowser = (target: string) =>
261 if (serverConfig.noBrowser) {261 if (serverConfig.noBrowser) {
262 return;262 return;
263 }263 }
264 const { openBrowser } = yield* Open;264 const externalLauncher = yield* ExternalLauncher.ExternalLauncher;
265265
266 yield* openBrowser(target).pipe(266 yield* externalLauncher.launchBrowser(target).pipe(
267 Effect.catch(() =>267 Effect.catch(() =>
268 Effect.logInfo("browser auto-open unavailable", {268 Effect.logInfo("browser auto-open unavailable", {
269 hint: `Open ${target} in your browser.`,269 hint: `Open ${target} in your browser.`,
apps/server/src/ws.ts+4-4
...@@ -40,7 +40,7 @@ import { RpcSerialization, RpcServer } from "effect/unstable/rpc";...@@ -40,7 +40,7 @@ import { RpcSerialization, RpcServer } from "effect/unstable/rpc";
40import { CheckpointDiffQuery } from "./checkpointing/Services/CheckpointDiffQuery.ts";40import { CheckpointDiffQuery } from "./checkpointing/Services/CheckpointDiffQuery.ts";
41import { ServerConfig } from "./config.ts";41import { ServerConfig } from "./config.ts";
42import { Keybindings } from "./keybindings.ts";42import { Keybindings } from "./keybindings.ts";
43import { Open, resolveAvailableEditors } from "./open.ts";43import * as ExternalLauncher from "./process/externalLauncher.ts";
44import { normalizeDispatchCommand } from "./orchestration/Normalizer.ts";44import { normalizeDispatchCommand } from "./orchestration/Normalizer.ts";
45import { OrchestrationEngineService } from "./orchestration/Services/OrchestrationEngine.ts";45import { OrchestrationEngineService } from "./orchestration/Services/OrchestrationEngine.ts";
46import { ProjectionSnapshotQuery } from "./orchestration/Services/ProjectionSnapshotQuery.ts";46import { ProjectionSnapshotQuery } from "./orchestration/Services/ProjectionSnapshotQuery.ts";
...@@ -163,7 +163,7 @@ const makeWsRpcLayer = (currentSessionId: AuthSessionId) =>...@@ -163,7 +163,7 @@ const makeWsRpcLayer = (currentSessionId: AuthSessionId) =>
163 const orchestrationEngine = yield* OrchestrationEngineService;163 const orchestrationEngine = yield* OrchestrationEngineService;
164 const checkpointDiffQuery = yield* CheckpointDiffQuery;164 const checkpointDiffQuery = yield* CheckpointDiffQuery;
165 const keybindings = yield* Keybindings;165 const keybindings = yield* Keybindings;
166 const open = yield* Open;166 const externalLauncher = yield* ExternalLauncher.ExternalLauncher;
167 const gitWorkflow = yield* GitWorkflowService;167 const gitWorkflow = yield* GitWorkflowService;
168 const vcsProvisioning = yield* VcsProvisioningService;168 const vcsProvisioning = yield* VcsProvisioningService;
169 const vcsStatusBroadcaster = yield* VcsStatusBroadcaster;169 const vcsStatusBroadcaster = yield* VcsStatusBroadcaster;
...@@ -583,7 +583,7 @@ const makeWsRpcLayer = (currentSessionId: AuthSessionId) =>...@@ -583,7 +583,7 @@ const makeWsRpcLayer = (currentSessionId: AuthSessionId) =>
583 keybindings: keybindingsConfig.keybindings,583 keybindings: keybindingsConfig.keybindings,
584 issues: keybindingsConfig.issues,584 issues: keybindingsConfig.issues,
585 providers,585 providers,
586 availableEditors: resolveAvailableEditors(),586 availableEditors: ExternalLauncher.resolveAvailableEditors(),
587 observability: {587 observability: {
588 logsDirectoryPath: config.logsDir,588 logsDirectoryPath: config.logsDir,
589 localTracingEnabled: true,589 localTracingEnabled: true,
...@@ -970,7 +970,7 @@ const makeWsRpcLayer = (currentSessionId: AuthSessionId) =>...@@ -970,7 +970,7 @@ const makeWsRpcLayer = (currentSessionId: AuthSessionId) =>
970 { "rpc.aggregate": "workspace" },970 { "rpc.aggregate": "workspace" },
971 ),971 ),
972 [WS_METHODS.shellOpenInEditor]: (input) =>972 [WS_METHODS.shellOpenInEditor]: (input) =>
973 observeRpcEffect(WS_METHODS.shellOpenInEditor, open.openInEditor(input), {973 observeRpcEffect(WS_METHODS.shellOpenInEditor, externalLauncher.launchEditor(input), {
974 "rpc.aggregate": "workspace",974 "rpc.aggregate": "workspace",
975 }),975 }),
976 [WS_METHODS.filesystemBrowse]: (input) =>976 [WS_METHODS.filesystemBrowse]: (input) =>
bun.lock+4-19
...@@ -17,7 +17,7 @@...@@ -17,7 +17,7 @@
17 },17 },
18 "apps/desktop": {18 "apps/desktop": {
19 "name": "@t3tools/desktop",19 "name": "@t3tools/desktop",
20 "version": "0.0.22",20 "version": "0.0.23",
21 "dependencies": {21 "dependencies": {
22 "@effect/platform-node": "catalog:",22 "@effect/platform-node": "catalog:",
23 "effect": "catalog:",23 "effect": "catalog:",
...@@ -52,7 +52,7 @@...@@ -52,7 +52,7 @@
52 },52 },
53 "apps/server": {53 "apps/server": {
54 "name": "t3",54 "name": "t3",
55 "version": "0.0.22",55 "version": "0.0.23",
56 "bin": {56 "bin": {
57 "t3": "./dist/bin.mjs",57 "t3": "./dist/bin.mjs",
58 },58 },
...@@ -65,7 +65,6 @@...@@ -65,7 +65,6 @@
65 "@pierre/diffs": "catalog:",65 "@pierre/diffs": "catalog:",
66 "effect": "catalog:",66 "effect": "catalog:",
67 "node-pty": "^1.1.0",67 "node-pty": "^1.1.0",
68 "open": "^10.1.0",
69 },68 },
70 "devDependencies": {69 "devDependencies": {
71 "@effect/language-service": "catalog:",70 "@effect/language-service": "catalog:",
...@@ -85,7 +84,7 @@...@@ -85,7 +84,7 @@
85 },84 },
86 "apps/web": {85 "apps/web": {
87 "name": "@t3tools/web",86 "name": "@t3tools/web",
88 "version": "0.0.22",87 "version": "0.0.23",
89 "dependencies": {88 "dependencies": {
90 "@base-ui/react": "^1.4.1",89 "@base-ui/react": "^1.4.1",
91 "@dnd-kit/core": "^6.3.1",90 "@dnd-kit/core": "^6.3.1",
...@@ -167,7 +166,7 @@...@@ -167,7 +166,7 @@
167 },166 },
168 "packages/contracts": {167 "packages/contracts": {
169 "name": "@t3tools/contracts",168 "name": "@t3tools/contracts",
170 "version": "0.0.22",169 "version": "0.0.23",
171 "dependencies": {170 "dependencies": {
172 "effect": "catalog:",171 "effect": "catalog:",
173 },172 },
...@@ -1038,8 +1037,6 @@...@@ -1038,8 +1037,6 @@
10381037
1039 "bun-types": ["bun-types@1.3.11", "", { "dependencies": { "@types/node": "*" } }, "sha512-1KGPpoxQWl9f6wcZh57LvrPIInQMn2TQ7jsgxqpRzg+l0QPOFvJVH7HmvHo/AiPgwXy+/Thf6Ov3EdVn1vOabg=="],1038 "bun-types": ["bun-types@1.3.11", "", { "dependencies": { "@types/node": "*" } }, "sha512-1KGPpoxQWl9f6wcZh57LvrPIInQMn2TQ7jsgxqpRzg+l0QPOFvJVH7HmvHo/AiPgwXy+/Thf6Ov3EdVn1vOabg=="],
10401039
1041 "bundle-name": ["bundle-name@4.1.0", "", { "dependencies": { "run-applescript": "^7.0.0" } }, "sha512-tjwM5exMg6BGRI+kNmTntNsvdZS1X8BFYS6tnJ2hdH0kVxM6/eVZ2xy+FqStSWvYmtfFMDLIxurorHwDKfDz5Q=="],
1042
1043 "bytes": ["bytes@3.1.2", "", {}, "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg=="],1040 "bytes": ["bytes@3.1.2", "", {}, "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg=="],
10441041
1045 "cac": ["cac@6.7.14", "", {}, "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ=="],1042 "cac": ["cac@6.7.14", "", {}, "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ=="],
...@@ -1126,16 +1123,10 @@...@@ -1126,16 +1123,10 @@
11261123
1127 "decompress-response": ["decompress-response@6.0.0", "", { "dependencies": { "mimic-response": "^3.1.0" } }, "sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ=="],1124 "decompress-response": ["decompress-response@6.0.0", "", { "dependencies": { "mimic-response": "^3.1.0" } }, "sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ=="],
11281125
1129 "default-browser": ["default-browser@5.5.0", "", { "dependencies": { "bundle-name": "^4.1.0", "default-browser-id": "^5.0.0" } }, "sha512-H9LMLr5zwIbSxrmvikGuI/5KGhZ8E2zH3stkMgM5LpOWDutGM2JZaj460Udnf1a+946zc7YBgrqEWwbk7zHvGw=="],
1130
1131 "default-browser-id": ["default-browser-id@5.0.1", "", {}, "sha512-x1VCxdX4t+8wVfd1so/9w+vQ4vx7lKd2Qp5tDRutErwmR85OgmfX7RlLRMWafRMY7hbEiXIbudNrjOAPa/hL8Q=="],
1132
1133 "defer-to-connect": ["defer-to-connect@2.0.1", "", {}, "sha512-4tvttepXG1VaYGrRibk5EwJd1t4udunSOVMdLSAL6mId1ix438oPwPZMALY41FCijukO1L0twNcGsdzS7dHgDg=="],1126 "defer-to-connect": ["defer-to-connect@2.0.1", "", {}, "sha512-4tvttepXG1VaYGrRibk5EwJd1t4udunSOVMdLSAL6mId1ix438oPwPZMALY41FCijukO1L0twNcGsdzS7dHgDg=="],
11341127
1135 "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=="],1128 "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=="],
11361129
1137 "define-lazy-prop": ["define-lazy-prop@3.0.0", "", {}, "sha512-N+MeXYoqr3pOgn8xfyRPREN7gHakLYjhsHhWGT3fWAiL4IkAt0iDw14QiiEm2bE30c5XX5q0FtAA3CK5f9/BUg=="],
1138
1139 "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=="],1130 "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=="],
11401131
1141 "defu": ["defu@6.1.4", "", {}, "sha512-mEQCMmwJu317oSz8CwdIOdwf3xMif1ttiM8LTufzc3g6kR+9Pe236twL8j3IYT1F7GfRgGcW6MWxzZjLIkuHIg=="],1132 "defu": ["defu@6.1.4", "", {}, "sha512-mEQCMmwJu317oSz8CwdIOdwf3xMif1ttiM8LTufzc3g6kR+9Pe236twL8j3IYT1F7GfRgGcW6MWxzZjLIkuHIg=="],
...@@ -1664,8 +1655,6 @@...@@ -1664,8 +1655,6 @@
16641655
1665 "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=="],1656 "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=="],
16661657
1667 "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=="],
1668
1669 "outvariant": ["outvariant@1.4.3", "", {}, "sha512-+Sl2UErvtsoajRDKCE5/dBz4DIvHXQQnAxtQTF04OJxY0+DyZXSo5P5Bb7XYWOh81syohlYL24hbDwxedPUJCA=="],1658 "outvariant": ["outvariant@1.4.3", "", {}, "sha512-+Sl2UErvtsoajRDKCE5/dBz4DIvHXQQnAxtQTF04OJxY0+DyZXSo5P5Bb7XYWOh81syohlYL24hbDwxedPUJCA=="],
16701659
1671 "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=="],1660 "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=="],
...@@ -1820,8 +1809,6 @@...@@ -1820,8 +1809,6 @@
18201809
1821 "router": ["router@2.2.0", "", { "dependencies": { "debug": "^4.4.0", "depd": "^2.0.0", "is-promise": "^4.0.0", "parseurl": "^1.3.3", "path-to-regexp": "^8.0.0" } }, "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ=="],1810 "router": ["router@2.2.0", "", { "dependencies": { "debug": "^4.4.0", "depd": "^2.0.0", "is-promise": "^4.0.0", "parseurl": "^1.3.3", "path-to-regexp": "^8.0.0" } }, "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ=="],
18221811
1823 "run-applescript": ["run-applescript@7.1.0", "", {}, "sha512-DPe5pVFaAsinSaV6QjQ6gdiedWDcRCbUuiQfQa2wmWV7+xC9bGulGI8+TdRmoFkAPaBXk8CrAbnlY2ISniJ47Q=="],
1824
1825 "safer-buffer": ["safer-buffer@2.1.2", "", {}, "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg=="],1812 "safer-buffer": ["safer-buffer@2.1.2", "", {}, "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg=="],
18261813
1827 "sax": ["sax@1.5.0", "", {}, "sha512-21IYA3Q5cQf089Z6tgaUTr7lDAyzoTPx5HRtbhsME8Udispad8dC/+sziTNugOEx54ilvatQ9YCzl4KQLPcRHA=="],1814 "sax": ["sax@1.5.0", "", {}, "sha512-21IYA3Q5cQf089Z6tgaUTr7lDAyzoTPx5HRtbhsME8Udispad8dC/+sziTNugOEx54ilvatQ9YCzl4KQLPcRHA=="],
...@@ -2110,8 +2097,6 @@...@@ -2110,8 +2097,6 @@
21102097
2111 "ws": ["ws@8.20.0", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-sAt8BhgNbzCtgGbt2OxmpuryO63ZoDk/sqaB/znQm94T4fCEsy/yV+7CdC1kJhOU9lboAEU7R3kquuycDoibVA=="],2098 "ws": ["ws@8.20.0", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-sAt8BhgNbzCtgGbt2OxmpuryO63ZoDk/sqaB/znQm94T4fCEsy/yV+7CdC1kJhOU9lboAEU7R3kquuycDoibVA=="],
21122099
2113 "wsl-utils": ["wsl-utils@0.1.0", "", { "dependencies": { "is-wsl": "^3.1.0" } }, "sha512-h3Fbisa2nKGPxCpm89Hk33lBLsnaGBvctQopaBSOW/uIs6FTe1ATyAnKFJrzVs9vpGdsTe73WF3V4lIsk4Gacw=="],
2114
2115 "xxhash-wasm": ["xxhash-wasm@1.1.0", "", {}, "sha512-147y/6YNh+tlp6nd/2pWq38i9h6mz/EuQ6njIrmW8D1BS5nCqs0P6DG+m6zTGnNz5I+uhZ0SHxBs9BsPrwcKDA=="],2100 "xxhash-wasm": ["xxhash-wasm@1.1.0", "", {}, "sha512-147y/6YNh+tlp6nd/2pWq38i9h6mz/EuQ6njIrmW8D1BS5nCqs0P6DG+m6zTGnNz5I+uhZ0SHxBs9BsPrwcKDA=="],
21162101
2117 "y18n": ["y18n@5.0.8", "", {}, "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA=="],2102 "y18n": ["y18n@5.0.8", "", {}, "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA=="],
packages/contracts/src/editor.ts+9-6
...@@ -44,13 +44,16 @@ export const EDITORS = [...@@ -44,13 +44,16 @@ export const EDITORS = [
44export const EditorId = Schema.Literals(EDITORS.map((e) => e.id));44export const EditorId = Schema.Literals(EDITORS.map((e) => e.id));
45export type EditorId = typeof EditorId.Type;45export type EditorId = typeof EditorId.Type;
4646
47export const OpenInEditorInput = Schema.Struct({47export const LaunchEditorInput = Schema.Struct({
48 cwd: TrimmedNonEmptyString,48 cwd: TrimmedNonEmptyString,
49 editor: EditorId,49 editor: EditorId,
50});50});
51export type OpenInEditorInput = typeof OpenInEditorInput.Type;51export type LaunchEditorInput = typeof LaunchEditorInput.Type;
5252
53export class OpenError extends Schema.TaggedErrorClass<OpenError>()("OpenError", {53export class ExternalLauncherError extends Schema.TaggedErrorClass<ExternalLauncherError>()(
54 message: Schema.String,54 "ExternalLauncherError",
55 cause: Schema.optional(Schema.Defect),55 {
56}) {}56 message: Schema.String,
57 cause: Schema.optional(Schema.Defect),
58 },
59) {}
packages/contracts/src/rpc.ts+3-3
...@@ -2,7 +2,7 @@ import * as Schema from "effect/Schema";...@@ -2,7 +2,7 @@ import * as Schema from "effect/Schema";
2import * as Rpc from "effect/unstable/rpc/Rpc";2import * as Rpc from "effect/unstable/rpc/Rpc";
3import * as RpcGroup from "effect/unstable/rpc/RpcGroup";3import * as RpcGroup from "effect/unstable/rpc/RpcGroup";
44
5import { OpenError, OpenInEditorInput } from "./editor.ts";5import { ExternalLauncherError, LaunchEditorInput } from "./editor.ts";
6import { AuthAccessStreamEvent } from "./auth.ts";6import { AuthAccessStreamEvent } from "./auth.ts";
7import {7import {
8 FilesystemBrowseInput,8 FilesystemBrowseInput,
...@@ -266,8 +266,8 @@ export const WsProjectsWriteFileRpc = Rpc.make(WS_METHODS.projectsWriteFile, {...@@ -266,8 +266,8 @@ export const WsProjectsWriteFileRpc = Rpc.make(WS_METHODS.projectsWriteFile, {
266});266});
267267
268export const WsShellOpenInEditorRpc = Rpc.make(WS_METHODS.shellOpenInEditor, {268export const WsShellOpenInEditorRpc = Rpc.make(WS_METHODS.shellOpenInEditor, {
269 payload: OpenInEditorInput,269 payload: LaunchEditorInput,
270 error: OpenError,270 error: ExternalLauncherError,
271});271});
272272
273export const WsFilesystemBrowseRpc = Rpc.make(WS_METHODS.filesystemBrowse, {273export const WsFilesystemBrowseRpc = Rpc.make(WS_METHODS.filesystemBrowse, {