| author | |
| committer | |
| log | 2f8d3baa88cd137b11083942a4961ca79a9f99be |
| tree | d31dab932ae981bfbe6f192a5d62f61076c938a9 |
| parent | 6b7447632292f54dc98244320a7f47d7d3858ad4 |
| signature | Signed by PGP key B5690EEEBB952194 |
Co-authored-by: codex <codex@users.noreply.github.com>5 files changed, 139 insertions(+), 29 deletions(-)
apps/server/src/preview/PortScanner.test.ts+2-2| ... | ... | @@ -6,9 +6,9 @@ import * as Net from "@t3tools/shared/Net"; |
| 6 | 6 | import { Effect, Layer } from "effect"; |
| 7 | 7 | import { expect } from "vite-plus/test"; |
| 8 | 8 | |
| 9 | import { ProcessRunner } from "../processRunner.ts"; | |
| 9 | import * as ProcessRunner from "../processRunner.ts"; | |
| 10 | 10 | import * as PortScanner from "./PortScanner.ts"; |
| 11 | const TestProcessRunner = Layer.succeed(ProcessRunner, { | |
| 11 | const TestProcessRunner = Layer.succeed(ProcessRunner.ProcessRunner, { | |
| 12 | 12 | run: () => Effect.die("ProcessRunner should not be used by Windows TCP probe tests"), |
| 13 | 13 | }); |
| 14 | 14 | const TestPortDiscoveryLive = PortScanner.layer.pipe( |
apps/server/src/process/externalLauncher.test.ts+11-9| ... | ... | @@ -11,7 +11,7 @@ import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; |
| 11 | 11 | |
| 12 | 12 | import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; |
| 13 | 13 | import { SpawnExecutableResolution } from "@t3tools/shared/shell"; |
| 14 | import { ExternalLauncher, layer as ExternalLauncherLive } from "./externalLauncher.ts"; | |
| 14 | import * as ExternalLauncher from "./externalLauncher.ts"; | |
| 15 | 15 | |
| 16 | 16 | function makeMockDetachedHandle(onUnref: () => void = () => undefined) { |
| 17 | 17 | return ChildProcessSpawner.makeHandle({ |
| ... | ... | @@ -54,7 +54,7 @@ const testLayer = (input: { |
| 54 | 54 | ); |
| 55 | 55 | |
| 56 | 56 | return Layer.mergeAll( |
| 57 | ExternalLauncherLive.pipe(Layer.provide(Layer.merge(NodeServices.layer, spawnerLayer))), | |
| 57 | ExternalLauncher.layer.pipe(Layer.provide(Layer.merge(NodeServices.layer, spawnerLayer))), | |
| 58 | 58 | Layer.succeed(HostProcessPlatform, input.platform), |
| 59 | 59 | Layer.succeed( |
| 60 | 60 | SpawnExecutableResolution, |
| ... | ... | @@ -68,7 +68,7 @@ it.effect("launches the default browser through the platform command", () => { |
| 68 | 68 | let spawned: ChildProcess.StandardCommand | undefined; |
| 69 | 69 | let didUnref = false; |
| 70 | 70 | return Effect.gen(function* () { |
| 71 | const launcher = yield* ExternalLauncher; | |
| 71 | const launcher = yield* ExternalLauncher.ExternalLauncher; | |
| 72 | 72 | |
| 73 | 73 | yield* launcher.launchBrowser("https://example.com/some path"); |
| 74 | 74 | |
| ... | ... | @@ -101,7 +101,7 @@ it.effect("launches an installed editor with platform-safe arguments", () => |
| 101 | 101 | |
| 102 | 102 | let spawned: ChildProcess.StandardCommand | undefined; |
| 103 | 103 | yield* Effect.gen(function* () { |
| 104 | const launcher = yield* ExternalLauncher; | |
| 104 | const launcher = yield* ExternalLauncher.ExternalLauncher; | |
| 105 | 105 | yield* launcher.launchEditor({ |
| 106 | 106 | editor: "vscode", |
| 107 | 107 | cwd: "C:\\workspace with spaces\\src\\index.ts:12:4", |
| ... | ... | @@ -139,7 +139,7 @@ it.effect("discovers editors through the service API", () => |
| 139 | 139 | yield* fileSystem.writeFileString(path.join(binDir, "explorer.CMD"), "@echo off\r\n"); |
| 140 | 140 | |
| 141 | 141 | const editors = yield* Effect.gen(function* () { |
| 142 | const launcher = yield* ExternalLauncher; | |
| 142 | const launcher = yield* ExternalLauncher.ExternalLauncher; | |
| 143 | 143 | return yield* launcher.resolveAvailableEditors(); |
| 144 | 144 | }).pipe( |
| 145 | 145 | Effect.provide( |
| ... | ... | @@ -157,10 +157,12 @@ it.effect("discovers editors through the service API", () => |
| 157 | 157 | |
| 158 | 158 | it.effect("rejects unknown editors through the service API", () => |
| 159 | 159 | Effect.gen(function* () { |
| 160 | const launcher = yield* ExternalLauncher; | |
| 161 | const result = yield* launcher | |
| 160 | const launcher = yield* ExternalLauncher.ExternalLauncher; | |
| 161 | const error = yield* launcher | |
| 162 | 162 | .launchEditor({ editor: "missing-editor" as never, cwd: "/tmp/workspace" }) |
| 163 | .pipe(Effect.result); | |
| 164 | assert.equal(result._tag, "Failure"); | |
| 163 | .pipe(Effect.flip); | |
| 164 | assert.instanceOf(error, ExternalLauncher.ExternalLauncherUnknownEditorError); | |
| 165 | assert.equal(error.editor, "missing-editor"); | |
| 166 | assert.equal(error.message, "Unknown editor: missing-editor"); | |
| 165 | 167 | }).pipe(Effect.provide(testLayer({ platform: "linux", env: { PATH: "" } }))), |
| 166 | 168 | ); |
apps/server/src/process/externalLauncher.ts+49-10| ... | ... | @@ -9,6 +9,11 @@ |
| 9 | 9 | import { |
| 10 | 10 | EDITORS, |
| 11 | 11 | ExternalLauncherError, |
| 12 | ExternalLauncherBrowserSpawnError, | |
| 13 | ExternalLauncherCommandNotFoundError, | |
| 14 | ExternalLauncherEditorSpawnError, | |
| 15 | ExternalLauncherUnknownEditorError, | |
| 16 | ExternalLauncherUnsupportedEditorError, | |
| 12 | 17 | type EditorId, |
| 13 | 18 | type LaunchEditorInput, |
| 14 | 19 | } from "@t3tools/contracts"; |
| ... | ... | @@ -29,9 +34,19 @@ import * as ChildProcessSpawner from "effect/unstable/process/ChildProcessSpawne |
| 29 | 34 | // Definitions |
| 30 | 35 | // ============================== |
| 31 | 36 | |
| 32 | export { ExternalLauncherError }; | |
| 37 | export { | |
| 38 | ExternalLauncherError, | |
| 39 | ExternalLauncherBrowserSpawnError, | |
| 40 | ExternalLauncherCommandNotFoundError, | |
| 41 | ExternalLauncherEditorSpawnError, | |
| 42 | ExternalLauncherUnknownEditorError, | |
| 43 | ExternalLauncherUnsupportedEditorError, | |
| 44 | isExternalLauncherError, | |
| 45 | } from "@t3tools/contracts"; | |
| 33 | 46 | export type { LaunchEditorInput }; |
| 34 | 47 | interface EditorLaunch { |
| 48 | readonly editor: EditorId; | |
| 49 | readonly target: string; | |
| 35 | 50 | readonly command: string; |
| 36 | 51 | readonly args: ReadonlyArray<string>; |
| 37 | 52 | } |
| ... | ... | @@ -317,7 +332,7 @@ const resolveEditorLaunch = Effect.fn("resolveEditorLaunch")(function* ( |
| 317 | 332 | }); |
| 318 | 333 | const editorDef = EDITORS.find((editor) => editor.id === input.editor); |
| 319 | 334 | if (!editorDef) { |
| 320 | return yield* new ExternalLauncherError({ message: `Unknown editor: ${input.editor}` }); | |
| 335 | return yield* new ExternalLauncherUnknownEditorError({ editor: input.editor }); | |
| 321 | 336 | } |
| 322 | 337 | |
| 323 | 338 | if (editorDef.commands) { |
| ... | ... | @@ -326,21 +341,28 @@ const resolveEditorLaunch = Effect.fn("resolveEditorLaunch")(function* ( |
| 326 | 341 | () => editorDef.commands[0], |
| 327 | 342 | ); |
| 328 | 343 | return { |
| 344 | editor: editorDef.id, | |
| 345 | target: input.cwd, | |
| 329 | 346 | command, |
| 330 | 347 | args: resolveEditorArgs(editorDef, input.cwd), |
| 331 | 348 | }; |
| 332 | 349 | } |
| 333 | 350 | |
| 334 | 351 | if (editorDef.id !== "file-manager") { |
| 335 | return yield* new ExternalLauncherError({ message: `Unsupported editor: ${input.editor}` }); | |
| 352 | return yield* new ExternalLauncherUnsupportedEditorError({ editor: input.editor }); | |
| 336 | 353 | } |
| 337 | 354 | |
| 338 | return { command: fileManagerCommandForPlatform(platform), args: [input.cwd] }; | |
| 355 | return { | |
| 356 | editor: editorDef.id, | |
| 357 | target: input.cwd, | |
| 358 | command: fileManagerCommandForPlatform(platform), | |
| 359 | args: [input.cwd], | |
| 360 | }; | |
| 339 | 361 | }); |
| 340 | 362 | |
| 341 | 363 | const launchAndUnref = Effect.fn("externalLauncher.launchAndUnref")(function* ( |
| 342 | 364 | launch: ProcessLaunch, |
| 343 | errorMessage: string, | |
| 365 | onError: (cause: unknown) => ExternalLauncherError, | |
| 344 | 366 | ): Effect.fn.Return<void, ExternalLauncherError, ChildProcessSpawner.ChildProcessSpawner> { |
| 345 | 367 | const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; |
| 346 | 368 | const command = ChildProcess.make(launch.command, launch.args, launch.options); |
| ... | ... | @@ -349,7 +371,7 @@ const launchAndUnref = Effect.fn("externalLauncher.launchAndUnref")(function* ( |
| 349 | 371 | Effect.flatMap((handle) => handle.unref), |
| 350 | 372 | Effect.asVoid, |
| 351 | 373 | Effect.scoped, |
| 352 | Effect.mapError((cause) => new ExternalLauncherError({ message: errorMessage, cause })), | |
| 374 | Effect.mapError(onError), | |
| 353 | 375 | ); |
| 354 | 376 | }); |
| 355 | 377 | |
| ... | ... | @@ -357,7 +379,16 @@ const launchBrowser = Effect.fn("externalLauncher.launchBrowser")(function* ( |
| 357 | 379 | target: string, |
| 358 | 380 | ): Effect.fn.Return<void, ExternalLauncherError, ChildProcessSpawner.ChildProcessSpawner> { |
| 359 | 381 | const launch = yield* resolveBrowserLaunch(target); |
| 360 | return yield* launchAndUnref(launch, "Browser auto-open failed"); | |
| 382 | return yield* launchAndUnref( | |
| 383 | launch, | |
| 384 | (cause) => | |
| 385 | new ExternalLauncherBrowserSpawnError({ | |
| 386 | target, | |
| 387 | command: launch.command, | |
| 388 | args: launch.args, | |
| 389 | cause, | |
| 390 | }), | |
| 391 | ); | |
| 361 | 392 | }); |
| 362 | 393 | |
| 363 | 394 | const launchEditorProcess = Effect.fn("externalLauncher.launchEditorProcess")(function* ( |
| ... | ... | @@ -369,8 +400,9 @@ const launchEditorProcess = Effect.fn("externalLauncher.launchEditorProcess")(fu |
| 369 | 400 | > { |
| 370 | 401 | const env = yield* readCommandLookupEnv; |
| 371 | 402 | if (!(yield* isCommandAvailable(launch.command, { env }))) { |
| 372 | return yield* new ExternalLauncherError({ | |
| 373 | message: `Editor command not found: ${launch.command}`, | |
| 403 | return yield* new ExternalLauncherCommandNotFoundError({ | |
| 404 | editor: launch.editor, | |
| 405 | command: launch.command, | |
| 374 | 406 | }); |
| 375 | 407 | } |
| 376 | 408 | |
| ... | ... | @@ -387,7 +419,14 @@ const launchEditorProcess = Effect.fn("externalLauncher.launchEditorProcess")(fu |
| 387 | 419 | stderr: "ignore", |
| 388 | 420 | }, |
| 389 | 421 | }, |
| 390 | "failed to spawn detached process", | |
| 422 | (cause) => | |
| 423 | new ExternalLauncherEditorSpawnError({ | |
| 424 | editor: launch.editor, | |
| 425 | target: launch.target, | |
| 426 | command: spawnCommand.command, | |
| 427 | args: spawnCommand.args, | |
| 428 | cause, | |
| 429 | }), | |
| 391 | 430 | ); |
| 392 | 431 | }); |
| 393 | 432 |
apps/server/src/server.test.ts+4-3| ... | ... | @@ -14,7 +14,7 @@ import { |
| 14 | 14 | GitCommandError, |
| 15 | 15 | KeybindingRule, |
| 16 | 16 | MessageId, |
| 17 | ExternalLauncherError, | |
| 17 | ExternalLauncherCommandNotFoundError, | |
| 18 | 18 | type OrchestrationThreadShell, |
| 19 | 19 | TerminalNotRunningError, |
| 20 | 20 | type OrchestrationCommand, |
| ... | ... | @@ -4570,8 +4570,9 @@ it.layer(NodeServices.layer)("server router seam", (it) => { |
| 4570 | 4570 | |
| 4571 | 4571 | it.effect("routes websocket rpc shell.openInEditor errors", () => |
| 4572 | 4572 | Effect.gen(function* () { |
| 4573 | const externalLauncherError = new ExternalLauncherError({ | |
| 4574 | message: "Editor command not found: cursor", | |
| 4573 | const externalLauncherError = new ExternalLauncherCommandNotFoundError({ | |
| 4574 | editor: "cursor", | |
| 4575 | command: "cursor", | |
| 4575 | 4576 | }); |
| 4576 | 4577 | yield* buildAppUnderTest({ |
| 4577 | 4578 | layers: { |
packages/contracts/src/editor.ts+73-5| ... | ... | @@ -50,10 +50,78 @@ export const LaunchEditorInput = Schema.Struct({ |
| 50 | 50 | }); |
| 51 | 51 | export type LaunchEditorInput = typeof LaunchEditorInput.Type; |
| 52 | 52 | |
| 53 | export class ExternalLauncherError extends Schema.TaggedErrorClass<ExternalLauncherError>()( | |
| 54 | "ExternalLauncherError", | |
| 53 | export class ExternalLauncherUnknownEditorError extends Schema.TaggedErrorClass<ExternalLauncherUnknownEditorError>()( | |
| 54 | "ExternalLauncherUnknownEditorError", | |
| 55 | 55 | { |
| 56 | message: Schema.String, | |
| 57 | cause: Schema.optional(Schema.Defect()), | |
| 56 | editor: Schema.String, | |
| 58 | 57 | }, |
| 59 | ) {} | |
| 58 | ) { | |
| 59 | override get message(): string { | |
| 60 | return `Unknown editor: ${this.editor}`; | |
| 61 | } | |
| 62 | } | |
| 63 | ||
| 64 | export class ExternalLauncherUnsupportedEditorError extends Schema.TaggedErrorClass<ExternalLauncherUnsupportedEditorError>()( | |
| 65 | "ExternalLauncherUnsupportedEditorError", | |
| 66 | { | |
| 67 | editor: EditorId, | |
| 68 | }, | |
| 69 | ) { | |
| 70 | override get message(): string { | |
| 71 | return `Unsupported editor: ${this.editor}`; | |
| 72 | } | |
| 73 | } | |
| 74 | ||
| 75 | export class ExternalLauncherCommandNotFoundError extends Schema.TaggedErrorClass<ExternalLauncherCommandNotFoundError>()( | |
| 76 | "ExternalLauncherCommandNotFoundError", | |
| 77 | { | |
| 78 | editor: EditorId, | |
| 79 | command: Schema.String, | |
| 80 | }, | |
| 81 | ) { | |
| 82 | override get message(): string { | |
| 83 | return `Editor command not found: ${this.command}`; | |
| 84 | } | |
| 85 | } | |
| 86 | ||
| 87 | const ExternalLauncherSpawnFields = { | |
| 88 | command: Schema.String, | |
| 89 | args: Schema.Array(Schema.String), | |
| 90 | cause: Schema.Defect(), | |
| 91 | }; | |
| 92 | ||
| 93 | export class ExternalLauncherBrowserSpawnError extends Schema.TaggedErrorClass<ExternalLauncherBrowserSpawnError>()( | |
| 94 | "ExternalLauncherBrowserSpawnError", | |
| 95 | { | |
| 96 | ...ExternalLauncherSpawnFields, | |
| 97 | target: Schema.String, | |
| 98 | }, | |
| 99 | ) { | |
| 100 | override get message(): string { | |
| 101 | return `Failed to launch browser target '${this.target}' with '${[this.command, ...this.args].join(" ")}'`; | |
| 102 | } | |
| 103 | } | |
| 104 | ||
| 105 | export class ExternalLauncherEditorSpawnError extends Schema.TaggedErrorClass<ExternalLauncherEditorSpawnError>()( | |
| 106 | "ExternalLauncherEditorSpawnError", | |
| 107 | { | |
| 108 | ...ExternalLauncherSpawnFields, | |
| 109 | editor: EditorId, | |
| 110 | target: Schema.String, | |
| 111 | }, | |
| 112 | ) { | |
| 113 | override get message(): string { | |
| 114 | return `Failed to launch '${this.target}' in ${this.editor} with '${[this.command, ...this.args].join(" ")}'`; | |
| 115 | } | |
| 116 | } | |
| 117 | ||
| 118 | export const ExternalLauncherError = Schema.Union([ | |
| 119 | ExternalLauncherUnknownEditorError, | |
| 120 | ExternalLauncherUnsupportedEditorError, | |
| 121 | ExternalLauncherCommandNotFoundError, | |
| 122 | ExternalLauncherBrowserSpawnError, | |
| 123 | ExternalLauncherEditorSpawnError, | |
| 124 | ]); | |
| 125 | export type ExternalLauncherError = typeof ExternalLauncherError.Type; | |
| 126 | ||
| 127 | export const isExternalLauncherError = Schema.is(ExternalLauncherError); |