History

Teach CLI and local server shared connections (#865)

* Teach CLI and local server shared connections * Use schemas for CLI server profile parsing

Rhys Sullivan committed 5 months ago

Commit 10b7eda1 parent 1b5618611 files · +1685 −146Browse files

Unified · Split

11 files
  1. apps/cli/package.json +5 −1
  2. apps/cli/src/local-server-manifest.test.ts +100 −0
  3. apps/cli/src/local-server-manifest.ts +130 −0
  4. apps/cli/src/main.ts +705 −131
  5. apps/cli/src/server-connection.test.ts +152 −0
  6. apps/cli/src/server-connection.ts +111 −0
  7. apps/cli/src/server-profile.test.ts +131 −0
  8. apps/cli/src/server-profile.ts +217 −0
  9. apps/local/src/serve.test.ts +74 −0
  10. apps/local/src/serve.ts +55 −14
  11. bun.lock +5 −0
apps/cli/package.json+5−1
@@ -18,6 +18,7 @@ · 17 unmodified lines
18 "build:publish": "bun run src/build.ts publish",18 "build:publish": "bun run src/build.ts publish",
19 "release:publish:dry-run": "bun run src/release.ts --dry-run",19 "release:publish:dry-run": "bun run src/release.ts --dry-run",
20 "release:publish": "bun run src/release.ts",20 "release:publish": "bun run src/release.ts",
21 "test": "bun --bun vitest run",
21 "typecheck": "tsgo --noEmit",22 "typecheck": "tsgo --noEmit",
22 "typecheck:slow": "tsc --noEmit"23 "typecheck:slow": "tsc --noEmit"
23 },24 },
@@ -27,12 +28,15 @@ · 3 unmodified lines
27 "@executor-js/integrations-registry": "workspace:*",28 "@executor-js/integrations-registry": "workspace:*",
28 "@executor-js/local": "workspace:*",29 "@executor-js/local": "workspace:*",
29 "@executor-js/runtime-quickjs": "workspace:*",30 "@executor-js/runtime-quickjs": "workspace:*",
31 "@executor-js/sdk": "workspace:*",
30 "@jitl/quickjs-wasmfile-release-sync": "catalog:",32 "@jitl/quickjs-wasmfile-release-sync": "catalog:",
31 "effect": "catalog:",33 "effect": "catalog:",
32 "quickjs-emscripten": "catalog:"34 "quickjs-emscripten": "catalog:"
33 },35 },
34 "devDependencies": {36 "devDependencies": {
37 "@effect/vitest": "catalog:",
35 "bun-types": "catalog:",38 "bun-types": "catalog:",
36 "typescript": "catalog:"39 "typescript": "catalog:",
40 "vitest": "catalog:"
37 }41 }
38}42}
apps/cli/src/local-server-manifest.test.ts+100−0
@@ -0,0 +1,100 @@
1import { afterEach, describe, expect, it } from "@effect/vitest";
2import { BunServices } from "@effect/platform-bun";
3import { mkdtempSync, rmSync } from "node:fs";
4import { tmpdir } from "node:os";
5import { join } from "node:path";
6import { Path } from "effect";
7import * as Effect from "effect/Effect";
8import * as Exit from "effect/Exit";
9
10import { normalizeExecutorServerConnection } from "@executor-js/sdk/shared";
11import {
12 acquireLocalServerStartLock,
13 readLocalServerManifest,
14 releaseLocalServerStartLock,
15 removeLocalServerManifestIfOwnedBy,
16 resolveExecutorDataDir,
17 writeLocalServerManifest,
18} from "./local-server-manifest";
19
20const previousDataDir = process.env.EXECUTOR_DATA_DIR;
21
22afterEach(() => {
23 if (previousDataDir === undefined) {
24 delete process.env.EXECUTOR_DATA_DIR;
25 } else {
26 process.env.EXECUTOR_DATA_DIR = previousDataDir;
27 }
28});
29
30describe("local server manifest", () => {
31 it.effect("round-trips the active local server owner", () =>
32 Effect.gen(function* () {
33 const dataDir = mkdtempSync(join(tmpdir(), "executor-local-server-"));
34 process.env.EXECUTOR_DATA_DIR = dataDir;
35
36 try {
37 const manifest = {
38 version: 1 as const,
39 kind: "cli-daemon" as const,
40 pid: process.pid,
41 startedAt: "2026-05-28T00:00:00.000Z",
42 dataDir,
43 scopeDir: dataDir,
44 connection: normalizeExecutorServerConnection({
45 origin: "http://localhost:4788",
46 }),
47 owner: {
48 client: "cli" as const,
49 version: "1.2.3",
50 executablePath: "/usr/local/bin/executor",
51 },
52 };
53
54 yield* writeLocalServerManifest(manifest);
55 expect((yield* readLocalServerManifest())?.connection.origin).toBe("http://localhost:4788");
56
57 yield* removeLocalServerManifestIfOwnedBy({ pid: process.pid + 1 });
58 expect(yield* readLocalServerManifest()).not.toBeNull();
59
60 yield* removeLocalServerManifestIfOwnedBy({ pid: process.pid });
61 expect(yield* readLocalServerManifest()).toBeNull();
62 } finally {
63 rmSync(dataDir, { recursive: true, force: true });
64 }
65 }).pipe(Effect.provide(BunServices.layer)),
66 );
67
68 it.effect("serializes local startup with a stale-aware lock", () =>
69 Effect.gen(function* () {
70 const dataDir = mkdtempSync(join(tmpdir(), "executor-local-server-lock-"));
71 process.env.EXECUTOR_DATA_DIR = dataDir;
72
73 try {
74 const first = yield* acquireLocalServerStartLock();
75 const second = yield* Effect.exit(acquireLocalServerStartLock());
76
77 expect(Exit.isFailure(second)).toBe(true);
78 yield* releaseLocalServerStartLock(first);
79 const third = yield* acquireLocalServerStartLock();
80 yield* releaseLocalServerStartLock(third);
81 } finally {
82 rmSync(dataDir, { recursive: true, force: true });
83 }
84 }).pipe(Effect.provide(BunServices.layer)),
85 );
86
87 it.effect("resolves the data dir from EXECUTOR_DATA_DIR", () =>
88 Effect.gen(function* () {
89 const dataDir = mkdtempSync(join(tmpdir(), "executor-local-server-dir-"));
90 process.env.EXECUTOR_DATA_DIR = dataDir;
91
92 try {
93 const path = yield* Path.Path;
94 expect(resolveExecutorDataDir(path)).toBe(dataDir);
95 } finally {
96 rmSync(dataDir, { recursive: true, force: true });
97 }
98 }).pipe(Effect.provide(BunServices.layer)),
99 );
100});
apps/cli/src/local-server-manifest.ts+130−0
@@ -0,0 +1,130 @@
1import { homedir } from "node:os";
2import { resolve } from "node:path";
3import { FileSystem, Option, Path, Schema } from "effect";
4import type { PlatformError } from "effect/PlatformError";
5import * as Effect from "effect/Effect";
6
7import {
8 parseExecutorLocalServerManifest,
9 serializeExecutorLocalServerManifest,
10 type ExecutorLocalServerManifest,
11} from "@executor-js/sdk/shared";
12import { isPidAlive } from "./daemon-state";
13
14export interface LocalServerStartLock {
15 readonly path: string;
16}
17
18export const resolveExecutorDataDir = (path: Path.Path): string =>
19 resolve(process.env.EXECUTOR_DATA_DIR ?? path.join(homedir(), ".executor"));
20
21const serverControlDir = (path: Path.Path): string =>
22 path.join(resolveExecutorDataDir(path), "server-control");
23
24const localServerManifestPath = (path: Path.Path): string =>
25 path.join(serverControlDir(path), "server.json");
26
27const localServerStartLockPath = (path: Path.Path): string =>
28 path.join(serverControlDir(path), "startup.lock");
29
30export const readLocalServerManifest = (): Effect.Effect<
31 ExecutorLocalServerManifest | null,
32 never,
33 FileSystem.FileSystem | Path.Path
34> =>
35 Effect.gen(function* () {
36 const fs = yield* FileSystem.FileSystem;
37 const path = yield* Path.Path;
38 const raw = yield* fs
39 .readFileString(localServerManifestPath(path))
40 .pipe(Effect.catchCause(() => Effect.succeed(null)));
41 if (raw === null) return null;
42 return parseExecutorLocalServerManifest(raw);
43 });
44
45export const writeLocalServerManifest = (
46 manifest: ExecutorLocalServerManifest,
47): Effect.Effect<void, PlatformError, FileSystem.FileSystem | Path.Path> =>
48 Effect.gen(function* () {
49 const fs = yield* FileSystem.FileSystem;
50 const path = yield* Path.Path;
51 yield* fs.makeDirectory(serverControlDir(path), { recursive: true });
52 yield* fs.writeFileString(
53 localServerManifestPath(path),
54 serializeExecutorLocalServerManifest(manifest),
55 );
56 });
57
58export const removeLocalServerManifestIfOwnedBy = (input: {
59 readonly pid: number;
60}): Effect.Effect<void, PlatformError, FileSystem.FileSystem | Path.Path> =>
61 Effect.gen(function* () {
62 const fs = yield* FileSystem.FileSystem;
63 const path = yield* Path.Path;
64 const manifestPath = localServerManifestPath(path);
65 const raw = yield* fs
66 .readFileString(manifestPath)
67 .pipe(Effect.catchCause(() => Effect.succeed(null)));
68 if (raw === null) return;
69 const manifest = parseExecutorLocalServerManifest(raw);
70 if (manifest?.pid !== input.pid) return;
71 yield* fs.remove(manifestPath, { force: true });
72 });
73
74const StartupLockPayload = Schema.Struct({
75 pid: Schema.Number,
76});
77
78const decodeStartupLockPayload = Schema.decodeUnknownOption(
79 Schema.fromJsonString(StartupLockPayload),
80);
81
82const parseLockPid = (raw: string): number | null => {
83 const decoded = decodeStartupLockPayload(raw);
84 return Option.isSome(decoded) ? decoded.value.pid : null;
85};
86
87export const acquireLocalServerStartLock = (): Effect.Effect<
88 LocalServerStartLock,
89 Error,
90 FileSystem.FileSystem | Path.Path
91> =>
92 Effect.gen(function* () {
93 const fs = yield* FileSystem.FileSystem;
94 const path = yield* Path.Path;
95 yield* fs.makeDirectory(serverControlDir(path), { recursive: true });
96
97 const lockPath = localServerStartLockPath(path);
98 const lockPayload = `${JSON.stringify({ pid: process.pid, startedAt: new Date().toISOString() }, null, 2)}\n`;
99
100 const tryAcquire = () =>
101 fs.writeFileString(lockPath, lockPayload, { flag: "wx" }).pipe(
102 Effect.as(true),
103 Effect.catchCause(() => Effect.succeed(false)),
104 );
105
106 if (yield* tryAcquire()) return { path: lockPath };
107
108 const existingRaw = yield* fs
109 .readFileString(lockPath)
110 .pipe(Effect.catchCause(() => Effect.succeed(null)));
111 if (existingRaw !== null) {
112 const existingPid = parseLockPid(existingRaw);
113 if (existingPid !== null && !isPidAlive(existingPid)) {
114 yield* fs.remove(lockPath, { force: true });
115 if (yield* tryAcquire()) return { path: lockPath };
116 }
117 }
118
119 return yield* Effect.fail(
120 new Error("Another local Executor server startup is already in progress."),
121 );
122 });
123
124export const releaseLocalServerStartLock = (
125 lock: LocalServerStartLock,
126): Effect.Effect<void, PlatformError, FileSystem.FileSystem | Path.Path> =>
127 Effect.gen(function* () {
128 const fs = yield* FileSystem.FileSystem;
129 yield* fs.remove(lock.path, { force: true });
130 });
apps/cli/src/main.ts+705−131
@@ -52,13 +52,23 @@ if (typeof Bun !== "undefined" && (await Bun.file(wasmOnDisk).exists())) { · 51 unmodified lines
52import { Argument as Args, Command, Flag as Options } from "effect/unstable/cli";52import { Argument as Args, Command, Flag as Options } from "effect/unstable/cli";
53import { BunRuntime, BunServices } from "@effect/platform-bun";53import { BunRuntime, BunServices } from "@effect/platform-bun";
54import { HttpApiClient } from "effect/unstable/httpapi";54import { HttpApiClient } from "effect/unstable/httpapi";
55import { FetchHttpClient } from "effect/unstable/http";55import { FetchHttpClient, HttpClient, HttpClientRequest } from "effect/unstable/http";
56import { FileSystem, Path as PlatformPath } from "effect";56import { FileSystem, Path as PlatformPath } from "effect";
57import type { PlatformError } from "effect/PlatformError";
57import * as Effect from "effect/Effect";58import * as Effect from "effect/Effect";
58import * as Option from "effect/Option";59import * as Option from "effect/Option";
59import * as Cause from "effect/Cause";60import * as Cause from "effect/Cause";
60 61
61import { ExecutorApi } from "@executor-js/api";62import { ExecutorApi } from "@executor-js/api";
63import {
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";
62import { startServer, runMcpStdioServer, getExecutor } from "@executor-js/local";72import { startServer, runMcpStdioServer, getExecutor } from "@executor-js/local";
63import { makeQuickJsExecutor } from "@executor-js/runtime-quickjs";73import { makeQuickJsExecutor } from "@executor-js/runtime-quickjs";
64import { fetchIntegrations } from "./integrations";74import { 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";
99import {
100 canAutoStartCliServerConnection,
101 chooseCliServerConnectionWithActiveLocal,
102 parseCliExecutorServerConnection,
103 type CliServerConnectionSource,
104 withCliServerAuthFallback,
105} from "./server-connection";
106import {
107 acquireLocalServerStartLock,
108 readLocalServerManifest,
109 releaseLocalServerStartLock,
110 removeLocalServerManifestIfOwnedBy,
111 resolveExecutorDataDir,
112 writeLocalServerManifest,
113} from "./local-server-manifest";
114import {
115 defaultCliServerConnectionProfile,
116 findCliServerConnectionProfile,
117 readCliServerConnectionStore,
118 removeCliServerConnectionProfile,
119 setDefaultCliServerConnectionProfile,
120 upsertCliServerConnectionProfile,
121 validateCliServerConnectionProfileName,
122} from "./server-profile";
89import {123import {
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
149const readDaemonScopeInfo = (baseUrl: string): Effect.Effect<DaemonScopeInfo | null> =>183const 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
177const isServerReachable = (baseUrl: string): Effect.Effect<boolean> =>217const 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
220const 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
180const normalizeDaemonScopeDir = (dir: string): string => {250const 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
185const currentDaemonScopeDir = (): string =>255const currentDaemonScopeDir = (): string =>
186 normalizeDaemonScopeDir(process.env.EXECUTOR_SCOPE_DIR ?? process.cwd());256 normalizeDaemonScopeDir(process.env.EXECUTOR_SCOPE_DIR ?? process.cwd());
187 257
258const currentScopeDirForManifest = (): string | null =>
259 process.env.EXECUTOR_SCOPE_DIR ? normalizeDaemonScopeDir(process.env.EXECUTOR_SCOPE_DIR) : null;
260
188const script = process.argv[1];261const script = process.argv[1];
189const isDevMode = isDevCliEntrypoint(script);262const isDevMode = isDevCliEntrypoint(script);
190const cliPrefix = isDevMode ? `bun run ${script}` : "executor";263const cliPrefix = isDevMode ? `bun run ${script}` : "executor";
@@ -192,6 +265,21 @@ const cliPrefix = isDevMode ? `bun run ${script}` : "executor"; · 1 unmodified line
192const toError = (cause: unknown): Error =>265const toError = (cause: unknown): Error =>
193 cause instanceof Error ? cause : new Error(String(cause));266 cause instanceof Error ? cause : new Error(String(cause));
194 267
268interface ServerTarget {
269 readonly baseUrl?: string;
270 readonly serverName?: string;
271}
272
273interface RequestedExecutorServerConnection {
274 readonly connection: ExecutorServerConnection;
275 readonly source: CliServerConnectionSource;
276}
277
278interface ExecuteCodeResult {
279 readonly connection: ExecutorServerConnection;
280 readonly outcome: ExecuteCodeOutcome;
281}
282
195const parseDaemonUrl = (baseUrl: string) =>283const 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
290const 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
202const daemonBaseUrl = (hostname: string, port: number): string =>297const daemonBaseUrl = (hostname: string, port: number): string =>
203 `http://${canonicalDaemonHost(hostname)}:${port}`;298 `http://${canonicalDaemonHost(hostname)}:${port}`;
204 299
300const 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
315const 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
337const 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
356const 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
205const installDefaultExecutorWebBaseUrl = (baseUrl: string): (() => void) => {365const 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
546const 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
587const 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
369const stopDaemon = (639const 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
465const executeCode = (input: {736const 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
499const printExecutionOutcome = (input: { baseUrl: string; outcome: ExecuteCodeOutcome }) =>778const 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
786const 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 client828// Typed API client
538// ---------------------------------------------------------------------------829// ---------------------------------------------------------------------------
539 830
540const makeApiClient = (baseUrl: string) =>831const 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 session846// 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
749const runStdioMcpSession = (input: { readonly elicitationMode: "browser" | "model" }) =>1101const 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
1182const 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
1189const serverProfile = Options.string("server").pipe(
1190 Options.optional,
1191 Options.withDescription("Named Executor server profile."),
1192);
1193
1194const daemonBaseUrlOption = Options.string("base-url").pipe(
1195 Options.withDefault(DEFAULT_BASE_URL),
1196 Options.withDescription("Local daemon origin."),
1197);
1198
1199const 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
802const applyScope = (s: Option.Option<string>) => {1207const 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
867interface ParsedCallHelpArgs {1272interface 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
877const isHelpFlag = (value: string): boolean => HELP_FLAGS.has(value);1283const isHelpFlag = (value: string): boolean => HELP_FLAGS.has(value);
878 1284
879const parseCallHelpArgs = (args: ReadonlyArray<string>): ParsedCallHelpArgs => {1285const 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
964const printCallBrowseHelp = (input: {1383const 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
1857const 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
1871const 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
1898const 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
1933const serverListCommand = Command.make("list", {}, () => printServerProfiles()).pipe(
1934 Command.withDescription("List configured Executor server profiles"),
1935);
1936
1937const 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
1951const 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
1972const 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
1409const webCommand = Command.make(1982const webCommand = Command.make(
1410 "web",1983 "web",
1411 {1984 {
@@ -1490,7 +2063,7 @@ const daemonRunCommand = Command.make( · 78 unmodified lines
1490const daemonStatusCommand = Command.make(2063const 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
1542const daemonStopCommand = Command.make(2115const 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
1550const daemonRestartCommand = Command.make(2123const 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,
apps/cli/src/server-connection.test.ts+152−0
@@ -0,0 +1,152 @@
1import { describe, expect, it } from "@effect/vitest";
2
3import {
4 canAutoStartCliServerConnection,
5 chooseCliServerConnectionWithActiveLocal,
6 parseCliExecutorServerConnection,
7 withCliServerAuthFallback,
8} from "./server-connection";
9
10describe("CLI server connection", () => {
11 it("treats localhost HTTP servers as auto-startable daemon targets", () => {
12 const connection = parseCliExecutorServerConnection("localhost:4788", {});
13
14 expect(connection.origin).toBe("http://localhost:4788");
15 expect(connection.apiBaseUrl).toBe("http://localhost:4788/api");
16 expect(canAutoStartCliServerConnection(connection)).toBe(true);
17 });
18
19 it("treats hosted HTTPS servers as explicit server connections", () => {
20 const connection = parseCliExecutorServerConnection("https://executor.example/api", {
21 EXECUTOR_API_KEY: "key_123",
22 });
23
24 expect(connection.origin).toBe("https://executor.example");
25 expect(connection.apiBaseUrl).toBe("https://executor.example/api");
26 expect(connection.auth).toEqual({ kind: "bearer", token: "key_123" });
27 expect(canAutoStartCliServerConnection(connection)).toBe(false);
28 });
29
30 it("adds environment auth only when a profile did not carry auth", () => {
31 const fromProfile = parseCliExecutorServerConnection("https://executor.example", {});
32 expect(withCliServerAuthFallback(fromProfile, { EXECUTOR_API_KEY: "key_123" }).auth).toEqual({
33 kind: "bearer",
34 token: "key_123",
35 });
36
37 const storedAuth = parseCliExecutorServerConnection("https://executor.example", {
38 EXECUTOR_API_KEY: "stored",
39 });
40 expect(withCliServerAuthFallback(storedAuth, { EXECUTOR_API_KEY: "env" }).auth).toEqual({
41 kind: "bearer",
42 token: "stored",
43 });
44 });
45
46 it("supports desktop-style basic auth for local server connections", () => {
47 const connection = parseCliExecutorServerConnection("http://127.0.0.1:4789", {
48 EXECUTOR_AUTH_PASSWORD: "desktop-password",
49 });
50
51 expect(connection.auth).toEqual({
52 kind: "basic",
53 username: "executor",
54 password: "desktop-password",
55 });
56 expect(canAutoStartCliServerConnection(connection)).toBe(false);
57 });
58
59 it("never auto-starts desktop sidecar profiles", () => {
60 const connection = {
61 ...parseCliExecutorServerConnection("http://127.0.0.1:4789", {}),
62 kind: "desktop-sidecar" as const,
63 };
64
65 expect(canAutoStartCliServerConnection(connection)).toBe(false);
66 });
67
68 it("prefers bearer auth for hosted connections and basic auth for local connections", () => {
69 const hosted = parseCliExecutorServerConnection("https://executor.example", {
70 EXECUTOR_API_KEY: "key_123",
71 EXECUTOR_AUTH_PASSWORD: "desktop-password",
72 });
73 expect(hosted.auth).toEqual({ kind: "bearer", token: "key_123" });
74
75 const local = parseCliExecutorServerConnection("http://127.0.0.1:4789", {
76 EXECUTOR_API_KEY: "key_123",
77 EXECUTOR_AUTH_PASSWORD: "desktop-password",
78 EXECUTOR_AUTH_USERNAME: "rhys",
79 });
80 expect(local.auth).toEqual({
81 kind: "basic",
82 username: "rhys",
83 password: "desktop-password",
84 });
85 });
86
87 it("attaches implicit local requests to the active local owner", () => {
88 const requested = parseCliExecutorServerConnection("http://localhost:4788", {});
89 const active = {
90 version: 1 as const,
91 kind: "desktop-sidecar" as const,
92 pid: process.pid,
93 startedAt: "2026-05-28T00:00:00.000Z",
94 dataDir: "/tmp/executor",
95 scopeDir: "/tmp/executor",
96 connection: parseCliExecutorServerConnection("http://127.0.0.1:4789", {
97 EXECUTOR_AUTH_PASSWORD: "desktop-password",
98 }),
99 owner: {
100 client: "desktop" as const,
101 version: "1.2.3",
102 executablePath: "/Applications/Executor.app/Contents/MacOS/Executor",
103 },
104 };
105
106 const decision = chooseCliServerConnectionWithActiveLocal({
107 requested,
108 source: "active-local",
109 active,
110 });
111
112 expect(decision).toMatchObject({
113 kind: "use-active",
114 connection: {
115 origin: "http://127.0.0.1:4789",
116 auth: {
117 kind: "basic",
118 username: "executor",
119 password: "desktop-password",
120 },
121 },
122 });
123 });
124
125 it("blocks local auto-start when another local owner is active", () => {
126 const requested = parseCliExecutorServerConnection("http://localhost:4788", {});
127 const active = {
128 version: 1 as const,
129 kind: "desktop-sidecar" as const,
130 pid: process.pid,
131 startedAt: "2026-05-28T00:00:00.000Z",
132 dataDir: "/tmp/executor",
133 scopeDir: "/tmp/executor",
134 connection: parseCliExecutorServerConnection("http://127.0.0.1:4789", {
135 EXECUTOR_AUTH_PASSWORD: "desktop-password",
136 }),
137 owner: {
138 client: "desktop" as const,
139 version: "1.2.3",
140 executablePath: "/Applications/Executor.app/Contents/MacOS/Executor",
141 },
142 };
143
144 expect(
145 chooseCliServerConnectionWithActiveLocal({
146 requested,
147 source: "explicit",
148 active,
149 }).kind,
150 ).toBe("conflict");
151 });
152});
apps/cli/src/server-connection.ts+111−0
@@ -0,0 +1,111 @@
1import {
2 DEFAULT_EXECUTOR_SERVER_USERNAME,
3 normalizeExecutorServerConnection,
4 type ExecutorLocalServerManifest,
5 type ExecutorServerAuth,
6 type ExecutorServerConnection,
7} from "@executor-js/sdk/shared";
8import { canAutoStartLocalDaemonForHost } from "./daemon";
9
10const readCliBasicServerAuth = (
11 env: Record<string, string | undefined> = process.env,
12): ExecutorServerAuth | undefined => {
13 const password = env.EXECUTOR_AUTH_PASSWORD;
14 if (!password) return undefined;
15 return {
16 kind: "basic",
17 username: env.EXECUTOR_AUTH_USERNAME ?? DEFAULT_EXECUTOR_SERVER_USERNAME,
18 password,
19 };
20};
21
22const readCliBearerServerAuth = (
23 env: Record<string, string | undefined> = process.env,
24): ExecutorServerAuth | undefined => {
25 const token = env.EXECUTOR_API_KEY ?? env.EXECUTOR_AUTH_TOKEN;
26 if (!token) return undefined;
27 return { kind: "bearer", token };
28};
29
30export const readCliServerAuth = (
31 env: Record<string, string | undefined> = process.env,
32): ExecutorServerAuth | undefined => readCliBearerServerAuth(env) ?? readCliBasicServerAuth(env);
33
34const readCliServerAuthForConnection = (
35 connection: ExecutorServerConnection,
36 env: Record<string, string | undefined> = process.env,
37): ExecutorServerAuth | undefined => {
38 const bearer = readCliBearerServerAuth(env);
39 const basic = readCliBasicServerAuth(env);
40 const protocol = new URL(connection.origin).protocol;
41
42 if (protocol === "https:") {
43 return bearer ?? basic;
44 }
45
46 return basic ?? bearer;
47};
48
49export const parseCliExecutorServerConnection = (
50 baseUrl: string,
51 env: Record<string, string | undefined> = process.env,
52): ExecutorServerConnection => {
53 const connection = normalizeExecutorServerConnection({
54 origin: baseUrl,
55 });
56 return normalizeExecutorServerConnection({
57 ...connection,
58 auth: readCliServerAuthForConnection(connection, env),
59 });
60};
61
62export const withCliServerAuthFallback = (
63 connection: ExecutorServerConnection,
64 env: Record<string, string | undefined> = process.env,
65): ExecutorServerConnection =>
66 connection.auth
67 ? connection
68 : normalizeExecutorServerConnection({
69 ...connection,
70 auth: readCliServerAuthForConnection(connection, env),
71 });
72
73export const canAutoStartCliServerConnection = (connection: ExecutorServerConnection): boolean => {
74 if (connection.kind !== "http") return false;
75 if (connection.auth?.kind === "basic") return false;
76 const url = new URL(connection.origin);
77 return url.protocol === "http:" && canAutoStartLocalDaemonForHost(url.hostname);
78};
79
80export type CliServerConnectionSource =
81 | "explicit"
82 | "default-profile"
83 | "implicit-default"
84 | "active-local";
85
86export type ActiveLocalServerDecision =
87 | { readonly kind: "use-requested"; readonly connection: ExecutorServerConnection }
88 | { readonly kind: "use-active"; readonly connection: ExecutorServerConnection }
89 | { readonly kind: "conflict"; readonly active: ExecutorLocalServerManifest };
90
91const sameOrigin = (left: string, right: string): boolean =>
92 normalizeExecutorServerConnection({ origin: left }).origin ===
93 normalizeExecutorServerConnection({ origin: right }).origin;
94
95export const chooseCliServerConnectionWithActiveLocal = (input: {
96 readonly requested: ExecutorServerConnection;
97 readonly source: CliServerConnectionSource;
98 readonly active: ExecutorLocalServerManifest | null;
99}): ActiveLocalServerDecision => {
100 if (!input.active) return { kind: "use-requested", connection: input.requested };
101 if (input.source === "active-local") {
102 return { kind: "use-active", connection: input.active.connection };
103 }
104 if (sameOrigin(input.requested.origin, input.active.connection.origin)) {
105 return { kind: "use-active", connection: input.active.connection };
106 }
107 if (canAutoStartCliServerConnection(input.requested)) {
108 return { kind: "conflict", active: input.active };
109 }
110 return { kind: "use-requested", connection: input.requested };
111};
apps/cli/src/server-profile.test.ts+131−0
@@ -0,0 +1,131 @@
1import { afterEach, describe, expect, it } from "@effect/vitest";
2import { BunServices } from "@effect/platform-bun";
3import { mkdtempSync, rmSync } from "node:fs";
4import { tmpdir } from "node:os";
5import { join } from "node:path";
6import * as Effect from "effect/Effect";
7
8import {
9 defaultCliServerConnectionProfile,
10 parseCliServerConnectionStore,
11 readCliServerConnectionStore,
12 removeCliServerConnectionProfile,
13 setDefaultCliServerConnectionProfile,
14 upsertCliServerConnectionProfile,
15} from "./server-profile";
16
17const previousDataDir = process.env.EXECUTOR_DATA_DIR;
18
19afterEach(() => {
20 if (previousDataDir === undefined) {
21 delete process.env.EXECUTOR_DATA_DIR;
22 } else {
23 process.env.EXECUTOR_DATA_DIR = previousDataDir;
24 }
25});
26
27describe("CLI server connection profiles", () => {
28 it("round-trips named server connections and default selection", () =>
29 Effect.gen(function* () {
30 const dataDir = mkdtempSync(join(tmpdir(), "executor-server-profiles-"));
31 process.env.EXECUTOR_DATA_DIR = dataDir;
32
33 try {
34 yield* upsertCliServerConnectionProfile({
35 name: "remote",
36 connection: {
37 origin: "https://executor.example/api",
38 auth: { kind: "bearer", token: "key_123" },
39 },
40 makeDefault: true,
41 });
42
43 const store = yield* readCliServerConnectionStore();
44 expect(store.defaultProfile).toBe("remote");
45 expect(store.profiles).toHaveLength(1);
46 expect(store.profiles[0]?.connection.kind).toBe("http");
47 expect(store.profiles[0]?.connection.origin).toBe("https://executor.example");
48 expect(store.profiles[0]?.connection.apiBaseUrl).toBe("https://executor.example/api");
49 expect(store.profiles[0]?.connection.auth).toEqual({
50 kind: "bearer",
51 token: "key_123",
52 });
53 expect(defaultCliServerConnectionProfile(store)?.name).toBe("remote");
54 } finally {
55 rmSync(dataDir, { recursive: true, force: true });
56 }
57 }).pipe(Effect.provide(BunServices.layer)));
58
59 it("switches and removes the default profile", () =>
60 Effect.gen(function* () {
61 const dataDir = mkdtempSync(join(tmpdir(), "executor-server-profiles-"));
62 process.env.EXECUTOR_DATA_DIR = dataDir;
63
64 try {
65 yield* upsertCliServerConnectionProfile({
66 name: "local",
67 connection: { origin: "localhost:4788" },
68 makeDefault: true,
69 });
70 yield* upsertCliServerConnectionProfile({
71 name: "remote",
72 connection: { origin: "https://executor.example" },
73 makeDefault: false,
74 });
75
76 const switched = yield* setDefaultCliServerConnectionProfile("remote");
77 expect(switched.defaultProfile).toBe("remote");
78
79 const removed = yield* removeCliServerConnectionProfile("remote");
80 expect(removed.defaultProfile).toBeNull();
81 expect(removed.profiles.map((profile) => profile.name)).toEqual(["local"]);
82 } finally {
83 rmSync(dataDir, { recursive: true, force: true });
84 }
85 }).pipe(Effect.provide(BunServices.layer)));
86
87 it("drops malformed profiles when parsing", () => {
88 const store = parseCliServerConnectionStore(
89 JSON.stringify({
90 version: 1,
91 defaultProfile: "missing",
92 profiles: [
93 { name: "valid", connection: { origin: "https://executor.example" } },
94 { name: "bad space", connection: { origin: "https://ignored.example" } },
95 { name: "no-origin", connection: {} },
96 ],
97 }),
98 );
99
100 expect(store.defaultProfile).toBeNull();
101 expect(store.profiles.map((profile) => profile.name)).toEqual(["valid"]);
102 });
103
104 it("preserves desktop sidecar profile kind", () => {
105 const store = parseCliServerConnectionStore(
106 JSON.stringify({
107 version: 1,
108 defaultProfile: "desktop",
109 profiles: [
110 {
111 name: "desktop",
112 connection: {
113 kind: "desktop-sidecar",
114 key: "desktop-sidecar",
115 origin: "http://127.0.0.1:4789",
116 auth: { kind: "basic", username: "executor", password: "secret" },
117 },
118 },
119 ],
120 }),
121 );
122
123 expect(store.defaultProfile).toBe("desktop");
124 expect(store.profiles[0]?.connection.kind).toBe("desktop-sidecar");
125 expect(store.profiles[0]?.connection.auth).toEqual({
126 kind: "basic",
127 username: "executor",
128 password: "secret",
129 });
130 });
131});
apps/cli/src/server-profile.ts+217−0
@@ -0,0 +1,217 @@
1import { homedir } from "node:os";
2import { FileSystem, Option, Path, Schema } from "effect";
3import type { PlatformError } from "effect/PlatformError";
4import * as Effect from "effect/Effect";
5
6import {
7 normalizeExecutorServerConnection,
8 type ExecutorServerConnection,
9 type ExecutorServerConnectionInput,
10} from "@executor-js/sdk/shared";
11
12export interface CliServerConnectionProfile {
13 readonly name: string;
14 readonly connection: ExecutorServerConnection;
15}
16
17export interface CliServerConnectionStore {
18 readonly version: 1;
19 readonly defaultProfile: string | null;
20 readonly profiles: readonly CliServerConnectionProfile[];
21}
22
23export const emptyCliServerConnectionStore: CliServerConnectionStore = {
24 version: 1,
25 defaultProfile: null,
26 profiles: [],
27};
28
29export const validateCliServerConnectionProfileName = (name: string): string => {
30 const trimmed = name.trim();
31 if (!/^[A-Za-z0-9_.-]+$/.test(trimmed)) {
32 throw new Error(
33 "Server profile names may contain only letters, numbers, dots, underscores, and dashes.",
34 );
35 }
36 return trimmed;
37};
38
39const resolveDataDir = (path: Path.Path): string =>
40 process.env.EXECUTOR_DATA_DIR ?? path.join(homedir(), ".executor");
41
42const serverConnectionStorePath = (path: Path.Path): string =>
43 path.join(resolveDataDir(path), "server-connections.json");
44
45const PersistedAuth = Schema.Union([
46 Schema.Struct({
47 kind: Schema.Literal("basic"),
48 username: Schema.optional(Schema.String),
49 password: Schema.String,
50 }),
51 Schema.Struct({
52 kind: Schema.Literal("bearer"),
53 token: Schema.String,
54 }),
55]);
56
57const PersistedConnection = Schema.Struct({
58 kind: Schema.optional(Schema.Literals(["http", "desktop-sidecar"])),
59 key: Schema.optional(Schema.String),
60 origin: Schema.optional(Schema.String),
61 apiBaseUrl: Schema.optional(Schema.String),
62 displayName: Schema.optional(Schema.String),
63 auth: Schema.optional(PersistedAuth),
64});
65
66const PersistedProfile = Schema.Struct({
67 name: Schema.String,
68 connection: PersistedConnection,
69});
70
71const PersistedStore = Schema.Struct({
72 version: Schema.Literal(1),
73 defaultProfile: Schema.optional(Schema.NullOr(Schema.String)),
74 profiles: Schema.Array(PersistedProfile),
75});
76
77const decodeStoreJson = Schema.decodeUnknownOption(Schema.fromJsonString(PersistedStore));
78
79const decodeConnection = (
80 input: ExecutorServerConnectionInput,
81): ExecutorServerConnection | null => {
82 if (!input.origin && !input.apiBaseUrl) return null;
83 return normalizeExecutorServerConnection(input);
84};
85
86export const parseCliServerConnectionStore = (raw: string): CliServerConnectionStore => {
87 const decoded = decodeStoreJson(raw);
88 if (Option.isNone(decoded)) return emptyCliServerConnectionStore;
89 const record = decoded.value;
90
91 const profiles = record.profiles.flatMap((value): readonly CliServerConnectionProfile[] => {
92 const connection = decodeConnection(value.connection);
93 if (!connection) return [];
94 try {
95 return [{ name: validateCliServerConnectionProfileName(value.name), connection }];
96 } catch {
97 return [];
98 }
99 });
100
101 const defaultProfile =
102 record.defaultProfile && profiles.some((profile) => profile.name === record.defaultProfile)
103 ? record.defaultProfile
104 : null;
105
106 return {
107 version: 1,
108 defaultProfile,
109 profiles,
110 };
111};
112
113const serializeCliServerConnectionStore = (store: CliServerConnectionStore): string =>
114 `${JSON.stringify(store, null, 2)}\n`;
115
116export const readCliServerConnectionStore = (): Effect.Effect<
117 CliServerConnectionStore,
118 never,
119 FileSystem.FileSystem | Path.Path
120> =>
121 Effect.gen(function* () {
122 const fs = yield* FileSystem.FileSystem;
123 const path = yield* Path.Path;
124 const raw = yield* fs
125 .readFileString(serverConnectionStorePath(path))
126 .pipe(Effect.catchCause(() => Effect.succeed(null)));
127 if (raw === null) return emptyCliServerConnectionStore;
128 return parseCliServerConnectionStore(raw);
129 });
130
131export const writeCliServerConnectionStore = (
132 store: CliServerConnectionStore,
133): Effect.Effect<void, PlatformError, FileSystem.FileSystem | Path.Path> =>
134 Effect.gen(function* () {
135 const fs = yield* FileSystem.FileSystem;
136 const path = yield* Path.Path;
137 const dataDir = resolveDataDir(path);
138 yield* fs.makeDirectory(dataDir, { recursive: true });
139 yield* fs.writeFileString(
140 serverConnectionStorePath(path),
141 serializeCliServerConnectionStore(store),
142 );
143 });
144
145export const upsertCliServerConnectionProfile = (input: {
146 readonly name: string;
147 readonly connection: ExecutorServerConnectionInput;
148 readonly makeDefault: boolean;
149}): Effect.Effect<CliServerConnectionStore, PlatformError, FileSystem.FileSystem | Path.Path> =>
150 Effect.gen(function* () {
151 const name = validateCliServerConnectionProfileName(input.name);
152 const store = yield* readCliServerConnectionStore();
153 const connection = normalizeExecutorServerConnection({
154 ...input.connection,
155 key: input.connection.key ?? `profile:${name}`,
156 displayName: input.connection.displayName ?? name,
157 });
158 const nextProfiles = [
159 ...store.profiles.filter((profile) => profile.name !== name),
160 { name, connection },
161 ].sort((a, b) => a.name.localeCompare(b.name));
162 const nextStore: CliServerConnectionStore = {
163 version: 1,
164 defaultProfile:
165 input.makeDefault || store.defaultProfile === null ? name : store.defaultProfile,
166 profiles: nextProfiles,
167 };
168 yield* writeCliServerConnectionStore(nextStore);
169 return nextStore;
170 });
171
172export const setDefaultCliServerConnectionProfile = (
173 name: string,
174): Effect.Effect<
175 CliServerConnectionStore,
176 Error | PlatformError,
177 FileSystem.FileSystem | Path.Path
178> =>
179 Effect.gen(function* () {
180 const profileName = validateCliServerConnectionProfileName(name);
181 const store = yield* readCliServerConnectionStore();
182 if (!store.profiles.some((profile) => profile.name === profileName)) {
183 return yield* Effect.fail(new Error(`No server profile named "${profileName}".`));
184 }
185 const nextStore: CliServerConnectionStore = { ...store, defaultProfile: profileName };
186 yield* writeCliServerConnectionStore(nextStore);
187 return nextStore;
188 });
189
190export const removeCliServerConnectionProfile = (
191 name: string,
192): Effect.Effect<CliServerConnectionStore, PlatformError, FileSystem.FileSystem | Path.Path> =>
193 Effect.gen(function* () {
194 const profileName = validateCliServerConnectionProfileName(name);
195 const store = yield* readCliServerConnectionStore();
196 const nextProfiles = store.profiles.filter((profile) => profile.name !== profileName);
197 const nextStore: CliServerConnectionStore = {
198 version: 1,
199 defaultProfile: store.defaultProfile === profileName ? null : store.defaultProfile,
200 profiles: nextProfiles,
201 };
202 yield* writeCliServerConnectionStore(nextStore);
203 return nextStore;
204 });
205
206export const findCliServerConnectionProfile = (
207 store: CliServerConnectionStore,
208 name: string,
209): CliServerConnectionProfile | null => {
210 const profileName = validateCliServerConnectionProfileName(name);
211 return store.profiles.find((profile) => profile.name === profileName) ?? null;
212};
213
214export const defaultCliServerConnectionProfile = (
215 store: CliServerConnectionStore,
216): CliServerConnectionProfile | null =>
217 store.defaultProfile ? findCliServerConnectionProfile(store, store.defaultProfile) : null;
apps/local/src/serve.test.ts+74−0
@@ -116,4 +116,78 @@ describe("startServer network bind auth", () => { · 115 unmodified lines
116 expect(authorized.status).toBe(200);116 expect(authorized.status).toBe(200);
117 expect(await authorized.text()).toBe("ok");117 expect(await authorized.text()).toBe("ok");
118 });118 });
119
120 it("answers browser CORS preflights before auth", async () => {
121 server = await startServer({
122 port: 0,
123 hostname: "127.0.0.1",
124 clientDir,
125 authToken: "test-token",
126 handlers: {
127 api: {
128 handler: async () => new Response("ok"),
129 dispose: async () => {},
130 },
131 mcp: {
132 handleRequest: async () => new Response("ok"),
133 handleApprovalRequest: async () => new Response("ok"),
134 close: async () => {},
135 },
136 },
137 });
138
139 const response = await fetch(`http://127.0.0.1:${server.port}/api/scope`, {
140 method: "OPTIONS",
141 headers: {
142 origin: "http://127.0.0.1:4789",
143 "access-control-request-method": "GET",
144 "access-control-request-headers": "authorization,b3,traceparent",
145 },
146 });
147
148 expect(response.status).toBe(204);
149 expect(response.headers.get("access-control-allow-origin")).toBe("http://127.0.0.1:4789");
150 expect(response.headers.get("access-control-allow-credentials")).toBe("true");
151 expect(response.headers.get("access-control-allow-headers")).toContain("traceparent");
152 expect(response.headers.get("access-control-allow-headers")).toContain("b3");
153 });
154
155 it("adds CORS headers to authenticated API failures", async () => {
156 server = await startServer({
157 port: 0,
158 hostname: "127.0.0.1",
159 clientDir,
160 authToken: "test-token",
161 handlers: {
162 api: {
163 handler: async () => new Response("ok"),
164 dispose: async () => {},
165 },
166 mcp: {
167 handleRequest: async () => new Response("ok"),
168 handleApprovalRequest: async () => new Response("ok"),
169 close: async () => {},
170 },
171 },
172 });
173
174 const response = await fetch(`http://127.0.0.1:${server.port}/api/scope`, {
175 headers: { origin: "http://127.0.0.1:4789" },
176 });
177
178 expect(response.status).toBe(401);
179 expect(response.headers.get("access-control-allow-origin")).toBe("http://127.0.0.1:4789");
180 expect(response.headers.get("access-control-allow-credentials")).toBe("true");
181 expect(response.headers.get("access-control-allow-headers")).toContain("traceparent");
182 });
183
184 it("does not expose unauthenticated loopback servers cross-origin", async () => {
185 const baseUrl = await startTestServer();
186 const response = await fetch(`${baseUrl}/api/scope`, {
187 headers: { origin: "https://example.com" },
188 });
189
190 expect(response.status).toBe(200);
191 expect(response.headers.get("access-control-allow-origin")).toBeNull();
192 });
119});193});
apps/local/src/serve.ts+55−14
@@ -200,6 +200,36 @@ export interface ServerInstance { · 199 unmodified lines
200 200
201type ServerHandlers = Awaited<ReturnType<typeof getServerHandlers>>;201type ServerHandlers = Awaited<ReturnType<typeof getServerHandlers>>;
202 202
203const corsHeaders = {
204 "access-control-allow-methods": "GET,POST,PUT,PATCH,DELETE,OPTIONS",
205 "access-control-allow-headers":
206 "authorization, content-type, x-executor-token, x-requested-with, traceparent, tracestate, baggage, b3",
207 "access-control-allow-credentials": "true",
208 "access-control-expose-headers": "*",
209} as const;
210
211const withCorsHeaders = (req: Request, response: Response): Response => {
212 const origin = req.headers.get("origin");
213 if (!origin) return response;
214 const headers = new Headers(response.headers);
215 headers.set("access-control-allow-origin", origin);
216 for (const [key, value] of Object.entries(corsHeaders)) headers.set(key, value);
217 headers.set(
218 "access-control-allow-headers",
219 req.headers.get("access-control-request-headers") ??
220 corsHeaders["access-control-allow-headers"],
221 );
222 headers.append("vary", "Origin");
223 return new Response(response.body, {
224 status: response.status,
225 statusText: response.statusText,
226 headers,
227 });
228};
229
230const corsPreflightResponse = (req: Request): Response =>
231 withCorsHeaders(req, new Response(null, { status: 204 }));
232
203export async function startServer(opts: StartServerOptions = {}): Promise<ServerInstance> {233export async function startServer(opts: StartServerOptions = {}): Promise<ServerInstance> {
204 const port = opts.port ?? parseInt(process.env.PORT ?? "4788", 10);234 const port = opts.port ?? parseInt(process.env.PORT ?? "4788", 10);
205 const hostname = opts.hostname ?? "127.0.0.1";235 const hostname = opts.hostname ?? "127.0.0.1";
@@ -264,8 +294,15 @@ export async function startServer(opts: StartServerOptions = {}): Promise<Server · 58 unmodified lines
264 idleTimeout: 0,294 idleTimeout: 0,
265 routes: { ...staticRoutes },295 routes: { ...staticRoutes },
266 async fetch(req) {296 async fetch(req) {
297 const maybeWithCorsHeaders = (response: Response): Response =>
298 requiresAuth ? withCorsHeaders(req, response) : response;
299
267 if (!isAllowedHost(req)) {300 if (!isAllowedHost(req)) {
268 return new Response("Forbidden", { status: 403 });301 return maybeWithCorsHeaders(new Response("Forbidden", { status: 403 }));
302 }
303
304 if (requiresAuth && req.method === "OPTIONS" && req.headers.has("origin")) {
305 return corsPreflightResponse(req);
269 }306 }
270 307
271 const url = new URL(req.url);308 const url = new URL(req.url);
@@ -276,18 +313,20 @@ export async function startServer(opts: StartServerOptions = {}): Promise<Server · 4 unmodified lines
276 const skipAuth = isUnauthenticatedOAuthCallbackPath(url.pathname);313 const skipAuth = isUnauthenticatedOAuthCallbackPath(url.pathname);
277 314
278 if (requiresAuth && !skipAuth && !isAuthorized(req)) {315 if (requiresAuth && !skipAuth && !isAuthorized(req)) {
279 return new Response("Unauthorized", {316 return maybeWithCorsHeaders(
280 status: 401,317 new Response("Unauthorized", {
281 headers: { "www-authenticate": 'Bearer realm="executor", Basic realm="executor"' },318 status: 401,
282 });319 headers: { "www-authenticate": 'Bearer realm="executor", Basic realm="executor"' },
320 }),
321 );
283 }322 }
284 323
285 if (url.pathname.startsWith("/mcp")) {324 if (url.pathname.startsWith("/mcp")) {
286 return handlers.mcp.handleRequest(req);325 return maybeWithCorsHeaders(await handlers.mcp.handleRequest(req));
287 }326 }
288 327
289 if (url.pathname.startsWith("/api/mcp-sessions/")) {328 if (url.pathname.startsWith("/api/mcp-sessions/")) {
290 return handlers.mcp.handleApprovalRequest(req);329 return maybeWithCorsHeaders(await handlers.mcp.handleApprovalRequest(req));
291 }330 }
292 331
293 // OAuth result polling — local-only, served outside the typed API332 // OAuth result polling — local-only, served outside the typed API
@@ -296,31 +335,33 @@ export async function startServer(opts: StartServerOptions = {}): Promise<Server · 2 unmodified lines
296 const awaitMatch = /^\/api\/oauth\/await\/([^/?#]+)$/.exec(url.pathname);335 const awaitMatch = /^\/api\/oauth\/await\/([^/?#]+)$/.exec(url.pathname);
297 if (awaitMatch && req.method === "GET") {336 if (awaitMatch && req.method === "GET") {
298 const result = consumeOAuthResult(awaitMatch[1]);337 const result = consumeOAuthResult(awaitMatch[1]);
299 return new Response(JSON.stringify(result), {338 return maybeWithCorsHeaders(
300 headers: { "content-type": "application/json" },339 new Response(JSON.stringify(result), {
301 });340 headers: { "content-type": "application/json" },
341 }),
342 );
302 }343 }
303 344
304 if (url.pathname.startsWith("/api/") || url.pathname === "/api") {345 if (url.pathname.startsWith("/api/") || url.pathname === "/api") {
305 url.pathname = url.pathname.slice("/api".length) || "/";346 url.pathname = url.pathname.slice("/api".length) || "/";
306 return handlers.api.handler(new Request(url, req));347 return maybeWithCorsHeaders(await handlers.api.handler(new Request(url, req)));
307 }348 }
308 349
309 // Dev mode: forward everything else (SPA + hashed assets) to the350 // Dev mode: forward everything else (SPA + hashed assets) to the
310 // vite child so source edits show up without a rebuild.351 // vite child so source edits show up without a rebuild.
311 if (viteChild) {352 if (viteChild) {
312 return proxyToVite(req, viteChild.url);353 return maybeWithCorsHeaders(await proxyToVite(req, viteChild.url));
313 }354 }
314 355
315 // If a path looks like a static asset (has a file extension), do not356 // If a path looks like a static asset (has a file extension), do not
316 // fall back to SPA HTML. Returning index.html here causes browser module357 // fall back to SPA HTML. Returning index.html here causes browser module
317 // MIME errors when hashed chunks are stale/missing.358 // MIME errors when hashed chunks are stale/missing.
318 if (hasFileExtension(url.pathname)) {359 if (hasFileExtension(url.pathname)) {
319 return new Response("Not Found", { status: 404 });360 return maybeWithCorsHeaders(new Response("Not Found", { status: 404 }));
320 }361 }
321 362
322 // SPA fallback363 // SPA fallback
323 return serveIndex();364 return maybeWithCorsHeaders(await serveIndex());
324 },365 },
325 error(error) {366 error(error) {
326 console.error("Server error:", error);367 console.error("Server error:", error);
bun.lock+5−0
@@ -39,13 +39,16 @@ · 38 unmodified lines
39 "@executor-js/integrations-registry": "workspace:*",39 "@executor-js/integrations-registry": "workspace:*",
40 "@executor-js/local": "workspace:*",40 "@executor-js/local": "workspace:*",
41 "@executor-js/runtime-quickjs": "workspace:*",41 "@executor-js/runtime-quickjs": "workspace:*",
42 "@executor-js/sdk": "workspace:*",
42 "@jitl/quickjs-wasmfile-release-sync": "catalog:",43 "@jitl/quickjs-wasmfile-release-sync": "catalog:",
43 "effect": "catalog:",44 "effect": "catalog:",
44 "quickjs-emscripten": "catalog:",45 "quickjs-emscripten": "catalog:",
45 },46 },
46 "devDependencies": {47 "devDependencies": {
48 "@effect/vitest": "catalog:",
47 "bun-types": "catalog:",49 "bun-types": "catalog:",
48 "typescript": "catalog:",50 "typescript": "catalog:",
51 "vitest": "catalog:",
49 },52 },
50 },53 },
51 "apps/cloud": {54 "apps/cloud": {
@@ -135,6 +138,7 @@ · 83 unmodified lines
135 "@executor-js/plugin-onepassword": "workspace:*",138 "@executor-js/plugin-onepassword": "workspace:*",
136 "@executor-js/plugin-openapi": "workspace:*",139 "@executor-js/plugin-openapi": "workspace:*",
137 "@executor-js/runtime-quickjs": "workspace:*",140 "@executor-js/runtime-quickjs": "workspace:*",
141 "@executor-js/sdk": "workspace:*",
138 "@jitl/quickjs-wasmfile-release-sync": "catalog:",142 "@jitl/quickjs-wasmfile-release-sync": "catalog:",
139 "@modelcontextprotocol/sdk": "^1.29.0",143 "@modelcontextprotocol/sdk": "^1.29.0",
140 "@types/node": "catalog:",144 "@types/node": "catalog:",
@@ -277,6 +281,7 @@ · 136 unmodified lines
277 "@executor-js/vite-plugin": "workspace:*",281 "@executor-js/vite-plugin": "workspace:*",
278 "@tanstack/react-router": "catalog:",282 "@tanstack/react-router": "catalog:",
279 "effect": "catalog:",283 "effect": "catalog:",
284 "lucide-react": "^1.7.0",
280 "react": "catalog:",285 "react": "catalog:",
281 "react-dom": "catalog:",286 "react-dom": "catalog:",
282 },287 },