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