| 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,9 +6,9 @@ import * as Net from "@t3tools/shared/Net"; |
| 6 | import { Effect, Layer } from "effect"; | 6 | import { Effect, Layer } from "effect"; |
| 7 | import { expect } from "vite-plus/test"; | 7 | import { expect } from "vite-plus/test"; |
| 8 | 8 | ||
| 9 | import { ProcessRunner } from "../processRunner.ts"; | 9 | import * as ProcessRunner from "../processRunner.ts"; |
| 10 | import * as PortScanner from "./PortScanner.ts"; | 10 | import * as PortScanner from "./PortScanner.ts"; |
| 11 | const TestProcessRunner = Layer.succeed(ProcessRunner, { | 11 | const TestProcessRunner = Layer.succeed(ProcessRunner.ProcessRunner, { |
| 12 | run: () => Effect.die("ProcessRunner should not be used by Windows TCP probe tests"), | 12 | run: () => Effect.die("ProcessRunner should not be used by Windows TCP probe tests"), |
| 13 | }); | 13 | }); |
| 14 | const TestPortDiscoveryLive = PortScanner.layer.pipe( | 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,7 +11,7 @@ import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; |
| 11 | 11 | ||
| 12 | import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; | 12 | import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; |
| 13 | import { SpawnExecutableResolution } from "@t3tools/shared/shell"; | 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 | function makeMockDetachedHandle(onUnref: () => void = () => undefined) { | 16 | function makeMockDetachedHandle(onUnref: () => void = () => undefined) { |
| 17 | return ChildProcessSpawner.makeHandle({ | 17 | return ChildProcessSpawner.makeHandle({ |
| ... | @@ -54,7 +54,7 @@ const testLayer = (input: { | ... | @@ -54,7 +54,7 @@ const testLayer = (input: { |
| 54 | ); | 54 | ); |
| 55 | 55 | ||
| 56 | return Layer.mergeAll( | 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 | Layer.succeed(HostProcessPlatform, input.platform), | 58 | Layer.succeed(HostProcessPlatform, input.platform), |
| 59 | Layer.succeed( | 59 | Layer.succeed( |
| 60 | SpawnExecutableResolution, | 60 | SpawnExecutableResolution, |
| ... | @@ -68,7 +68,7 @@ it.effect("launches the default browser through the platform command", () => { | ... | @@ -68,7 +68,7 @@ it.effect("launches the default browser through the platform command", () => { |
| 68 | let spawned: ChildProcess.StandardCommand | undefined; | 68 | let spawned: ChildProcess.StandardCommand | undefined; |
| 69 | let didUnref = false; | 69 | let didUnref = false; |
| 70 | return Effect.gen(function* () { | 70 | return Effect.gen(function* () { |
| 71 | const launcher = yield* ExternalLauncher; | 71 | const launcher = yield* ExternalLauncher.ExternalLauncher; |
| 72 | 72 | ||
| 73 | yield* launcher.launchBrowser("https://example.com/some path"); | 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,7 +101,7 @@ it.effect("launches an installed editor with platform-safe arguments", () => |
| 101 | 101 | ||
| 102 | let spawned: ChildProcess.StandardCommand | undefined; | 102 | let spawned: ChildProcess.StandardCommand | undefined; |
| 103 | yield* Effect.gen(function* () { | 103 | yield* Effect.gen(function* () { |
| 104 | const launcher = yield* ExternalLauncher; | 104 | const launcher = yield* ExternalLauncher.ExternalLauncher; |
| 105 | yield* launcher.launchEditor({ | 105 | yield* launcher.launchEditor({ |
| 106 | editor: "vscode", | 106 | editor: "vscode", |
| 107 | cwd: "C:\\workspace with spaces\\src\\index.ts:12:4", | 107 | cwd: "C:\\workspace with spaces\\src\\index.ts:12:4", |
| ... | @@ -139,7 +139,7 @@ it.effect("discovers editors through the service API", () => | ... | @@ -139,7 +139,7 @@ it.effect("discovers editors through the service API", () => |
| 139 | yield* fileSystem.writeFileString(path.join(binDir, "explorer.CMD"), "@echo off\r\n"); | 139 | yield* fileSystem.writeFileString(path.join(binDir, "explorer.CMD"), "@echo off\r\n"); |
| 140 | 140 | ||
| 141 | const editors = yield* Effect.gen(function* () { | 141 | const editors = yield* Effect.gen(function* () { |
| 142 | const launcher = yield* ExternalLauncher; | 142 | const launcher = yield* ExternalLauncher.ExternalLauncher; |
| 143 | return yield* launcher.resolveAvailableEditors(); | 143 | return yield* launcher.resolveAvailableEditors(); |
| 144 | }).pipe( | 144 | }).pipe( |
| 145 | Effect.provide( | 145 | Effect.provide( |
| ... | @@ -157,10 +157,12 @@ it.effect("discovers editors through the service API", () => | ... | @@ -157,10 +157,12 @@ it.effect("discovers editors through the service API", () => |
| 157 | 157 | ||
| 158 | it.effect("rejects unknown editors through the service API", () => | 158 | it.effect("rejects unknown editors through the service API", () => |
| 159 | Effect.gen(function* () { | 159 | Effect.gen(function* () { |
| 160 | const launcher = yield* ExternalLauncher; | 160 | const launcher = yield* ExternalLauncher.ExternalLauncher; |
| 161 | const result = yield* launcher | 161 | const error = yield* launcher |
| 162 | .launchEditor({ editor: "missing-editor" as never, cwd: "/tmp/workspace" }) | 162 | .launchEditor({ editor: "missing-editor" as never, cwd: "/tmp/workspace" }) |
| 163 | .pipe(Effect.result); | 163 | .pipe(Effect.flip); |
| 164 | assert.equal(result._tag, "Failure"); | 164 | assert.instanceOf(error, ExternalLauncher.ExternalLauncherUnknownEditorError); |
| 165 | assert.equal(error.editor, "missing-editor"); | ||
| 166 | assert.equal(error.message, "Unknown editor: missing-editor"); | ||
| 165 | }).pipe(Effect.provide(testLayer({ platform: "linux", env: { PATH: "" } }))), | 167 | }).pipe(Effect.provide(testLayer({ platform: "linux", env: { PATH: "" } }))), |
| 166 | ); | 168 | ); |
apps/server/src/process/externalLauncher.ts+49-10| ... | @@ -9,6 +9,11 @@ | ... | @@ -9,6 +9,11 @@ |
| 9 | import { | 9 | import { |
| 10 | EDITORS, | 10 | EDITORS, |
| 11 | ExternalLauncherError, | 11 | ExternalLauncherError, |
| 12 | ExternalLauncherBrowserSpawnError, | ||
| 13 | ExternalLauncherCommandNotFoundError, | ||
| 14 | ExternalLauncherEditorSpawnError, | ||
| 15 | ExternalLauncherUnknownEditorError, | ||
| 16 | ExternalLauncherUnsupportedEditorError, | ||
| 12 | type EditorId, | 17 | type EditorId, |
| 13 | type LaunchEditorInput, | 18 | type LaunchEditorInput, |
| 14 | } from "@t3tools/contracts"; | 19 | } from "@t3tools/contracts"; |
| ... | @@ -29,9 +34,19 @@ import * as ChildProcessSpawner from "effect/unstable/process/ChildProcessSpawne | ... | @@ -29,9 +34,19 @@ import * as ChildProcessSpawner from "effect/unstable/process/ChildProcessSpawne |
| 29 | // Definitions | 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 | export type { LaunchEditorInput }; | 46 | export type { LaunchEditorInput }; |
| 34 | interface EditorLaunch { | 47 | interface EditorLaunch { |
| 48 | readonly editor: EditorId; | ||
| 49 | readonly target: string; | ||
| 35 | readonly command: string; | 50 | readonly command: string; |
| 36 | readonly args: ReadonlyArray<string>; | 51 | readonly args: ReadonlyArray<string>; |
| 37 | } | 52 | } |
| ... | @@ -317,7 +332,7 @@ const resolveEditorLaunch = Effect.fn("resolveEditorLaunch")(function* ( | ... | @@ -317,7 +332,7 @@ const resolveEditorLaunch = Effect.fn("resolveEditorLaunch")(function* ( |
| 317 | }); | 332 | }); |
| 318 | const editorDef = EDITORS.find((editor) => editor.id === input.editor); | 333 | const editorDef = EDITORS.find((editor) => editor.id === input.editor); |
| 319 | if (!editorDef) { | 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 | if (editorDef.commands) { | 338 | if (editorDef.commands) { |
| ... | @@ -326,21 +341,28 @@ const resolveEditorLaunch = Effect.fn("resolveEditorLaunch")(function* ( | ... | @@ -326,21 +341,28 @@ const resolveEditorLaunch = Effect.fn("resolveEditorLaunch")(function* ( |
| 326 | () => editorDef.commands[0], | 341 | () => editorDef.commands[0], |
| 327 | ); | 342 | ); |
| 328 | return { | 343 | return { |
| 344 | editor: editorDef.id, | ||
| 345 | target: input.cwd, | ||
| 329 | command, | 346 | command, |
| 330 | args: resolveEditorArgs(editorDef, input.cwd), | 347 | args: resolveEditorArgs(editorDef, input.cwd), |
| 331 | }; | 348 | }; |
| 332 | } | 349 | } |
| 333 | 350 | ||
| 334 | if (editorDef.id !== "file-manager") { | 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 | const launchAndUnref = Effect.fn("externalLauncher.launchAndUnref")(function* ( | 363 | const launchAndUnref = Effect.fn("externalLauncher.launchAndUnref")(function* ( |
| 342 | launch: ProcessLaunch, | 364 | launch: ProcessLaunch, |
| 343 | errorMessage: string, | 365 | onError: (cause: unknown) => ExternalLauncherError, |
| 344 | ): Effect.fn.Return<void, ExternalLauncherError, ChildProcessSpawner.ChildProcessSpawner> { | 366 | ): Effect.fn.Return<void, ExternalLauncherError, ChildProcessSpawner.ChildProcessSpawner> { |
| 345 | const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; | 367 | const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; |
| 346 | const command = ChildProcess.make(launch.command, launch.args, launch.options); | 368 | const command = ChildProcess.make(launch.command, launch.args, launch.options); |
| ... | @@ -349,7 +371,7 @@ const launchAndUnref = Effect.fn("externalLauncher.launchAndUnref")(function* ( | ... | @@ -349,7 +371,7 @@ const launchAndUnref = Effect.fn("externalLauncher.launchAndUnref")(function* ( |
| 349 | Effect.flatMap((handle) => handle.unref), | 371 | Effect.flatMap((handle) => handle.unref), |
| 350 | Effect.asVoid, | 372 | Effect.asVoid, |
| 351 | Effect.scoped, | 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,7 +379,16 @@ const launchBrowser = Effect.fn("externalLauncher.launchBrowser")(function* ( |
| 357 | target: string, | 379 | target: string, |
| 358 | ): Effect.fn.Return<void, ExternalLauncherError, ChildProcessSpawner.ChildProcessSpawner> { | 380 | ): Effect.fn.Return<void, ExternalLauncherError, ChildProcessSpawner.ChildProcessSpawner> { |
| 359 | const launch = yield* resolveBrowserLaunch(target); | 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 | const launchEditorProcess = Effect.fn("externalLauncher.launchEditorProcess")(function* ( | 394 | const launchEditorProcess = Effect.fn("externalLauncher.launchEditorProcess")(function* ( |
| ... | @@ -369,8 +400,9 @@ const launchEditorProcess = Effect.fn("externalLauncher.launchEditorProcess")(fu | ... | @@ -369,8 +400,9 @@ const launchEditorProcess = Effect.fn("externalLauncher.launchEditorProcess")(fu |
| 369 | > { | 400 | > { |
| 370 | const env = yield* readCommandLookupEnv; | 401 | const env = yield* readCommandLookupEnv; |
| 371 | if (!(yield* isCommandAvailable(launch.command, { env }))) { | 402 | if (!(yield* isCommandAvailable(launch.command, { env }))) { |
| 372 | return yield* new ExternalLauncherError({ | 403 | return yield* new ExternalLauncherCommandNotFoundError({ |
| 373 | message: `Editor command not found: ${launch.command}`, | 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,7 +419,14 @@ const launchEditorProcess = Effect.fn("externalLauncher.launchEditorProcess")(fu |
| 387 | stderr: "ignore", | 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,7 +14,7 @@ import { |
| 14 | GitCommandError, | 14 | GitCommandError, |
| 15 | KeybindingRule, | 15 | KeybindingRule, |
| 16 | MessageId, | 16 | MessageId, |
| 17 | ExternalLauncherError, | 17 | ExternalLauncherCommandNotFoundError, |
| 18 | type OrchestrationThreadShell, | 18 | type OrchestrationThreadShell, |
| 19 | TerminalNotRunningError, | 19 | TerminalNotRunningError, |
| 20 | type OrchestrationCommand, | 20 | type OrchestrationCommand, |
| ... | @@ -4570,8 +4570,9 @@ it.layer(NodeServices.layer)("server router seam", (it) => { | ... | @@ -4570,8 +4570,9 @@ it.layer(NodeServices.layer)("server router seam", (it) => { |
| 4570 | 4570 | ||
| 4571 | it.effect("routes websocket rpc shell.openInEditor errors", () => | 4571 | it.effect("routes websocket rpc shell.openInEditor errors", () => |
| 4572 | Effect.gen(function* () { | 4572 | Effect.gen(function* () { |
| 4573 | const externalLauncherError = new ExternalLauncherError({ | 4573 | const externalLauncherError = new ExternalLauncherCommandNotFoundError({ |
| 4574 | message: "Editor command not found: cursor", | 4574 | editor: "cursor", |
| 4575 | command: "cursor", | ||
| 4575 | }); | 4576 | }); |
| 4576 | yield* buildAppUnderTest({ | 4577 | yield* buildAppUnderTest({ |
| 4577 | layers: { | 4578 | layers: { |
packages/contracts/src/editor.ts+73-5| ... | @@ -50,10 +50,78 @@ export const LaunchEditorInput = Schema.Struct({ | ... | @@ -50,10 +50,78 @@ export const LaunchEditorInput = Schema.Struct({ |
| 50 | }); | 50 | }); |
| 51 | export type LaunchEditorInput = typeof LaunchEditorInput.Type; | 51 | export type LaunchEditorInput = typeof LaunchEditorInput.Type; |
| 52 | 52 | ||
| 53 | export class ExternalLauncherError extends Schema.TaggedErrorClass<ExternalLauncherError>()( | 53 | export class ExternalLauncherUnknownEditorError extends Schema.TaggedErrorClass<ExternalLauncherUnknownEditorError>()( |
| 54 | "ExternalLauncherError", | 54 | "ExternalLauncherUnknownEditorError", |
| 55 | { | 55 | { |
| 56 | message: Schema.String, | 56 | editor: Schema.String, |
| 57 | cause: Schema.optional(Schema.Defect()), | ||
| 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); |