- Add external opener support alongside browser/editor launches - Remove the `open` dependency and cover platform behavior in tests
Unified · Split
| @@ -29,7 +29,6 @@ · 28 unmodified lines | ||
| 29 | 29 | "@pierre/diffs": "^1.1.0-beta.16", |
| 30 | 30 | "effect": "catalog:", |
| 31 | 31 | "node-pty": "^1.1.0", |
| 32 | − "open": "^10.1.0", | |
| 33 | 32 | "ws": "^8.18.0" |
| 34 | 33 | }, |
| 35 | 34 | "devDependencies": { |
| @@ -51,6 +51,8 @@ const testLayer = Layer.mergeAll( · 50 unmodified lines | ||
| 51 | 51 | stopSignal: Effect.void, |
| 52 | 52 | } satisfies ServerShape), |
| 53 | 53 | Layer.succeed(Open, { |
| 54 | + getAvailableEditors: Effect.succeed([]), | |
| 55 | + openExternal: () => Effect.void, | |
| 54 | 56 | openBrowser: (_target: string) => Effect.void, |
| 55 | 57 | openInEditor: () => Effect.void, |
| 56 | 58 | } satisfies OpenShape), |
| @@ -1,269 +1,558 @@ | ||
| 1 | −import * as NodeServices from "@effect/platform-node/NodeServices"; | |
| 1 | +import * as NodeFileSystem from "@effect/platform-node/NodeFileSystem"; | |
| 2 | +import * as NodePath from "@effect/platform-node/NodePath"; | |
| 2 | 3 | import { assert, it } from "@effect/vitest"; |
| 3 | −import { assertSuccess } from "@effect/vitest/utils"; | |
| 4 | −import { FileSystem, Path, Effect } from "effect"; | |
| 5 | − | |
| 6 | 4 | import { |
| 7 | − isCommandAvailable, | |
| 8 | − launchDetached, | |
| 9 | − resolveAvailableEditors, | |
| 10 | − resolveEditorLaunch, | |
| 11 | −} from "./open"; | |
| 12 | − | |
| 13 | −it.layer(NodeServices.layer)("resolveEditorLaunch", (it) => { | |
| 14 | − it.effect("returns commands for command-based editors", () => | |
| 15 | − Effect.gen(function* () { | |
| 16 | − const antigravityLaunch = yield* resolveEditorLaunch( | |
| 17 | − { cwd: "/tmp/workspace", editor: "antigravity" }, | |
| 18 | − "darwin", | |
| 19 | − ); | |
| 20 | − assert.deepEqual(antigravityLaunch, { | |
| 21 | − command: "agy", | |
| 22 | − args: ["/tmp/workspace"], | |
| 23 | − }); | |
| 5 | + Deferred, | |
| 6 | + Effect, | |
| 7 | + Exit, | |
| 8 | + FileSystem, | |
| 9 | + Layer, | |
| 10 | + PlatformError, | |
| 11 | + Scope, | |
| 12 | + Sink, | |
| 13 | + Stream, | |
| 14 | +} from "effect"; | |
| 15 | +import { ChildProcessSpawner } from "effect/unstable/process"; | |
| 24 | 16 | |
| 25 | − const cursorLaunch = yield* resolveEditorLaunch( | |
| 26 | − { cwd: "/tmp/workspace", editor: "cursor" }, | |
| 27 | − "darwin", | |
| 28 | − ); | |
| 29 | − assert.deepEqual(cursorLaunch, { | |
| 30 | − command: "cursor", | |
| 31 | − args: ["/tmp/workspace"], | |
| 32 | − }); | |
| 17 | +import { makeOpenLayer, Open } from "./open"; | |
| 33 | 18 | |
| 34 | − const vscodeLaunch = yield* resolveEditorLaunch( | |
| 35 | − { cwd: "/tmp/workspace", editor: "vscode" }, | |
| 36 | − "darwin", | |
| 37 | − ); | |
| 38 | − assert.deepEqual(vscodeLaunch, { | |
| 39 | − command: "code", | |
| 40 | − args: ["/tmp/workspace"], | |
| 41 | − }); | |
| 19 | +const encoder = new TextEncoder(); | |
| 42 | 20 | |
| 43 | − const vscodeInsidersLaunch = yield* resolveEditorLaunch( | |
| 44 | − { cwd: "/tmp/workspace", editor: "vscode-insiders" }, | |
| 45 | − "darwin", | |
| 46 | − ); | |
| 47 | − assert.deepEqual(vscodeInsidersLaunch, { | |
| 48 | − command: "code-insiders", | |
| 49 | − args: ["/tmp/workspace"], | |
| 50 | − }); | |
| 21 | +interface SpawnCall { | |
| 22 | + readonly command: string; | |
| 23 | + readonly args: ReadonlyArray<string>; | |
| 24 | + readonly detached?: boolean | undefined; | |
| 25 | + readonly shell?: boolean | string | undefined; | |
| 26 | + readonly stdin?: unknown; | |
| 27 | + readonly stdout?: unknown; | |
| 28 | + readonly stderr?: unknown; | |
| 29 | +} | |
| 51 | 30 | |
| 52 | − const vscodiumLaunch = yield* resolveEditorLaunch( | |
| 53 | − { cwd: "/tmp/workspace", editor: "vscodium" }, | |
| 54 | − "darwin", | |
| 55 | − ); | |
| 56 | − assert.deepEqual(vscodiumLaunch, { | |
| 57 | − command: "codium", | |
| 58 | − args: ["/tmp/workspace"], | |
| 59 | − }); | |
| 31 | +function decodePowerShellCommand(encoded: string): string { | |
| 32 | + return Buffer.from(encoded, "base64").toString("utf16le"); | |
| 33 | +} | |
| 60 | 34 | |
| 61 | − const zedLaunch = yield* resolveEditorLaunch( | |
| 62 | − { cwd: "/tmp/workspace", editor: "zed" }, | |
| 63 | − "darwin", | |
| 35 | +function platformSpawnError(message: string) { | |
| 36 | + return PlatformError.systemError({ | |
| 37 | + _tag: "Unknown", | |
| 38 | + module: "ChildProcess", | |
| 39 | + method: "spawn", | |
| 40 | + description: message, | |
| 41 | + }); | |
| 42 | +} | |
| 43 | + | |
| 44 | +function spawnerLayer( | |
| 45 | + calls: Array<SpawnCall>, | |
| 46 | + handler: (call: SpawnCall) => { | |
| 47 | + readonly code?: number; | |
| 48 | + readonly fail?: string; | |
| 49 | + readonly awaitExit?: Deferred.Deferred<void>; | |
| 50 | + readonly onKill?: () => void; | |
| 51 | + } = () => ({ | |
| 52 | + code: 0, | |
| 53 | + }), | |
| 54 | +) { | |
| 55 | + return Layer.succeed( | |
| 56 | + ChildProcessSpawner.ChildProcessSpawner, | |
| 57 | + ChildProcessSpawner.make((command) => { | |
| 58 | + const standardCommand = command as unknown as { | |
| 59 | + readonly command: string; | |
| 60 | + readonly args: ReadonlyArray<string>; | |
| 61 | + readonly options: { | |
| 62 | + readonly detached?: boolean; | |
| 63 | + readonly shell?: boolean | string; | |
| 64 | + readonly stdin?: unknown; | |
| 65 | + readonly stdout?: unknown; | |
| 66 | + readonly stderr?: unknown; | |
| 67 | + }; | |
| 68 | + }; | |
| 69 | + | |
| 70 | + const call: SpawnCall = { | |
| 71 | + command: standardCommand.command, | |
| 72 | + args: [...standardCommand.args], | |
| 73 | + detached: standardCommand.options.detached, | |
| 74 | + shell: standardCommand.options.shell, | |
| 75 | + stdin: standardCommand.options.stdin, | |
| 76 | + stdout: standardCommand.options.stdout, | |
| 77 | + stderr: standardCommand.options.stderr, | |
| 78 | + }; | |
| 79 | + calls.push(call); | |
| 80 | + | |
| 81 | + const result = handler(call); | |
| 82 | + if (result.fail) { | |
| 83 | + return Effect.fail(platformSpawnError(result.fail)); | |
| 84 | + } | |
| 85 | + | |
| 86 | + let exited = false; | |
| 87 | + const exitCode = result.awaitExit | |
| 88 | + ? Deferred.await(result.awaitExit).pipe( | |
| 89 | + Effect.tap(() => | |
| 90 | + Effect.sync(() => { | |
| 91 | + exited = true; | |
| 92 | + }), | |
| 93 | + ), | |
| 94 | + Effect.andThen(Effect.succeed(ChildProcessSpawner.ExitCode(result.code ?? 0))), | |
| 95 | + ) | |
| 96 | + : Effect.sync(() => { | |
| 97 | + exited = true; | |
| 98 | + return ChildProcessSpawner.ExitCode(result.code ?? 0); | |
| 99 | + }); | |
| 100 | + | |
| 101 | + return Effect.succeed( | |
| 102 | + ChildProcessSpawner.makeHandle({ | |
| 103 | + pid: ChildProcessSpawner.ProcessId(calls.length), | |
| 104 | + exitCode, | |
| 105 | + isRunning: Effect.succeed(false), | |
| 106 | + kill: () => | |
| 107 | + Effect.sync(() => { | |
| 108 | + if (!exited) { | |
| 109 | + result.onKill?.(); | |
| 110 | + } | |
| 111 | + }), | |
| 112 | + stdin: Sink.drain, | |
| 113 | + stdout: Stream.make(encoder.encode("")), | |
| 114 | + stderr: Stream.make(encoder.encode("")), | |
| 115 | + all: Stream.empty, | |
| 116 | + getInputFd: () => Sink.drain, | |
| 117 | + getOutputFd: () => Stream.empty, | |
| 118 | + }), | |
| 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", () => | |
| 73 | − Effect.gen(function* () { | |
| 74 | − const lineOnly = yield* resolveEditorLaunch( | |
| 75 | − { cwd: "/tmp/workspace/AGENTS.md:48", editor: "cursor" }, | |
| 76 | − "darwin", | |
| 77 | − ); | |
| 78 | − assert.deepEqual(lineOnly, { | |
| 79 | − command: "cursor", | |
| 80 | − args: ["--goto", "/tmp/workspace/AGENTS.md:48"], | |
| 81 | − }); | |
| 82 | − | |
| 83 | − const lineAndColumn = yield* resolveEditorLaunch( | |
| 84 | − { cwd: "/tmp/workspace/src/open.ts:71:5", editor: "cursor" }, | |
| 85 | − "darwin", | |
| 86 | − ); | |
| 87 | − assert.deepEqual(lineAndColumn, { | |
| 88 | − command: "cursor", | |
| 89 | − args: ["--goto", "/tmp/workspace/src/open.ts:71:5"], | |
| 90 | − }); | |
| 124 | +const provideOpen = ( | |
| 125 | + options: Parameters<typeof makeOpenLayer>[0], | |
| 126 | + spawnLayer: Layer.Layer<ChildProcessSpawner.ChildProcessSpawner>, | |
| 127 | +) => | |
| 128 | + makeOpenLayer(options).pipe( | |
| 129 | + Layer.provide(Layer.mergeAll(NodeFileSystem.layer, NodePath.layer, spawnLayer)), | |
| 130 | + ); | |
| 91 | 131 | |
| 92 | − const vscodeLineAndColumn = yield* resolveEditorLaunch( | |
| 93 | − { cwd: "/tmp/workspace/src/open.ts:71:5", editor: "vscode" }, | |
| 94 | − "darwin", | |
| 95 | − ); | |
| 96 | − assert.deepEqual(vscodeLineAndColumn, { | |
| 97 | − command: "code", | |
| 98 | − args: ["--goto", "/tmp/workspace/src/open.ts:71:5"], | |
| 99 | − }); | |
| 132 | +const runOpen = <A>( | |
| 133 | + options: Parameters<typeof makeOpenLayer>[0], | |
| 134 | + spawnLayer: Layer.Layer<ChildProcessSpawner.ChildProcessSpawner>, | |
| 135 | + effect: Effect.Effect<A, Error, Open>, | |
| 136 | +) => effect.pipe(Effect.provide(provideOpen(options, spawnLayer))); | |
| 100 | 137 | |
| 101 | − const vscodeInsidersLineAndColumn = yield* resolveEditorLaunch( | |
| 102 | − { cwd: "/tmp/workspace/src/open.ts:71:5", editor: "vscode-insiders" }, | |
| 103 | − "darwin", | |
| 104 | − ); | |
| 105 | − assert.deepEqual(vscodeInsidersLineAndColumn, { | |
| 106 | − command: "code-insiders", | |
| 107 | − args: ["--goto", "/tmp/workspace/src/open.ts:71:5"], | |
| 108 | − }); | |
| 138 | +const readOpen = Effect.service(Open); | |
| 109 | 139 | |
| 110 | − const vscodiumLineAndColumn = yield* resolveEditorLaunch( | |
| 111 | − { cwd: "/tmp/workspace/src/open.ts:71:5", editor: "vscodium" }, | |
| 112 | − "darwin", | |
| 113 | − ); | |
| 114 | − assert.deepEqual(vscodiumLineAndColumn, { | |
| 115 | − command: "codium", | |
| 140 | +const writeExecutable = (filePath: string) => | |
| 141 | + Effect.gen(function* () { | |
| 142 | + const fs = yield* FileSystem.FileSystem; | |
| 143 | + yield* fs.writeFileString(filePath, "#!/bin/sh\nexit 0\n"); | |
| 144 | + yield* fs.chmod(filePath, 0o755); | |
| 145 | + }); | |
| 146 | + | |
| 147 | +it.effect("getAvailableEditors detects installed editors through the service", () => | |
| 148 | + Effect.gen(function* () { | |
| 149 | + const fs = yield* FileSystem.FileSystem; | |
| 150 | + const dir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-open-editors-" }); | |
| 151 | + yield* fs.writeFileString(`${dir}/code-insiders.CMD`, "@echo off\r\n"); | |
| 152 | + yield* fs.writeFileString(`${dir}/codium.CMD`, "@echo off\r\n"); | |
| 153 | + yield* fs.writeFileString(`${dir}/explorer.CMD`, "@echo off\r\n"); | |
| 154 | + | |
| 155 | + const calls: Array<SpawnCall> = []; | |
| 156 | + const open = yield* runOpen( | |
| 157 | + { | |
| 158 | + platform: "win32", | |
| 159 | + env: { | |
| 160 | + PATH: dir, | |
| 161 | + PATHEXT: ".COM;.EXE;.BAT;.CMD", | |
| 162 | + }, | |
| 163 | + }, | |
| 164 | + spawnerLayer(calls), | |
| 165 | + readOpen, | |
| 166 | + ); | |
| 167 | + | |
| 168 | + const editors = yield* open.getAvailableEditors; | |
| 169 | + assert.deepEqual(editors, ["vscode-insiders", "vscodium", "file-manager"]); | |
| 170 | + assert.deepEqual(calls, []); | |
| 171 | + }).pipe(Effect.provide(NodeFileSystem.layer)), | |
| 172 | +); | |
| 173 | + | |
| 174 | +it.effect("getAvailableEditors does not advertise WSL file-manager from PowerShell alone", () => | |
| 175 | + Effect.gen(function* () { | |
| 176 | + const fs = yield* FileSystem.FileSystem; | |
| 177 | + const dir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-open-wsl-editors-" }); | |
| 178 | + yield* fs.makeDirectory(`${dir}/mnt/c/Windows/System32/WindowsPowerShell/v1.0`, { | |
| 179 | + recursive: true, | |
| 180 | + }); | |
| 181 | + yield* writeExecutable(`${dir}/mnt/c/Windows/System32/WindowsPowerShell/v1.0/powershell.exe`); | |
| 182 | + | |
| 183 | + const calls: Array<SpawnCall> = []; | |
| 184 | + const open = yield* runOpen( | |
| 185 | + { | |
| 186 | + platform: "linux", | |
| 187 | + env: { | |
| 188 | + PATH: `${dir}:${dir}/mnt/c/Windows/System32/WindowsPowerShell/v1.0`, | |
| 189 | + WSL_DISTRO_NAME: "Ubuntu", | |
| 190 | + }, | |
| 191 | + isWsl: true, | |
| 192 | + isInsideContainer: false, | |
| 193 | + powerShellCommand: `${dir}/mnt/c/Windows/System32/WindowsPowerShell/v1.0/powershell.exe`, | |
| 194 | + }, | |
| 195 | + spawnerLayer(calls), | |
| 196 | + readOpen, | |
| 197 | + ); | |
| 198 | + | |
| 199 | + const editors = yield* open.getAvailableEditors; | |
| 200 | + assert.equal(editors.includes("file-manager"), false); | |
| 201 | + assert.deepEqual(calls, []); | |
| 202 | + }).pipe(Effect.provide(NodeFileSystem.layer)), | |
| 203 | +); | |
| 204 | + | |
| 205 | +it.effect("openInEditor uses --goto for editors that support it", () => | |
| 206 | + Effect.gen(function* () { | |
| 207 | + const fs = yield* FileSystem.FileSystem; | |
| 208 | + const dir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-open-cursor-" }); | |
| 209 | + yield* writeExecutable(`${dir}/cursor`); | |
| 210 | + | |
| 211 | + const calls: Array<SpawnCall> = []; | |
| 212 | + const open = yield* runOpen( | |
| 213 | + { | |
| 214 | + platform: "darwin", | |
| 215 | + env: { PATH: dir }, | |
| 216 | + }, | |
| 217 | + spawnerLayer(calls), | |
| 218 | + readOpen, | |
| 219 | + ); | |
| 220 | + | |
| 221 | + yield* open.openInEditor({ | |
| 222 | + cwd: "/tmp/workspace/src/open.ts:71:5", | |
| 223 | + editor: "cursor", | |
| 224 | + }); | |
| 225 | + | |
| 226 | + assert.deepEqual(calls, [ | |
| 227 | + { | |
| 228 | + command: `${dir}/cursor`, | |
| 116 | 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( | |
| 120 | − { cwd: "/tmp/workspace/src/open.ts:71:5", editor: "zed" }, | |
| 121 | − "darwin", | |
| 122 | − ); | |
| 123 | − assert.deepEqual(zedLineAndColumn, { | |
| 124 | − command: "zed", | |
| 125 | − args: ["/tmp/workspace/src/open.ts:71:5"], | |
| 126 | − }); | |
| 127 | − }), | |
| 128 | − ); | |
| 240 | +it.effect("openInEditor launches Windows batch shims through cmd.exe without shell mode", () => | |
| 241 | + Effect.gen(function* () { | |
| 242 | + const fs = yield* FileSystem.FileSystem; | |
| 243 | + const dir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-open-vscode-win32-" }); | |
| 244 | + yield* fs.writeFileString(`${dir}/code.cmd`, "@echo off\r\n"); | |
| 129 | 245 | |
| 130 | − it.effect("maps file-manager editor to OS open commands", () => | |
| 131 | − Effect.gen(function* () { | |
| 132 | − const launch1 = yield* resolveEditorLaunch( | |
| 133 | − { cwd: "/tmp/workspace", editor: "file-manager" }, | |
| 134 | − "darwin", | |
| 135 | − ); | |
| 136 | − assert.deepEqual(launch1, { | |
| 137 | − command: "open", | |
| 246 | + const calls: Array<SpawnCall> = []; | |
| 247 | + const open = yield* runOpen( | |
| 248 | + { | |
| 249 | + platform: "win32", | |
| 250 | + env: { | |
| 251 | + PATH: dir, | |
| 252 | + PATHEXT: ".COM;.EXE;.BAT;.CMD", | |
| 253 | + }, | |
| 254 | + }, | |
| 255 | + spawnerLayer(calls), | |
| 256 | + readOpen, | |
| 257 | + ); | |
| 258 | + | |
| 259 | + yield* open.openInEditor({ | |
| 260 | + cwd: "C:\\work\\100% real\\file.ts:12:4", | |
| 261 | + editor: "vscode", | |
| 262 | + }); | |
| 263 | + | |
| 264 | + assert.equal(calls.length, 1); | |
| 265 | + assert.equal(calls[0]?.command, "cmd.exe"); | |
| 266 | + assert.deepEqual(calls[0]?.args.slice(0, 4), ["/d", "/v:off", "/s", "/c"]); | |
| 267 | + assert.equal(calls[0]?.args[4]?.toLowerCase().includes(`${dir}/code.cmd`.toLowerCase()), true); | |
| 268 | + assert.equal(calls[0]?.args[4]?.includes('"--goto"'), true); | |
| 269 | + assert.equal(calls[0]?.args[4]?.includes("100%% real"), true); | |
| 270 | + assert.equal(calls[0]?.detached, true); | |
| 271 | + assert.equal(calls[0]?.shell, false); | |
| 272 | + assert.equal(calls[0]?.stdin, "ignore"); | |
| 273 | + assert.equal(calls[0]?.stdout, "ignore"); | |
| 274 | + assert.equal(calls[0]?.stderr, "ignore"); | |
| 275 | + }).pipe(Effect.provide(NodeFileSystem.layer)), | |
| 276 | +); | |
| 277 | + | |
| 278 | +it.effect("openInEditor detached launches survive service scope shutdown", () => | |
| 279 | + Effect.gen(function* () { | |
| 280 | + const fs = yield* FileSystem.FileSystem; | |
| 281 | + const dir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-open-zed-detached-" }); | |
| 282 | + yield* writeExecutable(`${dir}/zed`); | |
| 283 | + | |
| 284 | + const calls: Array<SpawnCall> = []; | |
| 285 | + const exitDeferred = yield* Deferred.make<void>(); | |
| 286 | + let killCount = 0; | |
| 287 | + | |
| 288 | + yield* Effect.acquireUseRelease( | |
| 289 | + Scope.make("sequential"), | |
| 290 | + (scope) => | |
| 291 | + Effect.gen(function* () { | |
| 292 | + const runtimeServices = yield* Layer.build( | |
| 293 | + provideOpen( | |
| 294 | + { | |
| 295 | + platform: "darwin", | |
| 296 | + env: { PATH: dir }, | |
| 297 | + }, | |
| 298 | + spawnerLayer(calls, () => ({ | |
| 299 | + awaitExit: exitDeferred, | |
| 300 | + onKill: () => { | |
| 301 | + killCount += 1; | |
| 302 | + }, | |
| 303 | + })), | |
| 304 | + ), | |
| 305 | + ).pipe(Scope.provide(scope)); | |
| 306 | + | |
| 307 | + const open = yield* readOpen.pipe(Effect.provide(runtimeServices), Scope.provide(scope)); | |
| 308 | + yield* open.openInEditor({ | |
| 309 | + cwd: "/tmp/workspace", | |
| 310 | + editor: "zed", | |
| 311 | + }); | |
| 312 | + }), | |
| 313 | + (scope) => Scope.close(scope, Exit.void), | |
| 314 | + ); | |
| 315 | + | |
| 316 | + assert.equal(killCount, 0); | |
| 317 | + assert.deepEqual(calls, [ | |
| 318 | + { | |
| 319 | + command: `${dir}/zed`, | |
| 138 | 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( | |
| 142 | − { cwd: "C:\\workspace", editor: "file-manager" }, | |
| 143 | − "win32", | |
| 144 | − ); | |
| 145 | − assert.deepEqual(launch2, { | |
| 146 | − command: "explorer", | |
| 147 | − args: ["C:\\workspace"], | |
| 148 | − }); | |
| 149 | − | |
| 150 | − const launch3 = yield* resolveEditorLaunch( | |
| 151 | − { cwd: "/tmp/workspace", editor: "file-manager" }, | |
| 152 | − "linux", | |
| 153 | − ); | |
| 154 | − assert.deepEqual(launch3, { | |
| 155 | − command: "xdg-open", | |
| 329 | + yield* Deferred.succeed(exitDeferred, void 0); | |
| 330 | + yield* Effect.yieldNow; | |
| 331 | + assert.equal(killCount, 0); | |
| 332 | + }).pipe(Effect.provide(NodeFileSystem.layer)), | |
| 333 | +); | |
| 334 | + | |
| 335 | +it.effect("openInEditor uses the default opener for file-manager on macOS", () => | |
| 336 | + Effect.gen(function* () { | |
| 337 | + const fs = yield* FileSystem.FileSystem; | |
| 338 | + const dir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-open-macos-" }); | |
| 339 | + yield* writeExecutable(`${dir}/open`); | |
| 340 | + | |
| 341 | + const calls: Array<SpawnCall> = []; | |
| 342 | + const open = yield* runOpen( | |
| 343 | + { | |
| 344 | + platform: "darwin", | |
| 345 | + env: { PATH: dir }, | |
| 346 | + }, | |
| 347 | + spawnerLayer(calls), | |
| 348 | + readOpen, | |
| 349 | + ); | |
| 350 | + | |
| 351 | + yield* open.openInEditor({ | |
| 352 | + cwd: "/tmp/workspace", | |
| 353 | + editor: "file-manager", | |
| 354 | + }); | |
| 355 | + | |
| 356 | + assert.deepEqual(calls, [ | |
| 357 | + { | |
| 358 | + command: `${dir}/open`, | |
| 156 | 359 | args: ["/tmp/workspace"], |
| 157 | − }); | |
| 158 | − }), | |
| 159 | − ); | |
| 160 | −}); | |
| 161 | − | |
| 162 | −it.layer(NodeServices.layer)("launchDetached", (it) => { | |
| 163 | − it.effect("resolves when command can be spawned", () => | |
| 164 | − Effect.gen(function* () { | |
| 165 | − const result = yield* launchDetached({ | |
| 166 | − command: process.execPath, | |
| 167 | − args: ["-e", "process.exit(0)"], | |
| 168 | − }).pipe(Effect.result); | |
| 169 | − assertSuccess(result, undefined); | |
| 170 | − }), | |
| 171 | − ); | |
| 360 | + detached: undefined, | |
| 361 | + shell: false, | |
| 362 | + stdin: undefined, | |
| 363 | + stdout: undefined, | |
| 364 | + stderr: undefined, | |
| 365 | + }, | |
| 366 | + ]); | |
| 367 | + }).pipe(Effect.provide(NodeFileSystem.layer)), | |
| 368 | +); | |
| 172 | 369 | |
| 173 | − it.effect("rejects when command does not exist", () => | |
| 174 | − Effect.gen(function* () { | |
| 175 | − const result = yield* launchDetached({ | |
| 176 | − command: `t3code-no-such-command-${Date.now()}`, | |
| 177 | − args: [], | |
| 178 | − }).pipe(Effect.result); | |
| 179 | − assert.equal(result._tag, "Failure"); | |
| 180 | − }), | |
| 181 | − ); | |
| 182 | −}); | |
| 183 | − | |
| 184 | −it.layer(NodeServices.layer)("isCommandAvailable", (it) => { | |
| 185 | − it.effect("resolves win32 commands with PATHEXT", () => | |
| 186 | − Effect.gen(function* () { | |
| 187 | − const fs = yield* FileSystem.FileSystem; | |
| 188 | − const path = yield* Path.Path; | |
| 189 | − const dir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-open-test-" }); | |
| 190 | − yield* fs.writeFileString(path.join(dir, "code.CMD"), "@echo off\r\n"); | |
| 191 | − const env = { | |
| 192 | − PATH: dir, | |
| 193 | − PATHEXT: ".COM;.EXE;.BAT;.CMD", | |
| 194 | − } satisfies NodeJS.ProcessEnv; | |
| 195 | − assert.equal(isCommandAvailable("code", { platform: "win32", env }), true); | |
| 196 | − }), | |
| 197 | − ); | |
| 370 | +it.effect("openBrowser uses macOS open flags and app arguments", () => | |
| 371 | + Effect.gen(function* () { | |
| 372 | + const fs = yield* FileSystem.FileSystem; | |
| 373 | + const dir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-open-browser-macos-" }); | |
| 374 | + yield* writeExecutable(`${dir}/open`); | |
| 198 | 375 | |
| 199 | − it("returns false when a command is not on PATH", () => { | |
| 200 | − const env = { | |
| 201 | − PATH: "", | |
| 202 | − PATHEXT: ".COM;.EXE;.BAT;.CMD", | |
| 203 | − } satisfies NodeJS.ProcessEnv; | |
| 204 | − assert.equal(isCommandAvailable("definitely-not-installed", { platform: "win32", env }), false); | |
| 205 | − }); | |
| 376 | + const calls: Array<SpawnCall> = []; | |
| 377 | + const open = yield* runOpen( | |
| 378 | + { | |
| 379 | + platform: "darwin", | |
| 380 | + env: { PATH: dir }, | |
| 381 | + }, | |
| 382 | + spawnerLayer(calls), | |
| 383 | + readOpen, | |
| 384 | + ); | |
| 206 | 385 | |
| 207 | − it.effect("does not treat bare files without executable extension as available on win32", () => | |
| 208 | − Effect.gen(function* () { | |
| 209 | − const fs = yield* FileSystem.FileSystem; | |
| 210 | − const path = yield* Path.Path; | |
| 211 | − const dir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-open-test-" }); | |
| 212 | − yield* fs.writeFileString(path.join(dir, "npm"), "echo nope\r\n"); | |
| 213 | − const env = { | |
| 214 | − PATH: dir, | |
| 215 | − PATHEXT: ".COM;.EXE;.BAT;.CMD", | |
| 216 | − } satisfies NodeJS.ProcessEnv; | |
| 217 | − assert.equal(isCommandAvailable("npm", { platform: "win32", env }), false); | |
| 218 | − }), | |
| 219 | − ); | |
| 386 | + yield* open.openBrowser("https://example.com", { | |
| 387 | + wait: true, | |
| 388 | + background: true, | |
| 389 | + newInstance: true, | |
| 390 | + app: { | |
| 391 | + name: "google chrome", | |
| 392 | + arguments: ["--profile-directory=Work"], | |
| 393 | + }, | |
| 394 | + }); | |
| 220 | 395 | |
| 221 | − it.effect("appends PATHEXT for commands with non-executable extensions on win32", () => | |
| 222 | − Effect.gen(function* () { | |
| 223 | − const fs = yield* FileSystem.FileSystem; | |
| 224 | − const path = yield* Path.Path; | |
| 225 | − const dir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-open-test-" }); | |
| 226 | − yield* fs.writeFileString(path.join(dir, "my.tool.CMD"), "@echo off\r\n"); | |
| 227 | − const env = { | |
| 228 | − PATH: dir, | |
| 229 | − PATHEXT: ".COM;.EXE;.BAT;.CMD", | |
| 230 | − } satisfies NodeJS.ProcessEnv; | |
| 231 | − assert.equal(isCommandAvailable("my.tool", { platform: "win32", env }), true); | |
| 232 | − }), | |
| 233 | − ); | |
| 396 | + assert.deepEqual(calls, [ | |
| 397 | + { | |
| 398 | + command: `${dir}/open`, | |
| 399 | + args: [ | |
| 400 | + "--wait-apps", | |
| 401 | + "--background", | |
| 402 | + "--new", | |
| 403 | + "-a", | |
| 404 | + "google chrome", | |
| 405 | + "https://example.com", | |
| 406 | + "--args", | |
| 407 | + "--profile-directory=Work", | |
| 408 | + ], | |
| 409 | + detached: undefined, | |
| 410 | + shell: false, | |
| 411 | + stdin: undefined, | |
| 412 | + stdout: undefined, | |
| 413 | + stderr: undefined, | |
| 414 | + }, | |
| 415 | + ]); | |
| 416 | + }).pipe(Effect.provide(NodeFileSystem.layer)), | |
| 417 | +); | |
| 234 | 418 | |
| 235 | − it.effect("uses platform-specific PATH delimiter for platform overrides", () => | |
| 236 | − Effect.gen(function* () { | |
| 237 | − const fs = yield* FileSystem.FileSystem; | |
| 238 | − const path = yield* Path.Path; | |
| 239 | − const firstDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-open-test-" }); | |
| 240 | − const secondDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-open-test-" }); | |
| 241 | − yield* fs.writeFileString(path.join(firstDir, "code.CMD"), "@echo off\r\n"); | |
| 242 | − yield* fs.writeFileString(path.join(secondDir, "code.CMD"), "MZ"); | |
| 243 | − const env = { | |
| 244 | − PATH: `${firstDir};${secondDir}`, | |
| 245 | − PATHEXT: ".COM;.EXE;.BAT;.CMD", | |
| 246 | − } satisfies NodeJS.ProcessEnv; | |
| 247 | − assert.equal(isCommandAvailable("code", { platform: "win32", env }), true); | |
| 248 | − }), | |
| 249 | − ); | |
| 250 | −}); | |
| 251 | − | |
| 252 | −it.layer(NodeServices.layer)("resolveAvailableEditors", (it) => { | |
| 253 | − it.effect("returns installed editors for command launches", () => | |
| 254 | − Effect.gen(function* () { | |
| 255 | − const fs = yield* FileSystem.FileSystem; | |
| 256 | − const path = yield* Path.Path; | |
| 257 | − const dir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-editors-" }); | |
| 258 | − | |
| 259 | − yield* fs.writeFileString(path.join(dir, "code-insiders.CMD"), "@echo off\r\n"); | |
| 260 | − yield* fs.writeFileString(path.join(dir, "codium.CMD"), "@echo off\r\n"); | |
| 261 | − yield* fs.writeFileString(path.join(dir, "explorer.CMD"), "MZ"); | |
| 262 | − const editors = resolveAvailableEditors("win32", { | |
| 263 | − PATH: dir, | |
| 264 | − PATHEXT: ".COM;.EXE;.BAT;.CMD", | |
| 265 | − }); | |
| 266 | − assert.deepEqual(editors, ["vscode-insiders", "vscodium", "file-manager"]); | |
| 267 | − }), | |
| 268 | − ); | |
| 269 | −}); | |
| 419 | +it.effect("openBrowser uses PowerShell on win32", () => | |
| 420 | + Effect.gen(function* () { | |
| 421 | + const fs = yield* FileSystem.FileSystem; | |
| 422 | + const dir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-open-browser-win32-" }); | |
| 423 | + yield* fs.writeFileString(`${dir}/powershell.exe`, "MZ"); | |
| 424 | + | |
| 425 | + const calls: Array<SpawnCall> = []; | |
| 426 | + const open = yield* runOpen( | |
| 427 | + { | |
| 428 | + platform: "win32", | |
| 429 | + env: { | |
| 430 | + PATH: dir, | |
| 431 | + PATHEXT: ".COM;.EXE;.BAT;.CMD", | |
| 432 | + }, | |
| 433 | + }, | |
| 434 | + spawnerLayer(calls), | |
| 435 | + readOpen, | |
| 436 | + ); | |
| 437 | + | |
| 438 | + yield* open.openBrowser("https://example.com"); | |
| 439 | + | |
| 440 | + assert.equal(calls.length, 1); | |
| 441 | + assert.equal(calls[0]?.command, `${dir}/powershell.exe`); | |
| 442 | + assert.equal(calls[0]?.args.at(-2), "-EncodedCommand"); | |
| 443 | + assert.equal( | |
| 444 | + decodePowerShellCommand(calls[0]?.args.at(-1) ?? ""), | |
| 445 | + "Start 'https://example.com'", | |
| 446 | + ); | |
| 447 | + }).pipe(Effect.provide(NodeFileSystem.layer)), | |
| 448 | +); | |
| 449 | + | |
| 450 | +it.effect("openBrowser falls back from WSL PowerShell to xdg-open", () => | |
| 451 | + Effect.gen(function* () { | |
| 452 | + const fs = yield* FileSystem.FileSystem; | |
| 453 | + const dir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-open-browser-wsl-" }); | |
| 454 | + yield* fs.makeDirectory(`${dir}/mnt/c/Windows/System32/WindowsPowerShell/v1.0`, { | |
| 455 | + recursive: true, | |
| 456 | + }); | |
| 457 | + yield* writeExecutable(`${dir}/mnt/c/Windows/System32/WindowsPowerShell/v1.0/powershell.exe`); | |
| 458 | + yield* writeExecutable(`${dir}/xdg-open`); | |
| 459 | + | |
| 460 | + const calls: Array<SpawnCall> = []; | |
| 461 | + const open = yield* runOpen( | |
| 462 | + { | |
| 463 | + platform: "linux", | |
| 464 | + env: { | |
| 465 | + PATH: `${dir}:${dir}/mnt/c/Windows/System32/WindowsPowerShell/v1.0`, | |
| 466 | + WSL_DISTRO_NAME: "Ubuntu", | |
| 467 | + }, | |
| 468 | + isWsl: true, | |
| 469 | + isInsideContainer: false, | |
| 470 | + powerShellCommand: `${dir}/mnt/c/Windows/System32/WindowsPowerShell/v1.0/powershell.exe`, | |
| 471 | + }, | |
| 472 | + spawnerLayer(calls, (call) => | |
| 473 | + call.command.includes("powershell") | |
| 474 | + ? { fail: "powershell unavailable" } | |
| 475 | + : { | |
| 476 | + code: 0, | |
| 477 | + }, | |
| 478 | + ), | |
| 479 | + readOpen, | |
| 480 | + ); | |
| 481 | + | |
| 482 | + yield* open.openBrowser("https://example.com"); | |
| 483 | + | |
| 484 | + assert.equal(calls[0]?.command.includes("powershell"), true); | |
| 485 | + assert.equal(calls[1]?.command, `${dir}/xdg-open`); | |
| 486 | + assert.deepEqual(calls[1]?.args, ["https://example.com"]); | |
| 487 | + }).pipe(Effect.provide(NodeFileSystem.layer)), | |
| 488 | +); | |
| 489 | + | |
| 490 | +it.effect("openInEditor uses xdg-open first for WSL file-manager paths", () => | |
| 491 | + Effect.gen(function* () { | |
| 492 | + const fs = yield* FileSystem.FileSystem; | |
| 493 | + const dir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-open-wsl-file-manager-" }); | |
| 494 | + yield* fs.makeDirectory(`${dir}/mnt/c/Windows/System32/WindowsPowerShell/v1.0`, { | |
| 495 | + recursive: true, | |
| 496 | + }); | |
| 497 | + yield* writeExecutable(`${dir}/mnt/c/Windows/System32/WindowsPowerShell/v1.0/powershell.exe`); | |
| 498 | + yield* writeExecutable(`${dir}/xdg-open`); | |
| 499 | + | |
| 500 | + const calls: Array<SpawnCall> = []; | |
| 501 | + const open = yield* runOpen( | |
| 502 | + { | |
| 503 | + platform: "linux", | |
| 504 | + env: { | |
| 505 | + PATH: `${dir}:${dir}/mnt/c/Windows/System32/WindowsPowerShell/v1.0`, | |
| 506 | + WSL_DISTRO_NAME: "Ubuntu", | |
| 507 | + }, | |
| 508 | + isWsl: true, | |
| 509 | + isInsideContainer: false, | |
| 510 | + powerShellCommand: `${dir}/mnt/c/Windows/System32/WindowsPowerShell/v1.0/powershell.exe`, | |
| 511 | + }, | |
| 512 | + spawnerLayer(calls), | |
| 513 | + readOpen, | |
| 514 | + ); | |
| 515 | + | |
| 516 | + yield* open.openInEditor({ | |
| 517 | + cwd: "/home/julius/workspace", | |
| 518 | + editor: "file-manager", | |
| 519 | + }); | |
| 520 | + | |
| 521 | + assert.deepEqual(calls, [ | |
| 522 | + { | |
| 523 | + command: `${dir}/xdg-open`, | |
| 524 | + args: ["/home/julius/workspace"], | |
| 525 | + detached: true, | |
| 526 | + shell: false, | |
| 527 | + stdin: "ignore", | |
| 528 | + stdout: "ignore", | |
| 529 | + stderr: "ignore", | |
| 530 | + }, | |
| 531 | + ]); | |
| 532 | + }).pipe(Effect.provide(NodeFileSystem.layer)), | |
| 533 | +); | |
| 534 | + | |
| 535 | +it.effect("openInEditor fails when the editor command is unavailable", () => | |
| 536 | + Effect.gen(function* () { | |
| 537 | + const calls: Array<SpawnCall> = []; | |
| 538 | + const open = yield* runOpen( | |
| 539 | + { | |
| 540 | + platform: "darwin", | |
| 541 | + env: { PATH: "" }, | |
| 542 | + }, | |
| 543 | + spawnerLayer(calls), | |
| 544 | + readOpen, | |
| 545 | + ); | |
| 546 | + | |
| 547 | + const error = yield* open | |
| 548 | + .openInEditor({ | |
| 549 | + cwd: "/tmp/workspace", | |
| 550 | + editor: "cursor", | |
| 551 | + }) | |
| 552 | + .pipe(Effect.flip); | |
| 553 | + | |
| 554 | + assert.equal(error._tag, "OpenError"); | |
| 555 | + assert.equal(error.message, "Command not found: cursor"); | |
| 556 | + assert.deepEqual(calls, []); | |
| 557 | + }), | |
| 558 | +); | |
| @@ -1,21 +1,17 @@ | ||
| 1 | 1 | /** |
| 2 | 2 | * Open - Browser/editor launch service interface. |
| 3 | 3 | * |
| 4 | − * Owns process launch helpers for opening URLs in a browser and workspace | |
| 5 | − * paths in a configured editor. | |
| 4 | + * Owns process launch helpers for opening URLs in a browser, workspace paths in | |
| 5 | + * a configured editor, and generic external targets through the platform's | |
| 6 | + * default opener. | |
| 6 | 7 | * |
| 7 | 8 | * @module Open |
| 8 | 9 | */ |
| 9 | −import { spawn } from "node:child_process"; | |
| 10 | −import { accessSync, constants, statSync } from "node:fs"; | |
| 11 | −import { extname, join } from "node:path"; | |
| 10 | +import os from "node:os"; | |
| 12 | 11 | |
| 13 | 12 | import { EDITORS, type EditorId } from "@t3tools/contracts"; |
| 14 | −import { ServiceMap, Schema, Effect, Layer } from "effect"; | |
| 15 | − | |
| 16 | −// ============================== | |
| 17 | −// Definitions | |
| 18 | −// ============================== | |
| 13 | +import { Effect, Exit, FileSystem, Layer, Option, Path, Schema, Scope, ServiceMap } from "effect"; | |
| 14 | +import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; | |
| 19 | 15 | |
| 20 | 16 | export class OpenError extends Schema.TaggedErrorClass<OpenError>()("OpenError", { |
| 21 | 17 | message: Schema.String, |
| @@ -27,33 +23,66 @@ export interface OpenInEditorInput { · 5 unmodified lines | ||
| 27 | 23 | readonly editor: EditorId; |
| 28 | 24 | } |
| 29 | 25 | |
| 30 | −interface EditorLaunch { | |
| 31 | − readonly command: string; | |
| 32 | − readonly args: ReadonlyArray<string>; | |
| 26 | +export interface OpenApplicationInput { | |
| 27 | + readonly name: string | ReadonlyArray<string>; | |
| 28 | + readonly arguments?: ReadonlyArray<string>; | |
| 29 | +} | |
| 30 | + | |
| 31 | +export interface OpenExternalInput { | |
| 32 | + readonly target: string; | |
| 33 | + readonly wait?: boolean; | |
| 34 | + readonly background?: boolean; | |
| 35 | + readonly newInstance?: boolean; | |
| 36 | + readonly allowNonzeroExitCode?: boolean; | |
| 37 | + readonly app?: OpenApplicationInput | ReadonlyArray<OpenApplicationInput>; | |
| 33 | 38 | } |
| 34 | 39 | |
| 35 | −interface CommandAvailabilityOptions { | |
| 40 | +export interface OpenRuntimeOptions { | |
| 36 | 41 | readonly platform?: NodeJS.Platform; |
| 37 | 42 | readonly env?: NodeJS.ProcessEnv; |
| 43 | + readonly isWsl?: boolean; | |
| 44 | + readonly isInsideContainer?: boolean; | |
| 45 | + readonly powerShellCommand?: string; | |
| 46 | +} | |
| 47 | + | |
| 48 | +interface OpenRuntime { | |
| 49 | + readonly platform: NodeJS.Platform; | |
| 50 | + readonly env: NodeJS.ProcessEnv; | |
| 51 | + readonly isWsl: boolean; | |
| 52 | + readonly isInsideContainer: boolean; | |
| 53 | + readonly powerShellCandidates: ReadonlyArray<string>; | |
| 54 | + readonly windowsPathExtensions: ReadonlyArray<string>; | |
| 55 | +} | |
| 56 | + | |
| 57 | +interface LaunchPlan { | |
| 58 | + readonly command: string; | |
| 59 | + readonly args: ReadonlyArray<string>; | |
| 60 | + readonly wait: boolean; | |
| 61 | + readonly allowNonzeroExitCode: boolean; | |
| 62 | + readonly detached?: boolean; | |
| 63 | + readonly shell?: boolean; | |
| 64 | + readonly stdio?: "ignore"; | |
| 65 | +} | |
| 66 | + | |
| 67 | +interface ResolvedCommand { | |
| 68 | + readonly path: string; | |
| 69 | + readonly usesCmdWrapper: boolean; | |
| 38 | 70 | } |
| 39 | 71 | |
| 40 | 72 | const LINE_COLUMN_SUFFIX_PATTERN = /:\d+(?::\d+)?$/; |
| 73 | +const WINDOWS_POWERSHELL_CANDIDATES = ["powershell.exe", "powershell", "pwsh.exe", "pwsh"] as const; | |
| 74 | +const WSL_POWERSHELL_CANDIDATES = [ | |
| 75 | + "/mnt/c/Windows/System32/WindowsPowerShell/v1.0/powershell.exe", | |
| 76 | + "/mnt/c/Program Files/PowerShell/7/pwsh.exe", | |
| 77 | + "powershell.exe", | |
| 78 | + "pwsh.exe", | |
| 79 | +] as const; | |
| 80 | +const WINDOWS_BATCH_EXTENSIONS = [".CMD", ".BAT"] as const; | |
| 41 | 81 | |
| 42 | 82 | function shouldUseGotoFlag(editor: (typeof EDITORS)[number], target: string): boolean { |
| 43 | 83 | return editor.supportsGoto && LINE_COLUMN_SUFFIX_PATTERN.test(target); |
| 44 | 84 | } |
| 45 | 85 | |
| 46 | −function fileManagerCommandForPlatform(platform: NodeJS.Platform): string { | |
| 47 | − switch (platform) { | |
| 48 | − case "darwin": | |
| 49 | − return "open"; | |
| 50 | − case "win32": | |
| 51 | − return "explorer"; | |
| 52 | − default: | |
| 53 | − return "xdg-open"; | |
| 54 | − } | |
| 55 | −} | |
| 56 | − | |
| 57 | 86 | function stripWrappingQuotes(value: string): string { |
| 58 | 87 | return value.replace(/^"+|"+$/g, ""); |
| 59 | 88 | } |
| @@ -72,19 +101,24 @@ function resolveWindowsPathExtensions(env: NodeJS.ProcessEnv): ReadonlyArray<str · 12 unmodified lines | ||
| 72 | 101 | .map((entry) => entry.trim()) |
| 73 | 102 | .filter((entry) => entry.length > 0) |
| 74 | 103 | .map((entry) => (entry.startsWith(".") ? entry.toUpperCase() : `.${entry.toUpperCase()}`)); |
| 104 | + | |
| 75 | 105 | return parsed.length > 0 ? Array.from(new Set(parsed)) : fallback; |
| 76 | 106 | } |
| 77 | 107 | |
| 108 | +function resolveWindowsCommandShell(env: NodeJS.ProcessEnv): string { | |
| 109 | + return env.ComSpec ?? env.COMSPEC ?? "cmd.exe"; | |
| 110 | +} | |
| 111 | + | |
| 78 | 112 | function resolveCommandCandidates( |
| 79 | 113 | command: string, |
| 80 | − platform: NodeJS.Platform, | |
| 81 | − windowsPathExtensions: ReadonlyArray<string>, | |
| 114 | + runtime: OpenRuntime, | |
| 115 | + pathService: Path.Path, | |
| 82 | 116 | ): ReadonlyArray<string> { |
| 83 | − if (platform !== "win32") return [command]; | |
| 84 | − const extension = extname(command); | |
| 117 | + if (runtime.platform !== "win32") return [command]; | |
| 118 | + const extension = pathService.extname(command); | |
| 85 | 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 | 122 | const commandWithoutExtension = command.slice(0, -extension.length); |
| 89 | 123 | return Array.from( |
| 90 | 124 | new Set([ |
| @@ -96,179 +130,558 @@ function resolveCommandCandidates( · 5 unmodified lines | ||
| 96 | 130 | } |
| 97 | 131 | |
| 98 | 132 | const candidates: string[] = []; |
| 99 | − for (const extension of windowsPathExtensions) { | |
| 100 | − candidates.push(`${command}${extension}`); | |
| 101 | − candidates.push(`${command}${extension.toLowerCase()}`); | |
| 133 | + for (const extensionName of runtime.windowsPathExtensions) { | |
| 134 | + candidates.push(`${command}${extensionName}`); | |
| 135 | + candidates.push(`${command}${extensionName.toLowerCase()}`); | |
| 102 | 136 | } |
| 103 | 137 | return Array.from(new Set(candidates)); |
| 104 | 138 | } |
| 105 | 139 | |
| 106 | −function isExecutableFile( | |
| 107 | − filePath: string, | |
| 108 | − platform: NodeJS.Platform, | |
| 109 | − windowsPathExtensions: ReadonlyArray<string>, | |
| 110 | −): boolean { | |
| 111 | − try { | |
| 112 | − const stat = statSync(filePath); | |
| 113 | − if (!stat.isFile()) return false; | |
| 114 | − if (platform === "win32") { | |
| 115 | − const extension = extname(filePath); | |
| 116 | − if (extension.length === 0) return false; | |
| 117 | − return windowsPathExtensions.includes(extension.toUpperCase()); | |
| 118 | − } | |
| 119 | − accessSync(filePath, constants.X_OK); | |
| 140 | +function resolvePathDelimiter(platform: NodeJS.Platform): string { | |
| 141 | + return platform === "win32" ? ";" : ":"; | |
| 142 | +} | |
| 143 | + | |
| 144 | +function detectWsl(platform: NodeJS.Platform, env: NodeJS.ProcessEnv): boolean { | |
| 145 | + if (platform !== "linux") return false; | |
| 146 | + if (typeof env.WSL_DISTRO_NAME === "string" || typeof env.WSL_INTEROP === "string") { | |
| 120 | 147 | return true; |
| 121 | − } catch { | |
| 122 | − return false; | |
| 123 | 148 | } |
| 149 | + return os.release().toLowerCase().includes("microsoft"); | |
| 124 | 150 | } |
| 125 | 151 | |
| 126 | −function resolvePathDelimiter(platform: NodeJS.Platform): string { | |
| 127 | − return platform === "win32" ? ";" : ":"; | |
| 152 | +function quotePowerShellValue(value: string): string { | |
| 153 | + return `'${value.replaceAll("'", "''")}'`; | |
| 128 | 154 | } |
| 129 | 155 | |
| 130 | −export function isCommandAvailable( | |
| 156 | +function encodePowerShellCommand(command: string): string { | |
| 157 | + return Buffer.from(command, "utf16le").toString("base64"); | |
| 158 | +} | |
| 159 | + | |
| 160 | +function normalizeAppCandidates( | |
| 161 | + app: OpenExternalInput["app"], | |
| 162 | +): ReadonlyArray<{ readonly name: string; readonly arguments: ReadonlyArray<string> } | undefined> { | |
| 163 | + if (!app) return [undefined]; | |
| 164 | + | |
| 165 | + const apps = Array.isArray(app) ? app : [app]; | |
| 166 | + const candidates: Array<{ readonly name: string; readonly arguments: ReadonlyArray<string> }> = | |
| 167 | + []; | |
| 168 | + | |
| 169 | + for (const appDef of apps) { | |
| 170 | + const names = Array.isArray(appDef.name) ? appDef.name : [appDef.name]; | |
| 171 | + for (const name of names) { | |
| 172 | + candidates.push({ name, arguments: appDef.arguments ?? [] }); | |
| 173 | + } | |
| 174 | + } | |
| 175 | + | |
| 176 | + return candidates; | |
| 177 | +} | |
| 178 | + | |
| 179 | +function isUriLikeTarget(target: string): boolean { | |
| 180 | + return /^[A-Za-z][A-Za-z\d+.-]*:/.test(target); | |
| 181 | +} | |
| 182 | + | |
| 183 | +function shouldPreferWindowsOpenerOnWsl(input: OpenExternalInput, runtime: OpenRuntime): boolean { | |
| 184 | + return runtime.isWsl && !runtime.isInsideContainer && isUriLikeTarget(input.target); | |
| 185 | +} | |
| 186 | + | |
| 187 | +function makeLaunchPlan( | |
| 188 | + runtime: OpenRuntime, | |
| 131 | 189 | command: string, |
| 132 | − options: CommandAvailabilityOptions = {}, | |
| 133 | −): boolean { | |
| 134 | − const platform = options.platform ?? process.platform; | |
| 135 | − const env = options.env ?? process.env; | |
| 136 | − const windowsPathExtensions = platform === "win32" ? resolveWindowsPathExtensions(env) : []; | |
| 137 | − const commandCandidates = resolveCommandCandidates(command, platform, windowsPathExtensions); | |
| 138 | − | |
| 139 | − if (command.includes("/") || command.includes("\\")) { | |
| 140 | − return commandCandidates.some((candidate) => | |
| 141 | − isExecutableFile(candidate, platform, windowsPathExtensions), | |
| 142 | − ); | |
| 190 | + args: ReadonlyArray<string>, | |
| 191 | + options: { | |
| 192 | + readonly wait: boolean; | |
| 193 | + readonly allowNonzeroExitCode: boolean; | |
| 194 | + readonly detached?: boolean; | |
| 195 | + readonly shell?: boolean; | |
| 196 | + readonly stdio?: "ignore"; | |
| 197 | + }, | |
| 198 | +): LaunchPlan { | |
| 199 | + return { | |
| 200 | + command, | |
| 201 | + args, | |
| 202 | + wait: options.wait, | |
| 203 | + allowNonzeroExitCode: options.allowNonzeroExitCode, | |
| 204 | + shell: options.shell ?? false, | |
| 205 | + ...(options.detached !== undefined ? { detached: options.detached } : {}), | |
| 206 | + ...(options.stdio !== undefined ? { stdio: options.stdio } : {}), | |
| 207 | + }; | |
| 208 | +} | |
| 209 | + | |
| 210 | +function makeDarwinDefaultPlan(input: OpenExternalInput, runtime: OpenRuntime): LaunchPlan { | |
| 211 | + const args: string[] = []; | |
| 212 | + const wait = input.wait ?? false; | |
| 213 | + | |
| 214 | + if (wait) args.push("--wait-apps"); | |
| 215 | + if (input.background) args.push("--background"); | |
| 216 | + if (input.newInstance) args.push("--new"); | |
| 217 | + args.push(input.target); | |
| 218 | + | |
| 219 | + return makeLaunchPlan(runtime, "open", args, { | |
| 220 | + wait, | |
| 221 | + allowNonzeroExitCode: input.allowNonzeroExitCode ?? false, | |
| 222 | + shell: false, | |
| 223 | + }); | |
| 224 | +} | |
| 225 | + | |
| 226 | +function makeDarwinApplicationPlan( | |
| 227 | + input: OpenExternalInput, | |
| 228 | + app: { readonly name: string; readonly arguments: ReadonlyArray<string> }, | |
| 229 | + runtime: OpenRuntime, | |
| 230 | +): LaunchPlan { | |
| 231 | + const args: string[] = []; | |
| 232 | + const wait = input.wait ?? false; | |
| 233 | + | |
| 234 | + if (wait) args.push("--wait-apps"); | |
| 235 | + if (input.background) args.push("--background"); | |
| 236 | + if (input.newInstance) args.push("--new"); | |
| 237 | + args.push("-a", app.name); | |
| 238 | + args.push(input.target); | |
| 239 | + if (app.arguments.length > 0) { | |
| 240 | + args.push("--args", ...app.arguments); | |
| 143 | 241 | } |
| 144 | 242 | |
| 145 | − const pathValue = resolvePathEnvironmentVariable(env); | |
| 146 | − if (pathValue.length === 0) return false; | |
| 147 | − const pathEntries = pathValue | |
| 148 | − .split(resolvePathDelimiter(platform)) | |
| 149 | − .map((entry) => stripWrappingQuotes(entry.trim())) | |
| 150 | − .filter((entry) => entry.length > 0); | |
| 151 | − | |
| 152 | − for (const pathEntry of pathEntries) { | |
| 153 | − for (const candidate of commandCandidates) { | |
| 154 | − if (isExecutableFile(join(pathEntry, candidate), platform, windowsPathExtensions)) { | |
| 155 | − return true; | |
| 243 | + return makeLaunchPlan(runtime, "open", args, { | |
| 244 | + wait, | |
| 245 | + allowNonzeroExitCode: input.allowNonzeroExitCode ?? false, | |
| 246 | + shell: false, | |
| 247 | + }); | |
| 248 | +} | |
| 249 | + | |
| 250 | +function makePowerShellPlan( | |
| 251 | + input: OpenExternalInput, | |
| 252 | + runtime: OpenRuntime, | |
| 253 | + powerShellCommand: string, | |
| 254 | +): LaunchPlan { | |
| 255 | + const encodedParts = ["Start"]; | |
| 256 | + const wait = input.wait ?? false; | |
| 257 | + | |
| 258 | + if (wait) encodedParts.push("-Wait"); | |
| 259 | + encodedParts.push(quotePowerShellValue(input.target)); | |
| 260 | + | |
| 261 | + return makeLaunchPlan( | |
| 262 | + runtime, | |
| 263 | + powerShellCommand, | |
| 264 | + [ | |
| 265 | + "-NoProfile", | |
| 266 | + "-NonInteractive", | |
| 267 | + "-ExecutionPolicy", | |
| 268 | + "Bypass", | |
| 269 | + "-EncodedCommand", | |
| 270 | + encodePowerShellCommand(encodedParts.join(" ")), | |
| 271 | + ], | |
| 272 | + { | |
| 273 | + wait, | |
| 274 | + allowNonzeroExitCode: input.allowNonzeroExitCode ?? false, | |
| 275 | + shell: false, | |
| 276 | + }, | |
| 277 | + ); | |
| 278 | +} | |
| 279 | + | |
| 280 | +function makeLinuxDefaultPlan(input: OpenExternalInput, runtime: OpenRuntime): LaunchPlan { | |
| 281 | + const wait = input.wait ?? false; | |
| 282 | + return makeLaunchPlan(runtime, "xdg-open", [input.target], { | |
| 283 | + wait, | |
| 284 | + allowNonzeroExitCode: input.allowNonzeroExitCode ?? false, | |
| 285 | + detached: !wait, | |
| 286 | + ...(wait ? {} : { stdio: "ignore" as const }), | |
| 287 | + shell: false, | |
| 288 | + }); | |
| 289 | +} | |
| 290 | + | |
| 291 | +function makeWindowsExplorerPlan(input: OpenExternalInput, runtime: OpenRuntime): LaunchPlan { | |
| 292 | + return makeLaunchPlan(runtime, "explorer", [input.target], { | |
| 293 | + wait: false, | |
| 294 | + allowNonzeroExitCode: false, | |
| 295 | + shell: false, | |
| 296 | + }); | |
| 297 | +} | |
| 298 | + | |
| 299 | +function makeDirectApplicationPlan( | |
| 300 | + input: OpenExternalInput, | |
| 301 | + app: { readonly name: string; readonly arguments: ReadonlyArray<string> }, | |
| 302 | + runtime: OpenRuntime, | |
| 303 | +): LaunchPlan { | |
| 304 | + return makeLaunchPlan(runtime, app.name, [...app.arguments, input.target], { | |
| 305 | + wait: input.wait ?? false, | |
| 306 | + allowNonzeroExitCode: input.allowNonzeroExitCode ?? false, | |
| 307 | + shell: false, | |
| 308 | + }); | |
| 309 | +} | |
| 310 | + | |
| 311 | +function resolveExternalPlans( | |
| 312 | + input: OpenExternalInput, | |
| 313 | + runtime: OpenRuntime, | |
| 314 | +): ReadonlyArray<LaunchPlan> { | |
| 315 | + const appCandidates = normalizeAppCandidates(input.app); | |
| 316 | + const plans: LaunchPlan[] = []; | |
| 317 | + const preferWindowsOpenerOnWsl = shouldPreferWindowsOpenerOnWsl(input, runtime); | |
| 318 | + | |
| 319 | + for (const app of appCandidates) { | |
| 320 | + if (app) { | |
| 321 | + if (runtime.platform === "darwin") { | |
| 322 | + plans.push(makeDarwinApplicationPlan(input, app, runtime)); | |
| 323 | + } else { | |
| 324 | + plans.push(makeDirectApplicationPlan(input, app, runtime)); | |
| 156 | 325 | } |
| 326 | + continue; | |
| 157 | 327 | } |
| 158 | − } | |
| 159 | − return false; | |
| 160 | −} | |
| 161 | 328 | |
| 162 | −export function resolveAvailableEditors( | |
| 163 | − platform: NodeJS.Platform = process.platform, | |
| 164 | − env: NodeJS.ProcessEnv = process.env, | |
| 165 | −): ReadonlyArray<EditorId> { | |
| 166 | − const available: EditorId[] = []; | |
| 329 | + if (runtime.platform === "darwin") { | |
| 330 | + plans.push(makeDarwinDefaultPlan(input, runtime)); | |
| 331 | + continue; | |
| 332 | + } | |
| 167 | 333 | |
| 168 | − for (const editor of EDITORS) { | |
| 169 | − const command = editor.command ?? fileManagerCommandForPlatform(platform); | |
| 170 | − if (isCommandAvailable(command, { platform, env })) { | |
| 171 | − available.push(editor.id); | |
| 334 | + if (runtime.platform === "win32" || preferWindowsOpenerOnWsl) { | |
| 335 | + for (const powerShellCommand of runtime.powerShellCandidates) { | |
| 336 | + plans.push(makePowerShellPlan(input, runtime, powerShellCommand)); | |
| 337 | + } | |
| 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 | + | |
| 353 | +function toOpenError(message: string, cause: unknown): OpenError { | |
| 354 | + return new OpenError({ message, cause }); | |
| 355 | +} | |
| 356 | + | |
| 357 | +function isWindowsBatchShim(pathService: Path.Path, filePath: string): boolean { | |
| 358 | + return WINDOWS_BATCH_EXTENSIONS.includes(pathService.extname(filePath).toUpperCase() as never); | |
| 359 | +} | |
| 360 | + | |
| 361 | +function quoteForWindowsCmd(value: string): string { | |
| 362 | + return `"${value.replaceAll("%", "%%").replaceAll('"', '""')}"`; | |
| 363 | +} | |
| 364 | + | |
| 365 | +function makeWindowsCmdCommandLine(commandPath: string, args: ReadonlyArray<string>): string { | |
| 366 | + return `"${[commandPath, ...args].map(quoteForWindowsCmd).join(" ")}"`; | |
| 176 | 367 | } |
| 177 | 368 | |
| 178 | −/** | |
| 179 | − * OpenShape - Service API for browser and editor launch actions. | |
| 180 | − */ | |
| 181 | 369 | export interface OpenShape { |
| 182 | − /** | |
| 183 | − * Open a URL target in the default browser. | |
| 184 | − */ | |
| 185 | − readonly openBrowser: (target: string) => Effect.Effect<void, OpenError>; | |
| 186 | − | |
| 187 | − /** | |
| 188 | − * Open a workspace path in a selected editor integration. | |
| 189 | − * | |
| 190 | − * Launches the editor as a detached process so server startup is not blocked. | |
| 191 | − */ | |
| 370 | + readonly getAvailableEditors: Effect.Effect<ReadonlyArray<EditorId>, OpenError>; | |
| 371 | + readonly openExternal: (input: OpenExternalInput) => Effect.Effect<void, OpenError>; | |
| 372 | + readonly openBrowser: ( | |
| 373 | + target: string, | |
| 374 | + options?: Omit<OpenExternalInput, "target">, | |
| 375 | + ) => Effect.Effect<void, OpenError>; | |
| 192 | 376 | readonly openInEditor: (input: OpenInEditorInput) => Effect.Effect<void, OpenError>; |
| 193 | 377 | } |
| 194 | 378 | |
| 195 | −/** | |
| 196 | − * Open - Service tag for browser/editor launch operations. | |
| 197 | − */ | |
| 198 | 379 | export class Open extends ServiceMap.Service<Open, OpenShape>()("t3/open") {} |
| 199 | 380 | |
| 200 | −// ============================== | |
| 201 | −// Implementations | |
| 202 | −// ============================== | |
| 203 | − | |
| 204 | −export const resolveEditorLaunch = Effect.fnUntraced(function* ( | |
| 205 | − input: OpenInEditorInput, | |
| 206 | − platform: NodeJS.Platform = process.platform, | |
| 207 | −): Effect.fn.Return<EditorLaunch, OpenError> { | |
| 208 | − const editorDef = EDITORS.find((editor) => editor.id === input.editor); | |
| 209 | − if (!editorDef) { | |
| 210 | − return yield* new OpenError({ message: `Unknown editor: ${input.editor}` }); | |
| 211 | − } | |
| 381 | +const makeOpen = (options: OpenRuntimeOptions = {}) => | |
| 382 | + Effect.gen(function* () { | |
| 383 | + const fileSystem = yield* FileSystem.FileSystem; | |
| 384 | + const pathService = yield* Path.Path; | |
| 385 | + const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; | |
| 386 | + | |
| 387 | + const isInsideContainer = | |
| 388 | + options.isInsideContainer ?? | |
| 389 | + (typeof options.env?.CONTAINER === "string" || | |
| 390 | + typeof options.env?.container === "string" || | |
| 391 | + typeof options.env?.KUBERNETES_SERVICE_HOST === "string" | |
| 392 | + ? true | |
| 393 | + : yield* fileSystem.exists("/.dockerenv").pipe(Effect.catch(() => Effect.succeed(false)))); | |
| 394 | + | |
| 395 | + const runtime: OpenRuntime = { | |
| 396 | + platform: options.platform ?? process.platform, | |
| 397 | + env: options.env ?? process.env, | |
| 398 | + isWsl: | |
| 399 | + options.isWsl ?? | |
| 400 | + detectWsl(options.platform ?? process.platform, options.env ?? process.env), | |
| 401 | + isInsideContainer, | |
| 402 | + powerShellCandidates: | |
| 403 | + options.powerShellCommand !== undefined | |
| 404 | + ? [options.powerShellCommand] | |
| 405 | + : (options.isWsl ?? | |
| 406 | + detectWsl(options.platform ?? process.platform, options.env ?? process.env)) | |
| 407 | + ? WSL_POWERSHELL_CANDIDATES | |
| 408 | + : WINDOWS_POWERSHELL_CANDIDATES, | |
| 409 | + windowsPathExtensions: resolveWindowsPathExtensions(options.env ?? process.env), | |
| 410 | + }; | |
| 411 | + | |
| 412 | + const resolveCommand = Effect.fn(function* ( | |
| 413 | + command: string, | |
| 414 | + ): Effect.fn.Return<Option.Option<ResolvedCommand>, never> { | |
| 415 | + const candidates = resolveCommandCandidates(command, runtime, pathService); | |
| 416 | + | |
| 417 | + const resolveExecutableFile = Effect.fn(function* ( | |
| 418 | + filePath: string, | |
| 419 | + ): Effect.fn.Return<Option.Option<ResolvedCommand>, never> { | |
| 420 | + const info = yield* fileSystem.stat(filePath).pipe(Effect.option); | |
| 421 | + if (Option.isNone(info) || info.value.type !== "File") return Option.none(); | |
| 422 | + | |
| 423 | + if (runtime.platform === "win32") { | |
| 424 | + const extension = pathService.extname(filePath); | |
| 425 | + if ( | |
| 426 | + extension.length === 0 || | |
| 427 | + !runtime.windowsPathExtensions.includes(extension.toUpperCase()) | |
| 428 | + ) { | |
| 429 | + return Option.none(); | |
| 430 | + } | |
| 431 | + | |
| 432 | + return Option.some({ | |
| 433 | + path: filePath, | |
| 434 | + usesCmdWrapper: isWindowsBatchShim(pathService, filePath), | |
| 435 | + } satisfies ResolvedCommand); | |
| 436 | + } | |
| 437 | + | |
| 438 | + return (info.value.mode & 0o111) !== 0 | |
| 439 | + ? Option.some({ | |
| 440 | + path: filePath, | |
| 441 | + usesCmdWrapper: false, | |
| 442 | + } satisfies ResolvedCommand) | |
| 443 | + : Option.none(); | |
| 444 | + }); | |
| 445 | + | |
| 446 | + if (command.includes("/") || command.includes("\\")) { | |
| 447 | + for (const candidate of candidates) { | |
| 448 | + const resolved = yield* resolveExecutableFile(candidate); | |
| 449 | + if (Option.isSome(resolved)) return resolved; | |
| 450 | + } | |
| 451 | + return Option.none(); | |
| 452 | + } | |
| 212 | 453 | |
| 213 | − if (editorDef.command) { | |
| 214 | − return shouldUseGotoFlag(editorDef, input.cwd) | |
| 215 | − ? { command: editorDef.command, args: ["--goto", input.cwd] } | |
| 216 | − : { command: editorDef.command, args: [input.cwd] }; | |
| 217 | − } | |
| 454 | + const pathValue = resolvePathEnvironmentVariable(runtime.env); | |
| 455 | + if (pathValue.length === 0) return Option.none(); | |
| 456 | + | |
| 457 | + const pathEntries = pathValue | |
| 458 | + .split(resolvePathDelimiter(runtime.platform)) | |
| 459 | + .map((entry) => stripWrappingQuotes(entry.trim())) | |
| 460 | + .filter((entry) => entry.length > 0); | |
| 461 | + | |
| 462 | + for (const pathEntry of pathEntries) { | |
| 463 | + for (const candidate of candidates) { | |
| 464 | + const resolved = yield* resolveExecutableFile(pathService.join(pathEntry, candidate)); | |
| 465 | + if (Option.isSome(resolved)) { | |
| 466 | + return resolved; | |
| 467 | + } | |
| 468 | + } | |
| 469 | + } | |
| 218 | 470 | |
| 219 | − if (editorDef.id !== "file-manager") { | |
| 220 | − return yield* new OpenError({ message: `Unsupported editor: ${input.editor}` }); | |
| 221 | − } | |
| 471 | + return Option.none(); | |
| 472 | + }); | |
| 222 | 473 | |
| 223 | − return { command: fileManagerCommandForPlatform(platform), args: [input.cwd] }; | |
| 224 | −}); | |
| 474 | + const commandAvailable = (command: string) => | |
| 475 | + resolveCommand(command).pipe(Effect.map(Option.isSome)); | |
| 476 | + | |
| 477 | + const fileManagerAvailable = Effect.gen(function* () { | |
| 478 | + const candidates = | |
| 479 | + runtime.platform === "darwin" | |
| 480 | + ? ["open"] | |
| 481 | + : runtime.platform === "win32" | |
| 482 | + ? [...runtime.powerShellCandidates, "explorer"] | |
| 483 | + : ["xdg-open"]; | |
| 484 | + | |
| 485 | + for (const candidate of candidates) { | |
| 486 | + if (yield* commandAvailable(candidate)) { | |
| 487 | + return true; | |
| 488 | + } | |
| 489 | + } | |
| 225 | 490 | |
| 226 | −export const launchDetached = (launch: EditorLaunch) => | |
| 227 | − Effect.gen(function* () { | |
| 228 | − if (!isCommandAvailable(launch.command)) { | |
| 229 | − return yield* new OpenError({ message: `Editor command not found: ${launch.command}` }); | |
| 230 | − } | |
| 491 | + return false; | |
| 492 | + }); | |
| 493 | + | |
| 494 | + const getAvailableEditors = Effect.gen(function* () { | |
| 495 | + const available: EditorId[] = []; | |
| 496 | + | |
| 497 | + for (const editor of EDITORS) { | |
| 498 | + if (editor.id === "file-manager") { | |
| 499 | + if (yield* fileManagerAvailable) { | |
| 500 | + available.push(editor.id); | |
| 501 | + } | |
| 502 | + continue; | |
| 503 | + } | |
| 504 | + | |
| 505 | + if (editor.command && (yield* commandAvailable(editor.command))) { | |
| 506 | + available.push(editor.id); | |
| 507 | + } | |
| 508 | + } | |
| 509 | + | |
| 510 | + return available as ReadonlyArray<EditorId>; | |
| 511 | + }).pipe(Effect.mapError((cause) => toOpenError("Failed to resolve available editors", cause))); | |
| 512 | + | |
| 513 | + const spawnPlan = ( | |
| 514 | + plan: LaunchPlan, | |
| 515 | + resolvedCommand: ResolvedCommand, | |
| 516 | + failureMessage: string, | |
| 517 | + ) => | |
| 518 | + spawner | |
| 519 | + .spawn( | |
| 520 | + ChildProcess.make( | |
| 521 | + resolvedCommand.usesCmdWrapper | |
| 522 | + ? resolveWindowsCommandShell(runtime.env) | |
| 523 | + : resolvedCommand.path, | |
| 524 | + resolvedCommand.usesCmdWrapper | |
| 525 | + ? [ | |
| 526 | + "/d", | |
| 527 | + "/v:off", | |
| 528 | + "/s", | |
| 529 | + "/c", | |
| 530 | + makeWindowsCmdCommandLine(resolvedCommand.path, plan.args), | |
| 531 | + ] | |
| 532 | + : [...plan.args], | |
| 533 | + { | |
| 534 | + detached: plan.detached, | |
| 535 | + shell: plan.shell, | |
| 536 | + ...(plan.stdio === "ignore" | |
| 537 | + ? { | |
| 538 | + stdin: "ignore", | |
| 539 | + stdout: "ignore", | |
| 540 | + stderr: "ignore", | |
| 541 | + } | |
| 542 | + : {}), | |
| 543 | + }, | |
| 544 | + ), | |
| 545 | + ) | |
| 546 | + .pipe(Effect.mapError((cause) => toOpenError(failureMessage, cause))); | |
| 547 | + | |
| 548 | + const waitForExit = ( | |
| 549 | + plan: LaunchPlan, | |
| 550 | + failureMessage: string, | |
| 551 | + handle: ChildProcessSpawner.ChildProcessHandle, | |
| 552 | + ) => | |
| 553 | + handle.exitCode.pipe( | |
| 554 | + Effect.flatMap((exitCode) => | |
| 555 | + !plan.allowNonzeroExitCode && (exitCode as number) !== 0 | |
| 556 | + ? Effect.fail( | |
| 557 | + new OpenError({ | |
| 558 | + message: `${failureMessage} (code=${exitCode as number})`, | |
| 559 | + }), | |
| 560 | + ) | |
| 561 | + : Effect.void, | |
| 562 | + ), | |
| 563 | + Effect.mapError((cause) => toOpenError(failureMessage, cause)), | |
| 564 | + ); | |
| 565 | + | |
| 566 | + const runWaitedPlan = ( | |
| 567 | + plan: LaunchPlan, | |
| 568 | + resolvedCommand: ResolvedCommand, | |
| 569 | + failureMessage: string, | |
| 570 | + ) => | |
| 571 | + Effect.acquireUseRelease( | |
| 572 | + Scope.make("sequential"), | |
| 573 | + (scope) => | |
| 574 | + Effect.gen(function* () { | |
| 575 | + const handle = yield* spawnPlan(plan, resolvedCommand, failureMessage).pipe( | |
| 576 | + Scope.provide(scope), | |
| 577 | + ); | |
| 578 | + yield* waitForExit(plan, failureMessage, handle); | |
| 579 | + }), | |
| 580 | + (scope) => Scope.close(scope, Exit.void), | |
| 581 | + ); | |
| 582 | + | |
| 583 | + const runDetachedPlan = ( | |
| 584 | + plan: LaunchPlan, | |
| 585 | + resolvedCommand: ResolvedCommand, | |
| 586 | + failureMessage: string, | |
| 587 | + ) => | |
| 588 | + Effect.gen(function* () { | |
| 589 | + const childScope = yield* Scope.make("sequential"); | |
| 590 | + const handle = yield* spawnPlan(plan, resolvedCommand, failureMessage).pipe( | |
| 591 | + Scope.provide(childScope), | |
| 592 | + Effect.catch((error) => | |
| 593 | + Scope.close(childScope, Exit.void).pipe(Effect.andThen(Effect.fail(error))), | |
| 594 | + ), | |
| 595 | + ); | |
| 231 | 596 | |
| 232 | − yield* Effect.callback<void, OpenError>((resume) => { | |
| 233 | − let child; | |
| 234 | − try { | |
| 235 | − child = spawn(launch.command, [...launch.args], { | |
| 236 | − detached: true, | |
| 237 | − stdio: "ignore", | |
| 238 | − shell: process.platform === "win32", | |
| 239 | − }); | |
| 240 | − } catch (error) { | |
| 241 | − return resume( | |
| 242 | − Effect.fail(new OpenError({ message: "failed to spawn detached process", cause: error })), | |
| 597 | + const releaseOnExit: Effect.Effect<void, never> = handle.exitCode.pipe( | |
| 598 | + Effect.ignoreCause, | |
| 599 | + Effect.ensuring(Scope.close(childScope, Exit.void)), | |
| 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 = () => { | |
| 247 | − child.unref(); | |
| 248 | − resume(Effect.void); | |
| 249 | − }; | |
| 619 | + return resolveCommand(first.command).pipe( | |
| 620 | + Effect.flatMap((resolvedCommand) => { | |
| 621 | + if (Option.isNone(resolvedCommand)) { | |
| 622 | + return rest.length === 0 | |
| 623 | + ? Effect.fail(new OpenError({ message: `Command not found: ${first.command}` })) | |
| 624 | + : runFirstAvailablePlan(rest, failureMessage); | |
| 625 | + } | |
| 626 | + | |
| 627 | + return runPlan(first, resolvedCommand.value, failureMessage).pipe( | |
| 628 | + Effect.catch((error) => | |
| 629 | + rest.length === 0 ? Effect.fail(error) : runFirstAvailablePlan(rest, failureMessage), | |
| 630 | + ), | |
| 631 | + ); | |
| 632 | + }), | |
| 633 | + ); | |
| 634 | + }; | |
| 635 | + | |
| 636 | + const openExternal = (input: OpenExternalInput) => { | |
| 637 | + if (input.target.trim().length === 0) { | |
| 638 | + return Effect.fail(new OpenError({ message: "Open target must not be empty" })); | |
| 639 | + } | |
| 250 | 640 | |
| 251 | − child.once("spawn", handleSpawn); | |
| 252 | − child.once("error", (cause) => | |
| 253 | − resume(Effect.fail(new OpenError({ message: "failed to spawn detached process", cause }))), | |
| 641 | + return runFirstAvailablePlan( | |
| 642 | + resolveExternalPlans(input, runtime), | |
| 643 | + `Failed to open ${input.target}`, | |
| 254 | 644 | ); |
| 255 | − }); | |
| 256 | − }); | |
| 645 | + }; | |
| 646 | + | |
| 647 | + const openInEditor = (input: OpenInEditorInput) => { | |
| 648 | + const editor = EDITORS.find((candidate) => candidate.id === input.editor); | |
| 649 | + if (!editor) { | |
| 650 | + return Effect.fail(new OpenError({ message: `Unknown editor: ${input.editor}` })); | |
| 651 | + } | |
| 652 | + | |
| 653 | + if (editor.command) { | |
| 654 | + return runFirstAvailablePlan( | |
| 655 | + [ | |
| 656 | + makeLaunchPlan( | |
| 657 | + runtime, | |
| 658 | + editor.command, | |
| 659 | + shouldUseGotoFlag(editor, input.cwd) ? ["--goto", input.cwd] : [input.cwd], | |
| 660 | + { | |
| 661 | + wait: false, | |
| 662 | + allowNonzeroExitCode: false, | |
| 663 | + detached: true, | |
| 664 | + stdio: "ignore", | |
| 665 | + shell: false, | |
| 666 | + }, | |
| 667 | + ), | |
| 668 | + ], | |
| 669 | + `Failed to open ${input.cwd} in ${input.editor}`, | |
| 670 | + ); | |
| 671 | + } | |
| 257 | 672 | |
| 258 | −const make = Effect.gen(function* () { | |
| 259 | − const open = yield* Effect.tryPromise({ | |
| 260 | − try: () => import("open"), | |
| 261 | − catch: (cause) => new OpenError({ message: "failed to load browser opener", cause }), | |
| 673 | + return openExternal({ target: input.cwd }); | |
| 674 | + }; | |
| 675 | + | |
| 676 | + return { | |
| 677 | + getAvailableEditors, | |
| 678 | + openExternal, | |
| 679 | + openBrowser: (target, openOptions = {}) => openExternal({ ...openOptions, target }), | |
| 680 | + openInEditor, | |
| 681 | + } satisfies OpenShape; | |
| 262 | 682 | }); |
| 263 | 683 | |
| 264 | − return { | |
| 265 | − openBrowser: (target) => | |
| 266 | − Effect.tryPromise({ | |
| 267 | − try: () => open.default(target), | |
| 268 | − catch: (cause) => new OpenError({ message: "Browser auto-open failed", cause }), | |
| 269 | − }), | |
| 270 | − openInEditor: (input) => Effect.flatMap(resolveEditorLaunch(input), launchDetached), | |
| 271 | − } satisfies OpenShape; | |
| 272 | −}); | |
| 273 | − | |
| 274 | −export const OpenLive = Layer.effect(Open, make); | |
| 684 | +export const makeOpenLayer = (options: OpenRuntimeOptions = {}) => | |
| 685 | + Layer.effect(Open, makeOpen(options)); | |
| 686 | + | |
| 687 | +export const OpenLive = makeOpenLayer(); | |
| @@ -63,6 +63,8 @@ const asThreadId = (value: string): ThreadId => ThreadId.makeUnsafe(value); · 62 unmodified lines | ||
| 63 | 63 | const asTurnId = (value: string): TurnId => TurnId.makeUnsafe(value); |
| 64 | 64 | |
| 65 | 65 | const defaultOpenService: OpenShape = { |
| 66 | + getAvailableEditors: Effect.succeed([]), | |
| 67 | + openExternal: () => Effect.void, | |
| 66 | 68 | openBrowser: () => Effect.void, |
| 67 | 69 | openInEditor: () => Effect.void, |
| 68 | 70 | }; |
| @@ -1030,6 +1032,8 @@ describe("WebSocket Server", () => { · 961 unmodified lines | ||
| 1030 | 1032 | it("routes shell.openInEditor through the injected open service", async () => { |
| 1031 | 1033 | const openCalls: Array<{ cwd: string; editor: string }> = []; |
| 1032 | 1034 | const openService: OpenShape = { |
| 1035 | + getAvailableEditors: Effect.succeed([]), | |
| 1036 | + openExternal: () => Effect.void, | |
| 1033 | 1037 | openBrowser: () => Effect.void, |
| 1034 | 1038 | openInEditor: (input) => { |
| 1035 | 1039 | openCalls.push({ cwd: input.cwd, editor: input.editor }); |
| @@ -1531,6 +1535,8 @@ describe("WebSocket Server", () => { · 495 unmodified lines | ||
| 1531 | 1535 | process.on("unhandledRejection", onUnhandledRejection); |
| 1532 | 1536 | |
| 1533 | 1537 | const brokenOpenService: OpenShape = { |
| 1538 | + getAvailableEditors: Effect.succeed([]), | |
| 1539 | + openExternal: () => Effect.void, | |
| 1534 | 1540 | openBrowser: () => Effect.void, |
| 1535 | 1541 | openInEditor: () => |
| 1536 | 1542 | Effect.sync(() => BigInt(1)).pipe(Effect.map((result) => result as unknown as void)), |
| @@ -58,7 +58,7 @@ import { ProviderService } from "./provider/Services/ProviderService"; · 57 unmodified lines | ||
| 58 | 58 | import { ProviderRegistry } from "./provider/Services/ProviderRegistry"; |
| 59 | 59 | import { CheckpointDiffQuery } from "./checkpointing/Services/CheckpointDiffQuery"; |
| 60 | 60 | import { clamp } from "effect/Number"; |
| 61 | −import { Open, resolveAvailableEditors } from "./open"; | |
| 61 | +import { Open } from "./open"; | |
| 62 | 62 | import { ServerConfig } from "./config"; |
| 63 | 63 | import { GitCore } from "./git/Services/GitCore.ts"; |
| 64 | 64 | import { tryHandleProjectFaviconRequest } from "./projectFaviconRoute"; |
| @@ -250,7 +250,10 @@ export const createServer = Effect.fn(function* (): Effect.fn.Return< · 185 unmodified lines | ||
| 250 | 250 | logWebSocketEvents, |
| 251 | 251 | autoBootstrapProjectFromCwd, |
| 252 | 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 | 258 | const runtimeServices = yield* Effect.services< |
| 256 | 259 | ServerRuntimeServices | ServerConfig | FileSystem.FileSystem | Path.Path |
| @@ -615,7 +618,7 @@ export const createServer = Effect.fn(function* (): Effect.fn.Return< · 358 unmodified lines | ||
| 615 | 618 | const projectionReadModelQuery = yield* ProjectionSnapshotQuery; |
| 616 | 619 | const checkpointDiffQuery = yield* CheckpointDiffQuery; |
| 617 | 620 | const orchestrationReactor = yield* OrchestrationReactor; |
| 618 | − const { openInEditor } = yield* Open; | |
| 621 | + const { openInEditor } = open; | |
| 619 | 622 | |
| 620 | 623 | const subscriptionsScope = yield* Scope.make("sequential"); |
| 621 | 624 | yield* Effect.addFinalizer(() => Scope.close(subscriptionsScope, Exit.void)); |
| @@ -54,7 +54,6 @@ · 53 unmodified lines | ||
| 54 | 54 | "@pierre/diffs": "^1.1.0-beta.16", |
| 55 | 55 | "effect": "catalog:", |
| 56 | 56 | "node-pty": "^1.1.0", |
| 57 | − "open": "^10.1.0", | |
| 58 | 57 | "ws": "^8.18.0", |
| 59 | 58 | }, |
| 60 | 59 | "devDependencies": { |
| @@ -896,8 +895,6 @@ · 835 unmodified lines | ||
| 896 | 895 | |
| 897 | 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 | 898 | "cac": ["cac@6.7.14", "", {}, "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ=="], |
| 902 | 899 | |
| 903 | 900 | "cacheable-lookup": ["cacheable-lookup@5.0.4", "", {}, "sha512-2/kNscPhpcxrOigMZzbiWF7dz8ilhb/nIHU3EyZiXWXpeq/au8qJ8VhdftMkty3n7Gj6HIGalQG8oiBNB3AJgA=="], |
| @@ -972,16 +969,10 @@ · 68 unmodified lines | ||
| 972 | 969 | |
| 973 | 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 | 972 | "defer-to-connect": ["defer-to-connect@2.0.1", "", {}, "sha512-4tvttepXG1VaYGrRibk5EwJd1t4udunSOVMdLSAL6mId1ix438oPwPZMALY41FCijukO1L0twNcGsdzS7dHgDg=="], |
| 980 | 973 | |
| 981 | 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 | 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 | 978 | "defu": ["defu@6.1.4", "", {}, "sha512-mEQCMmwJu317oSz8CwdIOdwf3xMif1ttiM8LTufzc3g6kR+9Pe236twL8j3IYT1F7GfRgGcW6MWxzZjLIkuHIg=="], |
| @@ -1462,8 +1453,6 @@ · 474 unmodified lines | ||
| 1462 | 1453 | |
| 1463 | 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 | 1456 | "outvariant": ["outvariant@1.4.3", "", {}, "sha512-+Sl2UErvtsoajRDKCE5/dBz4DIvHXQQnAxtQTF04OJxY0+DyZXSo5P5Bb7XYWOh81syohlYL24hbDwxedPUJCA=="], |
| 1468 | 1457 | |
| 1469 | 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 | 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 | 1590 | "rxjs": ["rxjs@7.8.2", "", { "dependencies": { "tslib": "^2.1.0" } }, "sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA=="], |
| 1604 | 1591 | |
| 1605 | 1592 | "sax": ["sax@1.5.0", "", {}, "sha512-21IYA3Q5cQf089Z6tgaUTr7lDAyzoTPx5HRtbhsME8Udispad8dC/+sziTNugOEx54ilvatQ9YCzl4KQLPcRHA=="], |
| @@ -1856,8 +1843,6 @@ · 250 unmodified lines | ||
| 1856 | 1843 | |
| 1857 | 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 | 1846 | "xxhash-wasm": ["xxhash-wasm@1.1.0", "", {}, "sha512-147y/6YNh+tlp6nd/2pWq38i9h6mz/EuQ6njIrmW8D1BS5nCqs0P6DG+m6zTGnNz5I+uhZ0SHxBs9BsPrwcKDA=="], |
| 1862 | 1847 | |
| 1863 | 1848 | "y18n": ["y18n@5.0.8", "", {}, "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA=="], |