| @@ -52,13 +52,23 @@ if (typeof Bun !== "undefined" && (await Bun.file(wasmOnDisk).exists())) { · 51 unmodified lines |
| 52 | import { Argument as Args, Command, Flag as Options } from "effect/unstable/cli"; | 52 | import { Argument as Args, Command, Flag as Options } from "effect/unstable/cli"; |
| 53 | import { BunRuntime, BunServices } from "@effect/platform-bun"; | 53 | import { BunRuntime, BunServices } from "@effect/platform-bun"; |
| 54 | import { HttpApiClient } from "effect/unstable/httpapi"; | 54 | import { HttpApiClient } from "effect/unstable/httpapi"; |
| 55 | import { FetchHttpClient } from "effect/unstable/http"; | 55 | import { FetchHttpClient, HttpClient, HttpClientRequest } from "effect/unstable/http"; |
| 56 | import { FileSystem, Path as PlatformPath } from "effect"; | 56 | import { FileSystem, Path as PlatformPath } from "effect"; |
| | 57 | import type { PlatformError } from "effect/PlatformError"; |
| 57 | import * as Effect from "effect/Effect"; | 58 | import * as Effect from "effect/Effect"; |
| 58 | import * as Option from "effect/Option"; | 59 | import * as Option from "effect/Option"; |
| 59 | import * as Cause from "effect/Cause"; | 60 | import * as Cause from "effect/Cause"; |
| 60 | | 61 | |
| 61 | import { ExecutorApi } from "@executor-js/api"; | 62 | import { ExecutorApi } from "@executor-js/api"; |
| | 63 | import { |
| | 64 | DEFAULT_EXECUTOR_SERVER_USERNAME, |
| | 65 | getExecutorServerAuthorizationHeader, |
| | 66 | normalizeExecutorServerConnection, |
| | 67 | type ExecutorLocalServerKind, |
| | 68 | type ExecutorLocalServerManifest, |
| | 69 | type ExecutorServerConnection, |
| | 70 | type ExecutorServerConnectionInput, |
| | 71 | } from "@executor-js/sdk/shared"; |
| 62 | import { startServer, runMcpStdioServer, getExecutor } from "@executor-js/local"; | 72 | import { startServer, runMcpStdioServer, getExecutor } from "@executor-js/local"; |
| 63 | import { makeQuickJsExecutor } from "@executor-js/runtime-quickjs"; | 73 | import { makeQuickJsExecutor } from "@executor-js/runtime-quickjs"; |
| 64 | import { fetchIntegrations } from "./integrations"; | 74 | import { fetchIntegrations } from "./integrations"; |
| @@ -86,6 +96,30 @@ import { · 21 unmodified lines |
| 86 | writeDaemonPointer, | 96 | writeDaemonPointer, |
| 87 | writeDaemonRecord, | 97 | writeDaemonRecord, |
| 88 | } from "./daemon-state"; | 98 | } from "./daemon-state"; |
| | 99 | import { |
| | 100 | canAutoStartCliServerConnection, |
| | 101 | chooseCliServerConnectionWithActiveLocal, |
| | 102 | parseCliExecutorServerConnection, |
| | 103 | type CliServerConnectionSource, |
| | 104 | withCliServerAuthFallback, |
| | 105 | } from "./server-connection"; |
| | 106 | import { |
| | 107 | acquireLocalServerStartLock, |
| | 108 | readLocalServerManifest, |
| | 109 | releaseLocalServerStartLock, |
| | 110 | removeLocalServerManifestIfOwnedBy, |
| | 111 | resolveExecutorDataDir, |
| | 112 | writeLocalServerManifest, |
| | 113 | } from "./local-server-manifest"; |
| | 114 | import { |
| | 115 | defaultCliServerConnectionProfile, |
| | 116 | findCliServerConnectionProfile, |
| | 117 | readCliServerConnectionStore, |
| | 118 | removeCliServerConnectionProfile, |
| | 119 | setDefaultCliServerConnectionProfile, |
| | 120 | upsertCliServerConnectionProfile, |
| | 121 | validateCliServerConnectionProfileName, |
| | 122 | } from "./server-profile"; |
| 89 | import { | 123 | import { |
| 90 | buildResumeContentTemplate, | 124 | buildResumeContentTemplate, |
| 91 | buildToolPath, | 125 | buildToolPath, |
| @@ -146,9 +180,15 @@ interface DaemonScopeInfo { · 54 unmodified lines |
| 146 | readonly dir: string; | 180 | readonly dir: string; |
| 147 | } | 181 | } |
| 148 | | 182 | |
| 149 | const readDaemonScopeInfo = (baseUrl: string): Effect.Effect<DaemonScopeInfo | null> => | 183 | const readDaemonScopeInfo = ( |
| | 184 | baseUrl: string, |
| | 185 | authorization?: string, |
| | 186 | ): Effect.Effect<DaemonScopeInfo | null> => |
| 150 | Effect.tryPromise(() => | 187 | Effect.tryPromise(() => |
| 151 | fetch(`${baseUrl}/api/scope`, { signal: AbortSignal.timeout(2000) }), | 188 | fetch(`${baseUrl}/api/scope`, { |
| | 189 | ...(authorization ? { headers: { authorization } } : {}), |
| | 190 | signal: AbortSignal.timeout(2000), |
| | 191 | }), |
| 152 | ).pipe( | 192 | ).pipe( |
| 153 | Effect.flatMap((res) => { | 193 | Effect.flatMap((res) => { |
| 154 | if (!res.ok) return Effect.succeed(null); | 194 | if (!res.ok) return Effect.succeed(null); |
| @@ -174,8 +214,38 @@ const readDaemonScopeInfo = (baseUrl: string): Effect.Effect<DaemonScopeInfo | n · 19 unmodified lines |
| 174 | Effect.catchCause(() => Effect.succeed(null)), | 214 | Effect.catchCause(() => Effect.succeed(null)), |
| 175 | ); | 215 | ); |
| 176 | | 216 | |
| 177 | const isServerReachable = (baseUrl: string): Effect.Effect<boolean> => | 217 | const isServerReachable = (baseUrl: string, authorization?: string): Effect.Effect<boolean> => |
| 178 | readDaemonScopeInfo(baseUrl).pipe(Effect.map((scopeInfo) => scopeInfo !== null)); | 218 | readDaemonScopeInfo(baseUrl, authorization).pipe(Effect.map((scopeInfo) => scopeInfo !== null)); |
| | 219 | |
| | 220 | const readActiveLocalServerManifest = (): Effect.Effect< |
| | 221 | ExecutorLocalServerManifest | null, |
| | 222 | Error, |
| | 223 | FileSystem.FileSystem | PlatformPath.Path |
| | 224 | > => |
| | 225 | Effect.gen(function* () { |
| | 226 | const manifest = yield* readLocalServerManifest(); |
| | 227 | if (!manifest) return null; |
| | 228 | |
| | 229 | if (!isPidAlive(manifest.pid)) { |
| | 230 | yield* removeLocalServerManifestIfOwnedBy({ pid: manifest.pid }).pipe(Effect.ignore); |
| | 231 | return null; |
| | 232 | } |
| | 233 | |
| | 234 | const authorization = getExecutorServerAuthorizationHeader(manifest.connection) ?? undefined; |
| | 235 | if (yield* isServerReachable(manifest.connection.origin, authorization)) { |
| | 236 | return manifest; |
| | 237 | } |
| | 238 | |
| | 239 | return yield* Effect.fail( |
| | 240 | new Error( |
| | 241 | [ |
| | 242 | `A local Executor ${manifest.kind} is registered at ${manifest.connection.origin} (pid ${manifest.pid}) but is not reachable.`, |
| | 243 | "Refusing to start another local server against the same data directory.", |
| | 244 | "Stop the existing process or remove the stale server-control manifest after verifying the process is not using the database.", |
| | 245 | ].join("\n"), |
| | 246 | ), |
| | 247 | ); |
| | 248 | }); |
| 179 | | 249 | |
| 180 | const normalizeDaemonScopeDir = (dir: string): string => { | 250 | const normalizeDaemonScopeDir = (dir: string): string => { |
| 181 | const resolved = resolve(dir); | 251 | const resolved = resolve(dir); |
| @@ -185,6 +255,9 @@ const normalizeDaemonScopeDir = (dir: string): string => { · 3 unmodified lines |
| 185 | const currentDaemonScopeDir = (): string => | 255 | const currentDaemonScopeDir = (): string => |
| 186 | normalizeDaemonScopeDir(process.env.EXECUTOR_SCOPE_DIR ?? process.cwd()); | 256 | normalizeDaemonScopeDir(process.env.EXECUTOR_SCOPE_DIR ?? process.cwd()); |
| 187 | | 257 | |
| | 258 | const currentScopeDirForManifest = (): string | null => |
| | 259 | process.env.EXECUTOR_SCOPE_DIR ? normalizeDaemonScopeDir(process.env.EXECUTOR_SCOPE_DIR) : null; |
| | 260 | |
| 188 | const script = process.argv[1]; | 261 | const script = process.argv[1]; |
| 189 | const isDevMode = isDevCliEntrypoint(script); | 262 | const isDevMode = isDevCliEntrypoint(script); |
| 190 | const cliPrefix = isDevMode ? `bun run ${script}` : "executor"; | 263 | const cliPrefix = isDevMode ? `bun run ${script}` : "executor"; |
| @@ -192,6 +265,21 @@ const cliPrefix = isDevMode ? `bun run ${script}` : "executor"; · 1 unmodified line |
| 192 | const toError = (cause: unknown): Error => | 265 | const toError = (cause: unknown): Error => |
| 193 | cause instanceof Error ? cause : new Error(String(cause)); | 266 | cause instanceof Error ? cause : new Error(String(cause)); |
| 194 | | 267 | |
| | 268 | interface ServerTarget { |
| | 269 | readonly baseUrl?: string; |
| | 270 | readonly serverName?: string; |
| | 271 | } |
| | 272 | |
| | 273 | interface RequestedExecutorServerConnection { |
| | 274 | readonly connection: ExecutorServerConnection; |
| | 275 | readonly source: CliServerConnectionSource; |
| | 276 | } |
| | 277 | |
| | 278 | interface ExecuteCodeResult { |
| | 279 | readonly connection: ExecutorServerConnection; |
| | 280 | readonly outcome: ExecuteCodeOutcome; |
| | 281 | } |
| | 282 | |
| 195 | const parseDaemonUrl = (baseUrl: string) => | 283 | const parseDaemonUrl = (baseUrl: string) => |
| 196 | Effect.try({ | 284 | Effect.try({ |
| 197 | try: () => parseDaemonBaseUrl(baseUrl, DEFAULT_PORT), | 285 | try: () => parseDaemonBaseUrl(baseUrl, DEFAULT_PORT), |
| @@ -199,9 +287,81 @@ const parseDaemonUrl = (baseUrl: string) => · 1 unmodified line |
| 199 | cause instanceof Error ? cause : new Error(`Invalid base URL: ${String(cause)}`), | 287 | cause instanceof Error ? cause : new Error(`Invalid base URL: ${String(cause)}`), |
| 200 | }); | 288 | }); |
| 201 | | 289 | |
| | 290 | const parseExecutorServerConnection = (baseUrl: string) => |
| | 291 | Effect.try({ |
| | 292 | try: () => parseCliExecutorServerConnection(baseUrl), |
| | 293 | catch: (cause) => |
| | 294 | cause instanceof Error ? cause : new Error(`Invalid server URL: ${String(cause)}`), |
| | 295 | }); |
| | 296 | |
| 202 | const daemonBaseUrl = (hostname: string, port: number): string => | 297 | const daemonBaseUrl = (hostname: string, port: number): string => |
| 203 | `http://${canonicalDaemonHost(hostname)}:${port}`; | 298 | `http://${canonicalDaemonHost(hostname)}:${port}`; |
| 204 | | 299 | |
| | 300 | const serverAuthFromInputs = (input: { |
| | 301 | readonly authToken: string | undefined; |
| | 302 | readonly authPassword: string | undefined; |
| | 303 | }): ExecutorServerConnection["auth"] | undefined => { |
| | 304 | if (input.authPassword) { |
| | 305 | return { |
| | 306 | kind: "basic", |
| | 307 | username: DEFAULT_EXECUTOR_SERVER_USERNAME, |
| | 308 | password: input.authPassword, |
| | 309 | }; |
| | 310 | } |
| | 311 | if (input.authToken) return { kind: "bearer", token: input.authToken }; |
| | 312 | return undefined; |
| | 313 | }; |
| | 314 | |
| | 315 | const makeLocalServerManifest = (input: { |
| | 316 | readonly kind: ExecutorLocalServerKind; |
| | 317 | readonly connection: ExecutorServerConnection; |
| | 318 | }): Effect.Effect<ExecutorLocalServerManifest, never, PlatformPath.Path> => |
| | 319 | Effect.gen(function* () { |
| | 320 | const path = yield* PlatformPath.Path; |
| | 321 | return { |
| | 322 | version: 1, |
| | 323 | kind: input.kind, |
| | 324 | pid: process.pid, |
| | 325 | startedAt: new Date().toISOString(), |
| | 326 | dataDir: resolveExecutorDataDir(path), |
| | 327 | scopeDir: currentScopeDirForManifest(), |
| | 328 | connection: input.connection, |
| | 329 | owner: { |
| | 330 | client: "cli", |
| | 331 | version: CLI_VERSION, |
| | 332 | executablePath: isDevMode ? (script ?? null) : process.execPath, |
| | 333 | }, |
| | 334 | }; |
| | 335 | }); |
| | 336 | |
| | 337 | const assertNoOtherActiveLocalServer = (): Effect.Effect< |
| | 338 | void, |
| | 339 | Error, |
| | 340 | FileSystem.FileSystem | PlatformPath.Path |
| | 341 | > => |
| | 342 | Effect.gen(function* () { |
| | 343 | const active = yield* readActiveLocalServerManifest(); |
| | 344 | if (!active || active.pid === process.pid) return; |
| | 345 | return yield* Effect.fail( |
| | 346 | new Error( |
| | 347 | [ |
| | 348 | `A local Executor ${active.kind} is already running at ${active.connection.origin} (pid ${active.pid}).`, |
| | 349 | `It owns the current data directory: ${active.dataDir}`, |
| | 350 | "Stop it before starting another local server.", |
| | 351 | ].join("\n"), |
| | 352 | ), |
| | 353 | ); |
| | 354 | }); |
| | 355 | |
| | 356 | const publishLocalServerManifest = (input: { |
| | 357 | readonly kind: ExecutorLocalServerKind; |
| | 358 | readonly connection: ExecutorServerConnection; |
| | 359 | }): Effect.Effect<void, PlatformError, FileSystem.FileSystem | PlatformPath.Path> => |
| | 360 | Effect.gen(function* () { |
| | 361 | const manifest = yield* makeLocalServerManifest(input); |
| | 362 | yield* writeLocalServerManifest(manifest); |
| | 363 | }); |
| | 364 | |
| 205 | const installDefaultExecutorWebBaseUrl = (baseUrl: string): (() => void) => { | 365 | const installDefaultExecutorWebBaseUrl = (baseUrl: string): (() => void) => { |
| 206 | if (process.env.EXECUTOR_WEB_BASE_URL !== undefined) { | 366 | if (process.env.EXECUTOR_WEB_BASE_URL !== undefined) { |
| 207 | return () => {}; | 367 | return () => {}; |
| @@ -343,6 +503,23 @@ const ensureDaemon = ( · 135 unmodified lines |
| 343 | return resolvedTarget.baseUrl; | 503 | return resolvedTarget.baseUrl; |
| 344 | } | 504 | } |
| 345 | | 505 | |
| | 506 | const active = yield* readActiveLocalServerManifest(); |
| | 507 | if ( |
| | 508 | active && |
| | 509 | normalizeExecutorServerConnection({ origin: active.connection.origin }).origin !== |
| | 510 | normalizeExecutorServerConnection({ origin: resolvedTarget.baseUrl }).origin |
| | 511 | ) { |
| | 512 | return yield* Effect.fail( |
| | 513 | new Error( |
| | 514 | [ |
| | 515 | `A local Executor ${active.kind} is already running at ${active.connection.origin} (pid ${active.pid}).`, |
| | 516 | `It owns the current data directory: ${active.dataDir}`, |
| | 517 | "Refusing to start another local daemon against the same database.", |
| | 518 | ].join("\n"), |
| | 519 | ), |
| | 520 | ); |
| | 521 | } |
| | 522 | |
| 346 | const parsed = yield* parseDaemonUrl(baseUrl); | 523 | const parsed = yield* parseDaemonUrl(baseUrl); |
| 347 | const host = canonicalDaemonHost(parsed.hostname); | 524 | const host = canonicalDaemonHost(parsed.hostname); |
| 348 | | 525 | |
| @@ -366,6 +543,99 @@ const ensureDaemon = ( · 17 unmodified lines |
| 366 | }); | 543 | }); |
| 367 | }).pipe(Effect.mapError(toError)); | 544 | }).pipe(Effect.mapError(toError)); |
| 368 | | 545 | |
| | 546 | const resolveRequestedExecutorServerConnection = ( |
| | 547 | target: ServerTarget, |
| | 548 | ): Effect.Effect< |
| | 549 | RequestedExecutorServerConnection, |
| | 550 | Error, |
| | 551 | FileSystem.FileSystem | PlatformPath.Path |
| | 552 | > => |
| | 553 | Effect.gen(function* () { |
| | 554 | if (target.baseUrl && target.serverName) { |
| | 555 | return yield* Effect.fail(new Error("Use either --server or --base-url, not both.")); |
| | 556 | } |
| | 557 | |
| | 558 | if (target.serverName) { |
| | 559 | const store = yield* readCliServerConnectionStore(); |
| | 560 | const profile = findCliServerConnectionProfile(store, target.serverName); |
| | 561 | if (!profile) { |
| | 562 | return yield* Effect.fail(new Error(`No server profile named "${target.serverName}".`)); |
| | 563 | } |
| | 564 | return { connection: withCliServerAuthFallback(profile.connection), source: "explicit" }; |
| | 565 | } |
| | 566 | |
| | 567 | if (!target.baseUrl) { |
| | 568 | const store = yield* readCliServerConnectionStore(); |
| | 569 | const profile = defaultCliServerConnectionProfile(store); |
| | 570 | if (profile) { |
| | 571 | return { |
| | 572 | connection: withCliServerAuthFallback(profile.connection), |
| | 573 | source: "default-profile", |
| | 574 | }; |
| | 575 | } |
| | 576 | |
| | 577 | const active = yield* readActiveLocalServerManifest(); |
| | 578 | if (active) return { connection: active.connection, source: "active-local" }; |
| | 579 | } |
| | 580 | |
| | 581 | return { |
| | 582 | connection: yield* parseExecutorServerConnection(target.baseUrl ?? DEFAULT_BASE_URL), |
| | 583 | source: target.baseUrl ? "explicit" : "implicit-default", |
| | 584 | }; |
| | 585 | }); |
| | 586 | |
| | 587 | const resolveExecutorServerConnection = ( |
| | 588 | target: ServerTarget, |
| | 589 | ): Effect.Effect<ExecutorServerConnection, Error, FileSystem.FileSystem | PlatformPath.Path> => |
| | 590 | Effect.gen(function* () { |
| | 591 | const requestedResult = yield* resolveRequestedExecutorServerConnection(target); |
| | 592 | const active = yield* readActiveLocalServerManifest(); |
| | 593 | const decision = chooseCliServerConnectionWithActiveLocal({ |
| | 594 | requested: requestedResult.connection, |
| | 595 | source: requestedResult.source, |
| | 596 | active, |
| | 597 | }); |
| | 598 | |
| | 599 | if (decision.kind === "conflict") { |
| | 600 | return yield* Effect.fail( |
| | 601 | new Error( |
| | 602 | [ |
| | 603 | `A local Executor ${decision.active.kind} is already running at ${decision.active.connection.origin} (pid ${decision.active.pid}).`, |
| | 604 | `It owns the current data directory: ${decision.active.dataDir}`, |
| | 605 | "Refusing to auto-start another local server against the same database.", |
| | 606 | `Use the active server, or stop it before starting ${cliPrefix} daemon run.`, |
| | 607 | ].join("\n"), |
| | 608 | ), |
| | 609 | ); |
| | 610 | } |
| | 611 | |
| | 612 | const requested = decision.connection; |
| | 613 | if (decision.kind === "use-active") return requested; |
| | 614 | |
| | 615 | if (!canAutoStartCliServerConnection(requested)) { |
| | 616 | const authorization = getExecutorServerAuthorizationHeader(requested) ?? undefined; |
| | 617 | if (yield* isServerReachable(requested.origin, authorization)) { |
| | 618 | return requested; |
| | 619 | } |
| | 620 | return yield* Effect.fail( |
| | 621 | new Error( |
| | 622 | [ |
| | 623 | `Executor server is not reachable at ${requested.origin}.`, |
| | 624 | "For hosted Executor, set EXECUTOR_API_KEY to a bearer API key.", |
| | 625 | "For password-protected local or desktop servers, set EXECUTOR_AUTH_PASSWORD.", |
| | 626 | "For unauthenticated local Executor, use an http://localhost or http://127.0.0.1 server URL.", |
| | 627 | ].join("\n"), |
| | 628 | ), |
| | 629 | ); |
| | 630 | } |
| | 631 | |
| | 632 | const daemonUrl = yield* ensureDaemon(requested.origin); |
| | 633 | return normalizeExecutorServerConnection({ |
| | 634 | ...requested, |
| | 635 | origin: daemonUrl, |
| | 636 | }); |
| | 637 | }).pipe(Effect.mapError(toError)); |
| | 638 | |
| 369 | const stopDaemon = ( | 639 | const stopDaemon = ( |
| 370 | baseUrl: string, | 640 | baseUrl: string, |
| 371 | ): Effect.Effect<void, Error, FileSystem.FileSystem | PlatformPath.Path> => | 641 | ): Effect.Effect<void, Error, FileSystem.FileSystem | PlatformPath.Path> => |
| @@ -434,6 +704,7 @@ const stopDaemon = ( · 62 unmodified lines |
| 434 | | 704 | |
| 435 | yield* removeDaemonRecord({ hostname: host, port: target.port }); | 705 | yield* removeDaemonRecord({ hostname: host, port: target.port }); |
| 436 | yield* removeDaemonPointer({ hostname: host, scopeId }).pipe(Effect.ignore); | 706 | yield* removeDaemonPointer({ hostname: host, scopeId }).pipe(Effect.ignore); |
| | 707 | yield* removeLocalServerManifestIfOwnedBy({ pid: record.pid }).pipe(Effect.ignore); |
| 437 | console.log(`Daemon stopped at ${target.baseUrl}.`); | 708 | console.log(`Daemon stopped at ${target.baseUrl}.`); |
| 438 | }).pipe(Effect.mapError(toError)); | 709 | }).pipe(Effect.mapError(toError)); |
| 439 | | 710 | |
| @@ -463,12 +734,12 @@ const buildResumeApprovalUrl = (baseUrl: string, executionId: string): string => · 23 unmodified lines |
| 463 | }; | 734 | }; |
| 464 | | 735 | |
| 465 | const executeCode = (input: { | 736 | const executeCode = (input: { |
| 466 | baseUrl: string; | 737 | target: ServerTarget; |
| 467 | code: string; | 738 | code: string; |
| 468 | }): Effect.Effect<ExecuteCodeOutcome, Error, FileSystem.FileSystem | PlatformPath.Path> => | 739 | }): Effect.Effect<ExecuteCodeResult, Error, FileSystem.FileSystem | PlatformPath.Path> => |
| 469 | Effect.gen(function* () { | 740 | Effect.gen(function* () { |
| 470 | const daemonUrl = yield* ensureDaemon(input.baseUrl); | 741 | const connection = yield* resolveExecutorServerConnection(input.target); |
| 471 | const client = yield* makeApiClient(daemonUrl); | 742 | const client = yield* makeApiClient(connection); |
| 472 | const response = yield* client.executions.execute({ | 743 | const response = yield* client.executions.execute({ |
| 473 | payload: { | 744 | payload: { |
| 474 | code: input.code, | 745 | code: input.code, |
| @@ -478,11 +749,16 @@ const executeCode = (input: { · 3 unmodified lines |
| 478 | if (response.status === "paused") { | 749 | if (response.status === "paused") { |
| 479 | const executionId = extractExecutionId(response.structured); | 750 | const executionId = extractExecutionId(response.structured); |
| 480 | return { | 751 | return { |
| 481 | status: "paused" as const, | 752 | connection, |
| 482 | text: response.text, | 753 | outcome: { |
| 483 | executionId, | 754 | status: "paused" as const, |
| 484 | approvalUrl: executionId ? buildResumeApprovalUrl(daemonUrl, executionId) : undefined, | 755 | text: response.text, |
| 485 | interaction: extractPausedInteraction(response.structured), | 756 | executionId, |
| | 757 | approvalUrl: executionId |
| | 758 | ? buildResumeApprovalUrl(connection.origin, executionId) |
| | 759 | : undefined, |
| | 760 | interaction: extractPausedInteraction(response.structured), |
| | 761 | }, |
| 486 | }; | 762 | }; |
| 487 | } | 763 | } |
| 488 | | 764 | |
| @@ -491,12 +767,27 @@ const executeCode = (input: { · 2 unmodified lines |
| 491 | } | 767 | } |
| 492 | | 768 | |
| 493 | return { | 769 | return { |
| 494 | status: "completed" as const, | 770 | connection, |
| 495 | result: extractExecutionResult(response.structured), | 771 | outcome: { |
| | 772 | status: "completed" as const, |
| | 773 | result: extractExecutionResult(response.structured), |
| | 774 | }, |
| 496 | }; | 775 | }; |
| 497 | }).pipe(Effect.mapError(toError)); | 776 | }).pipe(Effect.mapError(toError)); |
| 498 | | 777 | |
| 499 | const printExecutionOutcome = (input: { baseUrl: string; outcome: ExecuteCodeOutcome }) => | 778 | const serverTargetResumeFlag = ( |
| | 779 | target: ServerTarget, |
| | 780 | connection: ExecutorServerConnection, |
| | 781 | ): string => |
| | 782 | target.serverName |
| | 783 | ? `--server ${shellQuoteArg(target.serverName)}` |
| | 784 | : `--base-url ${shellQuoteArg(target.baseUrl ?? connection.origin)}`; |
| | 785 | |
| | 786 | const printExecutionOutcome = (input: { |
| | 787 | target: ServerTarget; |
| | 788 | connection: ExecutorServerConnection; |
| | 789 | outcome: ExecuteCodeOutcome; |
| | 790 | }) => |
| 500 | Effect.sync(() => { | 791 | Effect.sync(() => { |
| 501 | if (input.outcome.status === "paused") { | 792 | if (input.outcome.status === "paused") { |
| 502 | console.log(input.outcome.text); | 793 | console.log(input.outcome.text); |
| @@ -505,7 +796,7 @@ const printExecutionOutcome = (input: { baseUrl: string; outcome: ExecuteCodeOut · 2 unmodified lines |
| 505 | console.log("\nApprove in browser:"); | 796 | console.log("\nApprove in browser:"); |
| 506 | console.log(` ${input.outcome.approvalUrl}`); | 797 | console.log(` ${input.outcome.approvalUrl}`); |
| 507 | } | 798 | } |
| 508 | const commandPrefix = `${cliPrefix} resume --execution-id ${input.outcome.executionId} --base-url ${input.baseUrl}`; | 799 | const commandPrefix = `${cliPrefix} resume --execution-id ${input.outcome.executionId} ${serverTargetResumeFlag(input.target, input.connection)}`; |
| 509 | if (input.outcome.interaction?.kind === "form") { | 800 | if (input.outcome.interaction?.kind === "form") { |
| 510 | const requestedSchema = input.outcome.interaction.requestedSchema; | 801 | const requestedSchema = input.outcome.interaction.requestedSchema; |
| 511 | if (requestedSchema && Object.keys(requestedSchema).length > 0) { | 802 | if (requestedSchema && Object.keys(requestedSchema).length > 0) { |
| @@ -537,10 +828,19 @@ const printExecutionOutcome = (input: { baseUrl: string; outcome: ExecuteCodeOut · 25 unmodified lines |
| 537 | // Typed API client | 828 | // Typed API client |
| 538 | // --------------------------------------------------------------------------- | 829 | // --------------------------------------------------------------------------- |
| 539 | | 830 | |
| 540 | const makeApiClient = (baseUrl: string) => | 831 | const makeApiClient = (connection: ExecutorServerConnection) => { |
| 541 | HttpApiClient.make(ExecutorApi, { baseUrl: `${baseUrl}/api` }).pipe( | 832 | const authorization = getExecutorServerAuthorizationHeader(connection); |
| 542 | Effect.provide(FetchHttpClient.layer), | 833 | return HttpApiClient.make(ExecutorApi, { |
| 543 | ); | 834 | baseUrl: connection.apiBaseUrl, |
| | 835 | ...(authorization |
| | 836 | ? { |
| | 837 | transformClient: HttpClient.mapRequest((request) => |
| | 838 | HttpClientRequest.setHeader(request, "authorization", authorization), |
| | 839 | ), |
| | 840 | } |
| | 841 | : {}), |
| | 842 | }).pipe(Effect.provide(FetchHttpClient.layer)); |
| | 843 | }; |
| 544 | | 844 | |
| 545 | // --------------------------------------------------------------------------- | 845 | // --------------------------------------------------------------------------- |
| 546 | // Foreground session | 846 | // Foreground session |
| @@ -561,39 +861,65 @@ const runForegroundSession = (input: { · 14 unmodified lines |
| 561 | ); | 861 | ); |
| 562 | | 862 | |
| 563 | try { | 863 | try { |
| 564 | const server = yield* Effect.promise(() => | 864 | const startupLock = yield* acquireLocalServerStartLock(); |
| 565 | startServer({ | 865 | let server: Awaited<ReturnType<typeof startServer>> | null = null; |
| 566 | port: input.port, | 866 | let baseUrl: string | null = null; |
| 567 | hostname: input.hostname, | | |
| 568 | allowedHosts: input.allowedHosts, | | |
| 569 | authToken: input.authToken, | | |
| 570 | authPassword: input.authPassword, | | |
| 571 | embeddedWebUI, | | |
| 572 | }), | | |
| 573 | ); | | |
| 574 | | 867 | |
| 575 | const baseUrl = `http://${displayHost}:${server.port}`; | 868 | try { |
| 576 | console.log(`Executor is ready.`); | 869 | yield* assertNoOtherActiveLocalServer(); |
| 577 | console.log(`Web: ${baseUrl}`); | 870 | server = yield* Effect.promise(() => |
| 578 | console.log(`MCP: ${baseUrl}/mcp`); | 871 | startServer({ |
| 579 | console.log(`OpenAPI: ${baseUrl}/api/docs`); | 872 | port: input.port, |
| 580 | if (input.hostname !== "127.0.0.1" && input.hostname !== "localhost") { | 873 | hostname: input.hostname, |
| 581 | console.log( | 874 | allowedHosts: input.allowedHosts, |
| 582 | `\n⚠ Listening on ${input.hostname}. Executor runs arbitrary commands — only expose on trusted networks.`, | 875 | authToken: input.authToken, |
| | 876 | authPassword: input.authPassword, |
| | 877 | embeddedWebUI, |
| | 878 | }), |
| 583 | ); | 879 | ); |
| 584 | if (input.allowedHosts.length > 0) { | 880 | baseUrl = `http://${displayHost}:${server.port}`; |
| 585 | console.log(` Extra allowed Host headers: ${input.allowedHosts.join(", ")}`); | 881 | yield* publishLocalServerManifest({ |
| 586 | } | 882 | kind: "foreground", |
| 587 | if (input.authPassword) { | 883 | connection: normalizeExecutorServerConnection({ |
| 588 | console.log(" Basic authentication is enabled."); | 884 | kind: "http", |
| 589 | } else if (input.authToken) { | 885 | origin: baseUrl, |
| 590 | console.log(" Token authentication is enabled."); | 886 | displayName: "CLI web", |
| 591 | } | 887 | auth: serverAuthFromInputs(input), |
| | 888 | }), |
| | 889 | }); |
| | 890 | } finally { |
| | 891 | yield* releaseLocalServerStartLock(startupLock).pipe(Effect.ignore); |
| | 892 | } |
| | 893 | |
| | 894 | if (!server || !baseUrl) { |
| | 895 | return yield* Effect.fail(new Error("Failed to start local Executor server.")); |
| 592 | } | 896 | } |
| 593 | console.log(`\nPress Ctrl+C to stop.`); | | |
| 594 | | 897 | |
| 595 | yield* waitForShutdownSignal(); | 898 | try { |
| 596 | yield* Effect.promise(() => server.stop()); | 899 | console.log(`Executor is ready.`); |
| | 900 | console.log(`Web: ${baseUrl}`); |
| | 901 | console.log(`MCP: ${baseUrl}/mcp`); |
| | 902 | console.log(`OpenAPI: ${baseUrl}/api/docs`); |
| | 903 | if (input.hostname !== "127.0.0.1" && input.hostname !== "localhost") { |
| | 904 | console.log( |
| | 905 | `\n⚠ Listening on ${input.hostname}. Executor runs arbitrary commands — only expose on trusted networks.`, |
| | 906 | ); |
| | 907 | if (input.allowedHosts.length > 0) { |
| | 908 | console.log(` Extra allowed Host headers: ${input.allowedHosts.join(", ")}`); |
| | 909 | } |
| | 910 | if (input.authPassword) { |
| | 911 | console.log(" Basic authentication is enabled."); |
| | 912 | } else if (input.authToken) { |
| | 913 | console.log(" Token authentication is enabled."); |
| | 914 | } |
| | 915 | } |
| | 916 | console.log(`\nPress Ctrl+C to stop.`); |
| | 917 | |
| | 918 | yield* waitForShutdownSignal(); |
| | 919 | } finally { |
| | 920 | yield* Effect.promise(() => server.stop()); |
| | 921 | yield* removeLocalServerManifestIfOwnedBy({ pid: process.pid }).pipe(Effect.ignore); |
| | 922 | } |
| 597 | } finally { | 923 | } finally { |
| 598 | restoreWebBaseUrl(); | 924 | restoreWebBaseUrl(); |
| 599 | } | 925 | } |
| @@ -614,37 +940,62 @@ const runDaemonSession = (input: { · 14 unmodified lines |
| 614 | const scopeId = currentDaemonScopeId(); | 940 | const scopeId = currentDaemonScopeId(); |
| 615 | | 941 | |
| 616 | try { | 942 | try { |
| 617 | const existing = yield* readDaemonPointer({ hostname: daemonHost, scopeId }); | 943 | const startupLock = yield* acquireLocalServerStartLock(); |
| | 944 | let server: Awaited<ReturnType<typeof startServer>> | null = null; |
| | 945 | let daemonPort: number | null = null; |
| | 946 | let token: string | null = null; |
| 618 | | 947 | |
| 619 | if (existing) { | 948 | try { |
| 620 | const existingUrl = daemonBaseUrl(existing.hostname, existing.port); | 949 | yield* assertNoOtherActiveLocalServer(); |
| 621 | if (isPidAlive(existing.pid) && (yield* isServerReachable(existingUrl))) { | 950 | |
| 622 | return yield* Effect.fail( | 951 | const existing = yield* readDaemonPointer({ hostname: daemonHost, scopeId }); |
| 623 | new Error( | 952 | |
| 624 | [ | 953 | if (existing) { |
| 625 | `A daemon is already running for scope ${scopeId} on ${daemonHost}.`, | 954 | const existingUrl = daemonBaseUrl(existing.hostname, existing.port); |
| 626 | `Existing daemon: ${existingUrl} (pid ${existing.pid}).`, | 955 | if (isPidAlive(existing.pid) && (yield* isServerReachable(existingUrl))) { |
| 627 | `Stop it first: ${cliPrefix} daemon stop`, | 956 | return yield* Effect.fail( |
| 628 | ].join("\n"), | 957 | new Error( |
| 629 | ), | 958 | [ |
| 630 | ); | 959 | `A daemon is already running for scope ${scopeId} on ${daemonHost}.`, |
| | 960 | `Existing daemon: ${existingUrl} (pid ${existing.pid}).`, |
| | 961 | `Stop it first: ${cliPrefix} daemon stop`, |
| | 962 | ].join("\n"), |
| | 963 | ), |
| | 964 | ); |
| | 965 | } |
| | 966 | yield* cleanupPointer({ hostname: existing.hostname, scopeId, port: existing.port }); |
| 631 | } | 967 | } |
| 632 | yield* cleanupPointer({ hostname: existing.hostname, scopeId, port: existing.port }); | | |
| 633 | } | | |
| 634 | | 968 | |
| 635 | const server = yield* Effect.promise(() => | 969 | server = yield* Effect.promise(() => |
| 636 | startServer({ | 970 | startServer({ |
| 637 | port: input.port, | 971 | port: input.port, |
| 638 | hostname: input.hostname, | 972 | hostname: input.hostname, |
| 639 | allowedHosts: input.allowedHosts, | 973 | allowedHosts: input.allowedHosts, |
| 640 | authToken: input.authToken, | 974 | authToken: input.authToken, |
| 641 | authPassword: input.authPassword, | 975 | authPassword: input.authPassword, |
| 642 | embeddedWebUI, | 976 | embeddedWebUI, |
| 643 | }), | 977 | }), |
| 644 | ); | 978 | ); |
| | 979 | |
| | 980 | daemonPort = server.port; |
| | 981 | token = randomUUID(); |
| | 982 | const daemonUrl = daemonBaseUrl(daemonHost, daemonPort); |
| | 983 | yield* publishLocalServerManifest({ |
| | 984 | kind: "cli-daemon", |
| | 985 | connection: normalizeExecutorServerConnection({ |
| | 986 | kind: "http", |
| | 987 | origin: daemonUrl, |
| | 988 | displayName: "CLI daemon", |
| | 989 | auth: serverAuthFromInputs(input), |
| | 990 | }), |
| | 991 | }); |
| | 992 | } finally { |
| | 993 | yield* releaseLocalServerStartLock(startupLock).pipe(Effect.ignore); |
| | 994 | } |
| 645 | | 995 | |
| 646 | const daemonPort = server.port; | 996 | if (!server || daemonPort === null || token === null) { |
| 647 | const token = randomUUID(); | 997 | return yield* Effect.fail(new Error("Failed to start local Executor daemon.")); |
| | 998 | } |
| 648 | | 999 | |
| 649 | try { | 1000 | try { |
| 650 | yield* writeDaemonRecord({ | 1001 | yield* writeDaemonRecord({ |
| @@ -674,6 +1025,7 @@ const runDaemonSession = (input: { · 23 unmodified lines |
| 674 | yield* Effect.promise(() => server.stop()); | 1025 | yield* Effect.promise(() => server.stop()); |
| 675 | yield* removeDaemonRecord({ hostname: daemonHost, port: daemonPort }); | 1026 | yield* removeDaemonRecord({ hostname: daemonHost, port: daemonPort }); |
| 676 | yield* removeDaemonPointer({ hostname: daemonHost, scopeId }).pipe(Effect.ignore); | 1027 | yield* removeDaemonPointer({ hostname: daemonHost, scopeId }).pipe(Effect.ignore); |
| | 1028 | yield* removeLocalServerManifestIfOwnedBy({ pid: process.pid }).pipe(Effect.ignore); |
| 677 | } | 1029 | } |
| 678 | } finally { | 1030 | } finally { |
| 679 | restoreWebBaseUrl(); | 1031 | restoreWebBaseUrl(); |
| @@ -748,30 +1100,57 @@ const withStdoutReroutedToStderr = async <A>(body: () => Promise<A>): Promise<A> · 68 unmodified lines |
| 748 | | 1100 | |
| 749 | const runStdioMcpSession = (input: { readonly elicitationMode: "browser" | "model" }) => | 1101 | const runStdioMcpSession = (input: { readonly elicitationMode: "browser" | "model" }) => |
| 750 | Effect.gen(function* () { | 1102 | Effect.gen(function* () { |
| 751 | const web = yield* Effect.promise(() => | 1103 | const startupLock = yield* acquireLocalServerStartLock(); |
| 752 | withStdoutReroutedToStderr(async () => { | 1104 | let web: Awaited< |
| 753 | const host = "127.0.0.1"; | 1105 | ReturnType< |
| 754 | const port = await Effect.runPromise( | 1106 | typeof withStdoutReroutedToStderr<{ |
| 755 | chooseDaemonPort({ preferredPort: DEFAULT_PORT, hostname: host }), | 1107 | readonly executor: Awaited<ReturnType<typeof getExecutor>>; |
| 756 | ); | 1108 | readonly server: Awaited<ReturnType<typeof startServer>>; |
| 757 | const baseUrl = `http://localhost:${port}`; | 1109 | readonly baseUrl: string; |
| 758 | const restoreWebBaseUrl = installDefaultExecutorWebBaseUrl(baseUrl); | 1110 | readonly restoreWebBaseUrl: () => void; |
| 759 | | 1111 | }> |
| 760 | try { | 1112 | > |
| 761 | const executor = await getExecutor(); | 1113 | > | null = null; |
| 762 | const server = await startServer({ | 1114 | |
| 763 | port, | 1115 | try { |
| 764 | hostname: host, | 1116 | yield* assertNoOtherActiveLocalServer(); |
| 765 | embeddedWebUI, | 1117 | web = yield* Effect.promise(() => |
| 766 | }); | 1118 | withStdoutReroutedToStderr(async () => { |
| 767 | const serverBaseUrl = `http://localhost:${server.port}`; | 1119 | const host = "127.0.0.1"; |
| 768 | return { executor, server, baseUrl: serverBaseUrl, restoreWebBaseUrl }; | 1120 | const port = await Effect.runPromise( |
| 769 | } catch (cause) { | 1121 | chooseDaemonPort({ preferredPort: DEFAULT_PORT, hostname: host }), |
| 770 | restoreWebBaseUrl(); | 1122 | ); |
| 771 | throw cause; | 1123 | const baseUrl = `http://localhost:${port}`; |
| 772 | } | 1124 | const restoreWebBaseUrl = installDefaultExecutorWebBaseUrl(baseUrl); |
| 773 | }), | 1125 | |
| 774 | ); | 1126 | try { |
| | 1127 | const executor = await getExecutor(); |
| | 1128 | const server = await startServer({ |
| | 1129 | port, |
| | 1130 | hostname: host, |
| | 1131 | embeddedWebUI, |
| | 1132 | }); |
| | 1133 | const serverBaseUrl = `http://localhost:${server.port}`; |
| | 1134 | return { executor, server, baseUrl: serverBaseUrl, restoreWebBaseUrl }; |
| | 1135 | } catch (cause) { |
| | 1136 | restoreWebBaseUrl(); |
| | 1137 | throw cause; |
| | 1138 | } |
| | 1139 | }), |
| | 1140 | ); |
| | 1141 | yield* publishLocalServerManifest({ |
| | 1142 | kind: "foreground", |
| | 1143 | connection: normalizeExecutorServerConnection({ |
| | 1144 | kind: "http", |
| | 1145 | origin: web.baseUrl, |
| | 1146 | displayName: "CLI MCP", |
| | 1147 | }), |
| | 1148 | }); |
| | 1149 | } finally { |
| | 1150 | yield* releaseLocalServerStartLock(startupLock).pipe(Effect.ignore); |
| | 1151 | } |
| | 1152 | |
| | 1153 | if (!web) return yield* Effect.fail(new Error("Failed to start local Executor MCP server.")); |
| 775 | | 1154 | |
| 776 | try { | 1155 | try { |
| 777 | yield* Effect.promise(() => | 1156 | yield* Effect.promise(() => |
| @@ -791,6 +1170,7 @@ const runStdioMcpSession = (input: { readonly elicitationMode: "browser" | "mode · 13 unmodified lines |
| 791 | } finally { | 1170 | } finally { |
| 792 | web.restoreWebBaseUrl(); | 1171 | web.restoreWebBaseUrl(); |
| 793 | yield* Effect.promise(() => web.server.stop()); | 1172 | yield* Effect.promise(() => web.server.stop()); |
| | 1173 | yield* removeLocalServerManifestIfOwnedBy({ pid: process.pid }).pipe(Effect.ignore); |
| 794 | } | 1174 | } |
| 795 | }); | 1175 | }); |
| 796 | | 1176 | |
| @@ -799,6 +1179,31 @@ const scope = Options.string("scope").pipe( · 2 unmodified lines |
| 799 | Options.withDescription("Path to workspace directory containing executor.jsonc"), | 1179 | Options.withDescription("Path to workspace directory containing executor.jsonc"), |
| 800 | ); | 1180 | ); |
| 801 | | 1181 | |
| | 1182 | const serverBaseUrl = Options.string("base-url").pipe( |
| | 1183 | Options.optional, |
| | 1184 | Options.withDescription( |
| | 1185 | "Executor server origin. Overrides the default profile; local URLs auto-start the daemon.", |
| | 1186 | ), |
| | 1187 | ); |
| | 1188 | |
| | 1189 | const serverProfile = Options.string("server").pipe( |
| | 1190 | Options.optional, |
| | 1191 | Options.withDescription("Named Executor server profile."), |
| | 1192 | ); |
| | 1193 | |
| | 1194 | const daemonBaseUrlOption = Options.string("base-url").pipe( |
| | 1195 | Options.withDefault(DEFAULT_BASE_URL), |
| | 1196 | Options.withDescription("Local daemon origin."), |
| | 1197 | ); |
| | 1198 | |
| | 1199 | const serverTargetFromOptions = (input: { |
| | 1200 | readonly baseUrl: Option.Option<string>; |
| | 1201 | readonly server: Option.Option<string>; |
| | 1202 | }): ServerTarget => ({ |
| | 1203 | baseUrl: Option.getOrUndefined(input.baseUrl), |
| | 1204 | serverName: Option.getOrUndefined(input.server), |
| | 1205 | }); |
| | 1206 | |
| 802 | const applyScope = (s: Option.Option<string>) => { | 1207 | const applyScope = (s: Option.Option<string>) => { |
| 803 | const dir = Option.getOrUndefined(s); | 1208 | const dir = Option.getOrUndefined(s); |
| 804 | if (dir) process.env.EXECUTOR_SCOPE_DIR = resolve(dir); | 1209 | if (dir) process.env.EXECUTOR_SCOPE_DIR = resolve(dir); |
| @@ -866,7 +1271,8 @@ const parsePositiveIntegerOption = (name: string, raw: string): number => { · 61 unmodified lines |
| 866 | | 1271 | |
| 867 | interface ParsedCallHelpArgs { | 1272 | interface ParsedCallHelpArgs { |
| 868 | readonly pathParts: ReadonlyArray<string>; | 1273 | readonly pathParts: ReadonlyArray<string>; |
| 869 | readonly baseUrl: string; | 1274 | readonly baseUrl: string | undefined; |
| | 1275 | readonly serverName: string | undefined; |
| 870 | readonly scopeDir: string | undefined; | 1276 | readonly scopeDir: string | undefined; |
| 871 | readonly match: string | undefined; | 1277 | readonly match: string | undefined; |
| 872 | readonly limit: number | undefined; | 1278 | readonly limit: number | undefined; |
| @@ -877,7 +1283,8 @@ const HELP_FLAGS = new Set(["--help", "-h"]); · 4 unmodified lines |
| 877 | const isHelpFlag = (value: string): boolean => HELP_FLAGS.has(value); | 1283 | const isHelpFlag = (value: string): boolean => HELP_FLAGS.has(value); |
| 878 | | 1284 | |
| 879 | const parseCallHelpArgs = (args: ReadonlyArray<string>): ParsedCallHelpArgs => { | 1285 | const parseCallHelpArgs = (args: ReadonlyArray<string>): ParsedCallHelpArgs => { |
| 880 | let baseUrl = DEFAULT_BASE_URL; | 1286 | let baseUrl: string | undefined = undefined; |
| | 1287 | let serverName: string | undefined = undefined; |
| 881 | let scopeDir: string | undefined = undefined; | 1288 | let scopeDir: string | undefined = undefined; |
| 882 | let match: string | undefined = undefined; | 1289 | let match: string | undefined = undefined; |
| 883 | let limit: number | undefined = undefined; | 1290 | let limit: number | undefined = undefined; |
| @@ -899,6 +1306,18 @@ const parseCallHelpArgs = (args: ReadonlyArray<string>): ParsedCallHelpArgs => { · 15 unmodified lines |
| 899 | continue; | 1306 | continue; |
| 900 | } | 1307 | } |
| 901 | | 1308 | |
| | 1309 | if (token === "--server") { |
| | 1310 | const value = args[index + 1]; |
| | 1311 | if (!value) throw new Error("Missing value for --server"); |
| | 1312 | serverName = value; |
| | 1313 | index += 1; |
| | 1314 | continue; |
| | 1315 | } |
| | 1316 | if (token.startsWith("--server=")) { |
| | 1317 | serverName = token.slice("--server=".length); |
| | 1318 | continue; |
| | 1319 | } |
| | 1320 | |
| 902 | if (token === "--scope") { | 1321 | if (token === "--scope") { |
| 903 | const value = args[index + 1]; | 1322 | const value = args[index + 1]; |
| 904 | if (!value) throw new Error("Missing value for --scope"); | 1323 | if (!value) throw new Error("Missing value for --scope"); |
| @@ -958,7 +1377,7 @@ const parseCallHelpArgs = (args: ReadonlyArray<string>): ParsedCallHelpArgs => { · 53 unmodified lines |
| 958 | pathParts.pop(); | 1377 | pathParts.pop(); |
| 959 | } | 1378 | } |
| 960 | | 1379 | |
| 961 | return { pathParts, baseUrl, scopeDir, match, limit }; | 1380 | return { pathParts, baseUrl, serverName, scopeDir, match, limit }; |
| 962 | }; | 1381 | }; |
| 963 | | 1382 | |
| 964 | const printCallBrowseHelp = (input: { | 1383 | const printCallBrowseHelp = (input: { |
| @@ -1097,8 +1516,11 @@ const runCallHelp = ( · 132 unmodified lines |
| 1097 | Effect.gen(function* () { | 1516 | Effect.gen(function* () { |
| 1098 | if (args.scopeDir) process.env.EXECUTOR_SCOPE_DIR = resolve(args.scopeDir); | 1517 | if (args.scopeDir) process.env.EXECUTOR_SCOPE_DIR = resolve(args.scopeDir); |
| 1099 | | 1518 | |
| 1100 | const daemonUrl = yield* ensureDaemon(args.baseUrl); | 1519 | const connection = yield* resolveExecutorServerConnection({ |
| 1101 | const client = yield* makeApiClient(daemonUrl); | 1520 | baseUrl: args.baseUrl, |
| | 1521 | serverName: args.serverName, |
| | 1522 | }); |
| | 1523 | const client = yield* makeApiClient(connection); |
| 1102 | const scopeInfo = yield* client.scope.info(); | 1524 | const scopeInfo = yield* client.scope.info(); |
| 1103 | const tools = yield* client.tools.list({ params: { scopeId: scopeInfo.id } }); | 1525 | const tools = yield* client.tools.list({ params: { scopeId: scopeInfo.id } }); |
| 1104 | const toolPaths = tools.map((tool) => tool.id); | 1526 | const toolPaths = tools.map((tool) => tool.id); |
| @@ -1257,12 +1679,14 @@ const callCommand = Command.make( · 152 unmodified lines |
| 1257 | "call", | 1679 | "call", |
| 1258 | { | 1680 | { |
| 1259 | pathParts: Args.string("tool-path-segment").pipe(Args.variadic({})), | 1681 | pathParts: Args.string("tool-path-segment").pipe(Args.variadic({})), |
| 1260 | baseUrl: Options.string("base-url").pipe(Options.withDefault(DEFAULT_BASE_URL)), | 1682 | baseUrl: serverBaseUrl, |
| | 1683 | server: serverProfile, |
| 1261 | scope, | 1684 | scope, |
| 1262 | }, | 1685 | }, |
| 1263 | ({ pathParts, baseUrl, scope }) => | 1686 | ({ pathParts, baseUrl, server, scope }) => |
| 1264 | Effect.gen(function* () { | 1687 | Effect.gen(function* () { |
| 1265 | applyScope(scope); | 1688 | applyScope(scope); |
| | 1689 | const target = serverTargetFromOptions({ baseUrl, server }); |
| 1266 | const { path, args } = yield* resolveToolInvocation({ | 1690 | const { path, args } = yield* resolveToolInvocation({ |
| 1267 | rawPathParts: pathParts, | 1691 | rawPathParts: pathParts, |
| 1268 | }); | 1692 | }); |
| @@ -1272,8 +1696,12 @@ const callCommand = Command.make( · 3 unmodified lines |
| 1272 | cause instanceof Error ? cause : new Error(`Invalid tool path: ${String(cause)}`), | 1696 | cause instanceof Error ? cause : new Error(`Invalid tool path: ${String(cause)}`), |
| 1273 | }); | 1697 | }); |
| 1274 | | 1698 | |
| 1275 | const outcome = yield* executeCode({ baseUrl, code }); | 1699 | const result = yield* executeCode({ target, code }); |
| 1276 | yield* printExecutionOutcome({ baseUrl, outcome }); | 1700 | yield* printExecutionOutcome({ |
| | 1701 | target, |
| | 1702 | connection: result.connection, |
| | 1703 | outcome: result.outcome, |
| | 1704 | }); |
| 1277 | }), | 1705 | }), |
| 1278 | ).pipe( | 1706 | ).pipe( |
| 1279 | Command.withDescription( | 1707 | Command.withDescription( |
| @@ -1295,17 +1723,19 @@ const resumeCommand = Command.make( · 15 unmodified lines |
| 1295 | Options.optional, | 1723 | Options.optional, |
| 1296 | Options.withDescription("JSON object to send when action=accept"), | 1724 | Options.withDescription("JSON object to send when action=accept"), |
| 1297 | ), | 1725 | ), |
| 1298 | baseUrl: Options.string("base-url").pipe(Options.withDefault(DEFAULT_BASE_URL)), | 1726 | baseUrl: serverBaseUrl, |
| | 1727 | server: serverProfile, |
| 1299 | scope, | 1728 | scope, |
| 1300 | }, | 1729 | }, |
| 1301 | ({ executionId, action, content, baseUrl, scope }) => | 1730 | ({ executionId, action, content, baseUrl, server, scope }) => |
| 1302 | Effect.gen(function* () { | 1731 | Effect.gen(function* () { |
| 1303 | applyScope(scope); | 1732 | applyScope(scope); |
| 1304 | const daemonUrl = yield* ensureDaemon(baseUrl); | 1733 | const target = serverTargetFromOptions({ baseUrl, server }); |
| | 1734 | const connection = yield* resolveExecutorServerConnection(target); |
| 1305 | | 1735 | |
| 1306 | const contentObj = yield* parseOptionalJsonObject(Option.getOrUndefined(content)); | 1736 | const contentObj = yield* parseOptionalJsonObject(Option.getOrUndefined(content)); |
| 1307 | | 1737 | |
| 1308 | const client = yield* makeApiClient(daemonUrl); | 1738 | const client = yield* makeApiClient(connection); |
| 1309 | const result = yield* client.executions.resume({ | 1739 | const result = yield* client.executions.resume({ |
| 1310 | params: { executionId }, | 1740 | params: { executionId }, |
| 1311 | payload: { action, content: contentObj }, | 1741 | payload: { action, content: contentObj }, |
| @@ -1317,7 +1747,7 @@ const resumeCommand = Command.make( · 5 unmodified lines |
| 1317 | if (nextExecutionId) { | 1747 | if (nextExecutionId) { |
| 1318 | console.log(""); | 1748 | console.log(""); |
| 1319 | console.log("Approval required:"); | 1749 | console.log("Approval required:"); |
| 1320 | console.log(buildResumeApprovalUrl(daemonUrl, nextExecutionId)); | 1750 | console.log(buildResumeApprovalUrl(connection.origin, nextExecutionId)); |
| 1321 | } | 1751 | } |
| 1322 | process.exit(0); | 1752 | process.exit(0); |
| 1323 | } | 1753 | } |
| @@ -1347,20 +1777,26 @@ const toolsSearchCommand = Command.make( · 23 unmodified lines |
| 1347 | query: Args.string("query"), | 1777 | query: Args.string("query"), |
| 1348 | namespace: Options.string("namespace").pipe(Options.optional), | 1778 | namespace: Options.string("namespace").pipe(Options.optional), |
| 1349 | limit: Options.integer("limit").pipe(Options.withDefault(12)), | 1779 | limit: Options.integer("limit").pipe(Options.withDefault(12)), |
| 1350 | baseUrl: Options.string("base-url").pipe(Options.withDefault(DEFAULT_BASE_URL)), | 1780 | baseUrl: serverBaseUrl, |
| | 1781 | server: serverProfile, |
| 1351 | scope, | 1782 | scope, |
| 1352 | }, | 1783 | }, |
| 1353 | ({ query, namespace, limit, baseUrl, scope }) => | 1784 | ({ query, namespace, limit, baseUrl, server, scope }) => |
| 1354 | Effect.gen(function* () { | 1785 | Effect.gen(function* () { |
| 1355 | applyScope(scope); | 1786 | applyScope(scope); |
| | 1787 | const target = serverTargetFromOptions({ baseUrl, server }); |
| 1356 | const code = buildSearchToolsCode({ | 1788 | const code = buildSearchToolsCode({ |
| 1357 | query, | 1789 | query, |
| 1358 | namespace: Option.getOrUndefined(namespace), | 1790 | namespace: Option.getOrUndefined(namespace), |
| 1359 | limit, | 1791 | limit, |
| 1360 | }); | 1792 | }); |
| 1361 | | 1793 | |
| 1362 | const outcome = yield* executeCode({ baseUrl, code }); | 1794 | const result = yield* executeCode({ target, code }); |
| 1363 | yield* printExecutionOutcome({ baseUrl, outcome }); | 1795 | yield* printExecutionOutcome({ |
| | 1796 | target, |
| | 1797 | connection: result.connection, |
| | 1798 | outcome: result.outcome, |
| | 1799 | }); |
| 1364 | }), | 1800 | }), |
| 1365 | ).pipe(Command.withDescription("Search tools by natural-language query")); | 1801 | ).pipe(Command.withDescription("Search tools by natural-language query")); |
| 1366 | | 1802 | |
| @@ -1369,19 +1805,25 @@ const toolsSourcesCommand = Command.make( · 2 unmodified lines |
| 1369 | { | 1805 | { |
| 1370 | query: Options.string("query").pipe(Options.optional), | 1806 | query: Options.string("query").pipe(Options.optional), |
| 1371 | limit: Options.integer("limit").pipe(Options.withDefault(50)), | 1807 | limit: Options.integer("limit").pipe(Options.withDefault(50)), |
| 1372 | baseUrl: Options.string("base-url").pipe(Options.withDefault(DEFAULT_BASE_URL)), | 1808 | baseUrl: serverBaseUrl, |
| | 1809 | server: serverProfile, |
| 1373 | scope, | 1810 | scope, |
| 1374 | }, | 1811 | }, |
| 1375 | ({ query, limit, baseUrl, scope }) => | 1812 | ({ query, limit, baseUrl, server, scope }) => |
| 1376 | Effect.gen(function* () { | 1813 | Effect.gen(function* () { |
| 1377 | applyScope(scope); | 1814 | applyScope(scope); |
| | 1815 | const target = serverTargetFromOptions({ baseUrl, server }); |
| 1378 | const code = buildListSourcesCode({ | 1816 | const code = buildListSourcesCode({ |
| 1379 | query: Option.getOrUndefined(query), | 1817 | query: Option.getOrUndefined(query), |
| 1380 | limit, | 1818 | limit, |
| 1381 | }); | 1819 | }); |
| 1382 | | 1820 | |
| 1383 | const outcome = yield* executeCode({ baseUrl, code }); | 1821 | const result = yield* executeCode({ target, code }); |
| 1384 | yield* printExecutionOutcome({ baseUrl, outcome }); | 1822 | yield* printExecutionOutcome({ |
| | 1823 | target, |
| | 1824 | connection: result.connection, |
| | 1825 | outcome: result.outcome, |
| | 1826 | }); |
| 1385 | }), | 1827 | }), |
| 1386 | ).pipe(Command.withDescription("List configured sources and tool counts")); | 1828 | ).pipe(Command.withDescription("List configured sources and tool counts")); |
| 1387 | | 1829 | |
| @@ -1389,15 +1831,21 @@ const toolsDescribeCommand = Command.make( · 1 unmodified line |
| 1389 | "describe", | 1831 | "describe", |
| 1390 | { | 1832 | { |
| 1391 | path: Args.string("path"), | 1833 | path: Args.string("path"), |
| 1392 | baseUrl: Options.string("base-url").pipe(Options.withDefault(DEFAULT_BASE_URL)), | 1834 | baseUrl: serverBaseUrl, |
| | 1835 | server: serverProfile, |
| 1393 | scope, | 1836 | scope, |
| 1394 | }, | 1837 | }, |
| 1395 | ({ path, baseUrl, scope }) => | 1838 | ({ path, baseUrl, server, scope }) => |
| 1396 | Effect.gen(function* () { | 1839 | Effect.gen(function* () { |
| 1397 | applyScope(scope); | 1840 | applyScope(scope); |
| | 1841 | const target = serverTargetFromOptions({ baseUrl, server }); |
| 1398 | const code = buildDescribeToolCode(path); | 1842 | const code = buildDescribeToolCode(path); |
| 1399 | const outcome = yield* executeCode({ baseUrl, code }); | 1843 | const result = yield* executeCode({ target, code }); |
| 1400 | yield* printExecutionOutcome({ baseUrl, outcome }); | 1844 | yield* printExecutionOutcome({ |
| | 1845 | target, |
| | 1846 | connection: result.connection, |
| | 1847 | outcome: result.outcome, |
| | 1848 | }); |
| 1401 | }), | 1849 | }), |
| 1402 | ).pipe(Command.withDescription("Describe a tool's TypeScript and JSON schema")); | 1850 | ).pipe(Command.withDescription("Describe a tool's TypeScript and JSON schema")); |
| 1403 | | 1851 | |
| @@ -1406,6 +1854,131 @@ const toolsCommand = Command.make("tools").pipe( · 2 unmodified lines |
| 1406 | Command.withDescription("Discover available tools and sources"), | 1854 | Command.withDescription("Discover available tools and sources"), |
| 1407 | ); | 1855 | ); |
| 1408 | | 1856 | |
| | 1857 | const profileConnectionInput = (input: { |
| | 1858 | readonly origin: string; |
| | 1859 | readonly displayName: Option.Option<string>; |
| | 1860 | readonly kind: Option.Option<"http" | "desktop-sidecar">; |
| | 1861 | }): ExecutorServerConnectionInput => { |
| | 1862 | const selectedKind = Option.getOrUndefined(input.kind); |
| | 1863 | const displayName = Option.getOrUndefined(input.displayName); |
| | 1864 | return { |
| | 1865 | kind: selectedKind ?? "http", |
| | 1866 | origin: input.origin, |
| | 1867 | ...(displayName ? { displayName } : {}), |
| | 1868 | }; |
| | 1869 | }; |
| | 1870 | |
| | 1871 | const printServerProfiles = () => |
| | 1872 | Effect.gen(function* () { |
| | 1873 | const store = yield* readCliServerConnectionStore(); |
| | 1874 | if (store.profiles.length === 0) { |
| | 1875 | console.log("No server profiles configured."); |
| | 1876 | console.log(`Add one: ${cliPrefix} server add local ${DEFAULT_BASE_URL} --default`); |
| | 1877 | return; |
| | 1878 | } |
| | 1879 | |
| | 1880 | const rows = store.profiles.map((profile) => ({ |
| | 1881 | marker: profile.name === store.defaultProfile ? "*" : " ", |
| | 1882 | name: profile.name, |
| | 1883 | kind: profile.connection.kind, |
| | 1884 | origin: profile.connection.origin, |
| | 1885 | displayName: profile.connection.displayName, |
| | 1886 | auth: profile.connection.auth ? "stored-auth" : "env-auth", |
| | 1887 | })); |
| | 1888 | const nameWidth = rows.reduce((max, row) => Math.max(max, row.name.length), 4); |
| | 1889 | const kindWidth = rows.reduce((max, row) => Math.max(max, row.kind.length), 4); |
| | 1890 | |
| | 1891 | for (const row of rows) { |
| | 1892 | console.log( |
| | 1893 | `${row.marker} ${row.name.padEnd(nameWidth)} ${row.kind.padEnd(kindWidth)} ${row.origin} ${row.displayName} ${row.auth}`, |
| | 1894 | ); |
| | 1895 | } |
| | 1896 | }); |
| | 1897 | |
| | 1898 | const serverAddCommand = Command.make( |
| | 1899 | "add", |
| | 1900 | { |
| | 1901 | name: Args.string("name"), |
| | 1902 | origin: Args.string("origin"), |
| | 1903 | displayName: Options.string("display-name").pipe( |
| | 1904 | Options.optional, |
| | 1905 | Options.withDescription("Display label for this server profile."), |
| | 1906 | ), |
| | 1907 | kind: Options.choice("kind", ["http", "desktop-sidecar"] as const).pipe( |
| | 1908 | Options.optional, |
| | 1909 | Options.withDescription("Server kind. Defaults to http."), |
| | 1910 | ), |
| | 1911 | makeDefault: Options.boolean("default").pipe( |
| | 1912 | Options.withDefault(false), |
| | 1913 | Options.withDescription("Make this profile the default server."), |
| | 1914 | ), |
| | 1915 | }, |
| | 1916 | ({ name, origin, displayName, kind, makeDefault }) => |
| | 1917 | Effect.gen(function* () { |
| | 1918 | const profileName = validateCliServerConnectionProfileName(name); |
| | 1919 | const store = yield* upsertCliServerConnectionProfile({ |
| | 1920 | name: profileName, |
| | 1921 | connection: profileConnectionInput({ origin, displayName, kind }), |
| | 1922 | makeDefault, |
| | 1923 | }); |
| | 1924 | const profile = findCliServerConnectionProfile(store, profileName); |
| | 1925 | if (!profile) return yield* Effect.fail(new Error(`Failed to save "${profileName}".`)); |
| | 1926 | console.log(`Saved server profile "${profile.name}" (${profile.connection.origin}).`); |
| | 1927 | if (store.defaultProfile === profile.name) { |
| | 1928 | console.log(`Default server profile: ${profile.name}`); |
| | 1929 | } |
| | 1930 | }), |
| | 1931 | ).pipe(Command.withDescription("Add or update a named Executor server profile")); |
| | 1932 | |
| | 1933 | const serverListCommand = Command.make("list", {}, () => printServerProfiles()).pipe( |
| | 1934 | Command.withDescription("List configured Executor server profiles"), |
| | 1935 | ); |
| | 1936 | |
| | 1937 | const serverUseCommand = Command.make( |
| | 1938 | "use", |
| | 1939 | { |
| | 1940 | name: Args.string("name"), |
| | 1941 | }, |
| | 1942 | ({ name }) => |
| | 1943 | Effect.gen(function* () { |
| | 1944 | const store = yield* setDefaultCliServerConnectionProfile(name); |
| | 1945 | const profile = defaultCliServerConnectionProfile(store); |
| | 1946 | if (!profile) return yield* Effect.fail(new Error(`No server profile named "${name}".`)); |
| | 1947 | console.log(`Default server profile: ${profile.name} (${profile.connection.origin}).`); |
| | 1948 | }), |
| | 1949 | ).pipe(Command.withDescription("Set the default Executor server profile")); |
| | 1950 | |
| | 1951 | const serverRemoveCommand = Command.make( |
| | 1952 | "remove", |
| | 1953 | { |
| | 1954 | name: Args.string("name"), |
| | 1955 | }, |
| | 1956 | ({ name }) => |
| | 1957 | Effect.gen(function* () { |
| | 1958 | const profileName = validateCliServerConnectionProfileName(name); |
| | 1959 | const store = yield* readCliServerConnectionStore(); |
| | 1960 | const profile = findCliServerConnectionProfile(store, profileName); |
| | 1961 | if (!profile) { |
| | 1962 | return yield* Effect.fail(new Error(`No server profile named "${profileName}".`)); |
| | 1963 | } |
| | 1964 | const nextStore = yield* removeCliServerConnectionProfile(profileName); |
| | 1965 | console.log(`Removed server profile "${profileName}".`); |
| | 1966 | if (nextStore.defaultProfile === null) { |
| | 1967 | console.log("No default server profile is configured."); |
| | 1968 | } |
| | 1969 | }), |
| | 1970 | ).pipe(Command.withDescription("Remove an Executor server profile")); |
| | 1971 | |
| | 1972 | const serverCommand = Command.make("server").pipe( |
| | 1973 | Command.withSubcommands([ |
| | 1974 | serverAddCommand, |
| | 1975 | serverListCommand, |
| | 1976 | serverUseCommand, |
| | 1977 | serverRemoveCommand, |
| | 1978 | ] as const), |
| | 1979 | Command.withDescription("Manage named Executor server profiles"), |
| | 1980 | ); |
| | 1981 | |
| 1409 | const webCommand = Command.make( | 1982 | const webCommand = Command.make( |
| 1410 | "web", | 1983 | "web", |
| 1411 | { | 1984 | { |
| @@ -1490,7 +2063,7 @@ const daemonRunCommand = Command.make( · 78 unmodified lines |
| 1490 | const daemonStatusCommand = Command.make( | 2063 | const daemonStatusCommand = Command.make( |
| 1491 | "status", | 2064 | "status", |
| 1492 | { | 2065 | { |
| 1493 | baseUrl: Options.string("base-url").pipe(Options.withDefault(DEFAULT_BASE_URL)), | 2066 | baseUrl: daemonBaseUrlOption, |
| 1494 | }, | 2067 | }, |
| 1495 | ({ baseUrl }) => | 2068 | ({ baseUrl }) => |
| 1496 | Effect.gen(function* () { | 2069 | Effect.gen(function* () { |
| @@ -1542,7 +2115,7 @@ const daemonStatusCommand = Command.make( · 45 unmodified lines |
| 1542 | const daemonStopCommand = Command.make( | 2115 | const daemonStopCommand = Command.make( |
| 1543 | "stop", | 2116 | "stop", |
| 1544 | { | 2117 | { |
| 1545 | baseUrl: Options.string("base-url").pipe(Options.withDefault(DEFAULT_BASE_URL)), | 2118 | baseUrl: daemonBaseUrlOption, |
| 1546 | }, | 2119 | }, |
| 1547 | ({ baseUrl }) => stopDaemon(baseUrl), | 2120 | ({ baseUrl }) => stopDaemon(baseUrl), |
| 1548 | ).pipe(Command.withDescription("Stop the local daemon")); | 2121 | ).pipe(Command.withDescription("Stop the local daemon")); |
| @@ -1550,7 +2123,7 @@ const daemonStopCommand = Command.make( · 1 unmodified line |
| 1550 | const daemonRestartCommand = Command.make( | 2123 | const daemonRestartCommand = Command.make( |
| 1551 | "restart", | 2124 | "restart", |
| 1552 | { | 2125 | { |
| 1553 | baseUrl: Options.string("base-url").pipe(Options.withDefault(DEFAULT_BASE_URL)), | 2126 | baseUrl: daemonBaseUrlOption, |
| 1554 | scope, | 2127 | scope, |
| 1555 | }, | 2128 | }, |
| 1556 | ({ baseUrl, scope }) => | 2129 | ({ baseUrl, scope }) => |
| @@ -1600,6 +2173,7 @@ const root = Command.make("executor").pipe( · 43 unmodified lines |
| 1600 | callCommand, | 2173 | callCommand, |
| 1601 | resumeCommand, | 2174 | resumeCommand, |
| 1602 | toolsCommand, | 2175 | toolsCommand, |
| | 2176 | serverCommand, |
| 1603 | webCommand, | 2177 | webCommand, |
| 1604 | daemonCommand, | 2178 | daemonCommand, |
| 1605 | mcpCommand, | 2179 | mcpCommand, |