History

Update CLI web setup flow

Rhys Sullivan committed 4 months ago

Commit 30ecd421 parent 831bfbd7 files · +233 −112Browse files

Unified · Split

7 files
  1. README.md +6 −3
  2. apps/cli/src/main.ts +134 −83
  3. e2e/local/auth.test.ts +10 −8
  4. e2e/local/cli-open-stale-manifest.test.ts +5 −1
  5. e2e/local/cli-web-transition.test.ts +57 −0
  6. e2e/local/local-server.ts +15 −12
  7. e2e/local/mcp-browser-approve.test.ts +6 −5
README.md+6−3
@@ -10,10 +10,11 @@ The integration layer for AI agents. One catalog for every tool, shared across e · 9 unmodified lines
1010
1111 ```bash
1212 npm install -g executor
13+executor install
1314 executor web
1415 ```
1516
16−This starts a local runtime with a web UI at `http://127.0.0.1:4788`. From there, add your first source and start using tools.
17+This installs the local background service and opens the web UI. From there, add your first source and start using tools.
1718
1819 ### Use as an MCP server
1920
@@ -60,7 +61,7 @@ If you can represent it with a JSON schema, it can be an integration. Executor h · 40 unmodified lines
6061
6162 ### Via the web UI
6263
63−Open `http://127.0.0.1:4788`, go to **Add Source**, paste a URL, and Executor will detect the type, index the tools, and handle auth.
64+Run `executor web`, go to **Add Source**, paste a URL, and Executor will detect the type, index the tools, and handle auth.
6465
6566 ### Via the CLI
6667
@@ -119,7 +120,9 @@ executor resume --execution-id exec_123 · 52 unmodified lines
119120 ## CLI reference
120121
121122 ```bash
122−executor web # start runtime + web UI
123+executor install # install/start the durable background service
124+executor web # open the running web UI
125+executor web --foreground # start a temporary foreground runtime + web UI
123126 executor daemon run # start persistent local daemon in background
124127 executor daemon status # show daemon status
125128 executor daemon stop # stop daemon
apps/cli/src/main.ts+134−83
@@ -1916,28 +1916,45 @@ const serverCommand = Command.make("server").pipe( · 1915 unmodified lines
19161916 const webCommand = Command.make(
19171917 "web",
19181918 {
1919− port: Options.integer("port").pipe(Options.withDefault(DEFAULT_PORT)),
1919+ foreground: Options.boolean("foreground")
1920+ .pipe(Options.withDefault(false))
1921+ .pipe(
1922+ Options.withDescription(
1923+ "Run a temporary web server in this terminal. By default, web opens the installed background service.",
1924+ ),
1925+ ),
1926+ port: Options.integer("port")
1927+ .pipe(Options.withDefault(DEFAULT_PORT))
1928+ .pipe(Options.withDescription("Port for the temporary --foreground server.")),
19201929 hostname: Options.string("hostname")
19211930 .pipe(Options.withDefault("127.0.0.1"))
1922− .pipe(Options.withDescription("Bind address. Use 0.0.0.0 to listen on all interfaces.")),
1931+ .pipe(
1932+ Options.withDescription(
1933+ "Bind address for the temporary --foreground server. Use 0.0.0.0 to listen on all interfaces.",
1934+ ),
1935+ ),
19231936 allowedHost: Options.string("allowed-host")
19241937 .pipe(Options.atLeast(0))
19251938 .pipe(
19261939 Options.withDescription(
1927− "Grant an extra origin cross-origin (CORS) access (repeatable). Not needed to reach the server from another host — the bearer token is the gate; localhost is always allowed.",
1940+ "For --foreground, grant an extra origin cross-origin (CORS) access (repeatable). Not needed to reach the server from another host — the bearer token is the gate; localhost is always allowed.",
19281941 ),
19291942 ),
19301943 authToken: Options.string("auth-token")
19311944 .pipe(Options.optional)
19321945 .pipe(
19331946 Options.withDescription(
1934− "Override the bearer token. Defaults to the stable token in auth.json.",
1947+ "For --foreground, override the bearer token. Defaults to the stable token in auth.json.",
19351948 ),
19361949 ),
19371950 scope,
19381951 },
1939− ({ port, scope, hostname, allowedHost, authToken }) =>
1952+ ({ foreground, port, scope, hostname, allowedHost, authToken }) =>
19401953 Effect.gen(function* () {
1954+ if (!foreground) {
1955+ yield* openRunningLocalWebApp();
1956+ return;
1957+ }
19411958 applyScope(scope);
19421959 yield* runForegroundSession({
19431960 port,
@@ -1946,7 +1963,7 @@ const webCommand = Command.make( · 2 unmodified lines
19461963 authToken: Option.getOrUndefined(authToken),
19471964 });
19481965 }),
1949−).pipe(Command.withDescription("Start a foreground web session"));
1966+).pipe(Command.withDescription("Open the Executor web UI"));
19501967
19511968 const daemonRunCommand = Command.make(
19521969 "run",
@@ -2108,80 +2125,86 @@ const mcpCommand = Command.make( · 155 unmodified lines
21082125
21092126 const supervisedServiceOrigin = (port: number): string => `http://127.0.0.1:${port}`;
21102127
2111−const serviceInstallCommand = Command.make(
2112− "install",
2113− {
2114− port: Options.integer("port")
2115− .pipe(Options.withDefault(DEFAULT_SERVICE_PORT))
2116− .pipe(Options.withDescription("Port the supervised daemon binds (loopback only).")),
2117− },
2118− ({ port }) =>
2119− Effect.gen(function* () {
2120− if (isDevMode) {
2121− return yield* Effect.fail(
2122− new Error(
2123− [
2124− "`service install` requires the compiled `executor` binary so the OS can run it directly.",
2125− `In a dev checkout, run \`${cliPrefix} daemon run --foreground\` instead.`,
2126− ].join("\n"),
2127− ),
2128− );
2129− }
2130−
2131− const backend = getServiceBackend();
2132− if (!backend.automated) {
2133− // Unsupported platforms surface their manual steps via the install error.
2134− yield* backend.install({ executablePath: process.execPath, port, version: CLI_VERSION });
2135− return;
2136− }
2128+const servicePortOption = () =>
2129+ Options.integer("port")
2130+ .pipe(Options.withDefault(DEFAULT_SERVICE_PORT))
2131+ .pipe(Options.withDescription("Port the supervised daemon binds (loopback only)."));
21372132
2138− // Don't fight an already-running local server against the same data dir
2139− // (a desktop sidecar, a foreground `executor web`, or an existing daemon).
2140− const active = yield* readActiveLocalServerManifest();
2141− if (active) {
2142− const status = yield* backend.status();
2143− if (status.registered && status.running && active.kind === "cli-daemon") {
2144− console.log(
2145− `Executor service already running at ${active.connection.origin} (pid ${active.pid}).`,
2146− );
2147− return;
2148− }
2149− return yield* Effect.fail(
2150− new Error(
2151− [
2152− `A local Executor ${active.kind} is already running at ${active.connection.origin} (pid ${active.pid}).`,
2153− `Stop it first (quit the desktop app, or \`${cliPrefix} daemon stop\`), then re-run install.`,
2154− ].join("\n"),
2155− ),
2156− );
2157− }
2133+const installService = (port: number, commandName: string) =>
2134+ Effect.gen(function* () {
2135+ const command = `${cliPrefix} ${commandName}`;
2136+ if (isDevMode) {
2137+ return yield* Effect.fail(
2138+ new Error(
2139+ [
2140+ `\`${command}\` requires the compiled \`executor\` binary so the OS can run it directly.`,
2141+ `In a dev checkout, run \`${cliPrefix} daemon run --foreground\` instead.`,
2142+ ].join("\n"),
2143+ ),
2144+ );
2145+ }
21582146
2159− // The unit carries no secret: the supervised daemon mints/loads its bearer
2160− // from auth.json (under EXECUTOR_DATA_DIR) on first boot, and clients read
2161− // the same file — so reachability is the credential-free /api/health probe.
2147+ const backend = getServiceBackend();
2148+ if (!backend.automated) {
2149+ // Unsupported platforms surface their manual steps via the install error.
21622150 yield* backend.install({ executablePath: process.execPath, port, version: CLI_VERSION });
2151+ return;
2152+ }
21632153
2164− const origin = supervisedServiceOrigin(port);
2165− const reachable = yield* waitForReachable({
2166− check: isServerReachable(origin),
2167− timeoutMs: DAEMON_BOOT_TIMEOUT_MS,
2168− intervalMs: DAEMON_BOOT_POLL_MS,
2169− });
2170− if (!reachable) {
2171− return yield* Effect.fail(
2172− new Error(
2173− [
2174− `Installed ${SERVICE_LABEL} but it did not become reachable at ${origin} within ${DAEMON_BOOT_TIMEOUT_MS / 1000}s.`,
2175− `Check ~/.executor/logs/daemon.error.log and \`${cliPrefix} service status\`.`,
2176− ].join("\n"),
2177− ),
2154+ // Don't fight an already-running local server against the same data dir
2155+ // (a desktop sidecar, a foreground `executor web`, or an existing daemon).
2156+ const active = yield* readActiveLocalServerManifest();
2157+ if (active) {
2158+ const status = yield* backend.status();
2159+ if (status.registered && status.running && active.kind === "cli-daemon") {
2160+ console.log(
2161+ `Executor service already running at ${active.connection.origin} (pid ${active.pid}).`,
21782162 );
2163+ return;
21792164 }
2165+ return yield* Effect.fail(
2166+ new Error(
2167+ [
2168+ `A local Executor ${active.kind} is already running at ${active.connection.origin} (pid ${active.pid}).`,
2169+ `Stop it first (quit the desktop app, or \`${cliPrefix} daemon stop\`), then re-run \`${command}\`.`,
2170+ ].join("\n"),
2171+ ),
2172+ );
2173+ }
21802174
2181− console.log(`Executor is now running as a background service at ${origin}.`);
2182− console.log("It keeps serving after you quit the app and restarts on login.");
2183− console.log(`Open it in your browser, already signed in, with: ${cliPrefix} open`);
2184− }),
2175+ // The unit carries no secret: the supervised daemon mints/loads its bearer
2176+ // from auth.json (under EXECUTOR_DATA_DIR) on first boot, and clients read
2177+ // the same file — so reachability is the credential-free /api/health probe.
2178+ yield* backend.install({ executablePath: process.execPath, port, version: CLI_VERSION });
2179+
2180+ const origin = supervisedServiceOrigin(port);
2181+ const reachable = yield* waitForReachable({
2182+ check: isServerReachable(origin),
2183+ timeoutMs: DAEMON_BOOT_TIMEOUT_MS,
2184+ intervalMs: DAEMON_BOOT_POLL_MS,
2185+ });
2186+ if (!reachable) {
2187+ return yield* Effect.fail(
2188+ new Error(
2189+ [
2190+ `Installed ${SERVICE_LABEL} but it did not become reachable at ${origin} within ${DAEMON_BOOT_TIMEOUT_MS / 1000}s.`,
2191+ `Check ~/.executor/logs/daemon.error.log and \`${cliPrefix} service status\`.`,
2192+ ].join("\n"),
2193+ ),
2194+ );
2195+ }
2196+
2197+ console.log(`Executor is now running as a background service at ${origin}.`);
2198+ console.log("It keeps serving after you quit the app and restarts on login.");
2199+ console.log(`Open it in your browser, already signed in, with: ${cliPrefix} web`);
2200+ });
2201+
2202+const serviceInstallCommand = Command.make(
2203+ "install",
2204+ {
2205+ port: servicePortOption(),
2206+ },
2207+ ({ port }) => installService(port, "service install"),
21852208 ).pipe(
21862209 Command.withDescription("Install and start Executor as an OS-supervised background service"),
21872210 );
@@ -2240,6 +2263,16 @@ const serviceCommand = Command.make("service").pipe( · 52 unmodified lines
22402263 Command.withDescription("Manage the OS-supervised background service"),
22412264 );
22422265
2266+const installCommand = Command.make(
2267+ "install",
2268+ {
2269+ port: servicePortOption(),
2270+ },
2271+ ({ port }) => installService(port, "install"),
2272+).pipe(
2273+ Command.withDescription("Install and start Executor as an OS-supervised background service"),
2274+);
2275+
22432276 // ---------------------------------------------------------------------------
22442277 // Root command
22452278 // ---------------------------------------------------------------------------
@@ -2263,17 +2296,28 @@ const openInBrowser = (url: string): Effect.Effect<void> => · 17 unmodified lines
22632296 execFile(cmd, [...args], () => {});
22642297 });
22652298
2266−/**
2267− * `executor open` — the friendly way back in. Reads the running local server's
2268− * manifest and opens the browser straight to its `?_token=` URL, so the user
2269− * never has to copy a bearer token out of a terminal or auth.json by hand.
2270− */
2271−const openCommand = Command.make("open", {}, () =>
2299+const printNoRunningLocalWebApp = (): void => {
2300+ console.log("Executor is not running.");
2301+ console.log("");
2302+ console.log("Install and start the background service:");
2303+ console.log(` ${cliPrefix} install`);
2304+ console.log("");
2305+ console.log("Then open the web UI:");
2306+ console.log(` ${cliPrefix} web`);
2307+ console.log("");
2308+ console.log("For a temporary foreground server:");
2309+ console.log(` ${cliPrefix} web --foreground`);
2310+};
2311+
2312+const openRunningLocalWebApp = (): Effect.Effect<
2313+ void,
2314+ never,
2315+ FileSystem.FileSystem | PlatformPath.Path
2316+> =>
22722317 Effect.gen(function* () {
22732318 const manifest = yield* readActiveLocalServerManifest().pipe(Effect.orElseSucceed(() => null));
22742319 if (!manifest) {
2275− console.log("No local Executor server is running.");
2276− console.log(`Start one with: ${cliPrefix} web`);
2320+ printNoRunningLocalWebApp();
22772321 return;
22782322 }
22792323 const { origin, auth } = manifest.connection;
@@ -2281,8 +2325,14 @@ const openCommand = Command.make("open", {}, () => · 1 unmodified line
22812325 const url = token ? `${origin}/?_token=${token}` : origin;
22822326 console.log(`Opening ${url}`);
22832327 yield* openInBrowser(url);
2284− }),
2285−).pipe(
2328+ });
2329+
2330+/**
2331+ * `executor open` — the friendly way back in. Reads the running local server's
2332+ * manifest and opens the browser straight to its `?_token=` URL, so the user
2333+ * never has to copy a bearer token out of a terminal or auth.json by hand.
2334+ */
2335+const openCommand = Command.make("open", {}, () => openRunningLocalWebApp()).pipe(
22862336 Command.withDescription("Open the running Executor web app in your browser, already signed in"),
22872337 );
22882338
@@ -2291,6 +2341,7 @@ const root = Command.make("executor").pipe( · 2 unmodified lines
22912341 callCommand,
22922342 resumeCommand,
22932343 toolsCommand,
2344+ installCommand,
22942345 serverCommand,
22952346 webCommand,
22962347 daemonCommand,
e2e/local/auth.test.ts+10−8
@@ -1,16 +1,18 @@
11 // Local-only — the single-user bearer-auth flow as DEVELOPER SESSIONS, the way
2−// a human tests it: run the dev CLI in a real terminal, watch `executor web`
3−// print its one-time `?_token=` URL, then drive a browser against it. Two clean
4−// stories, each its own film (terminal.cast + session.mp4 spliced by
5−// scenario.ts), each booting its OWN `executor web` (own data dir, `--port 0`):
2+// a human tests it: run the dev CLI in a real terminal, watch
3+// `executor web --foreground` print its one-time `?_token=` URL, then drive a
4+// browser against it. Two clean stories, each its own film (terminal.cast +
5+// session.mp4 spliced by scenario.ts), each booting its OWN temporary server
6+// (own data dir, `--port 0`):
67 //
78 // 1. The CLI's ?_token URL boots straight into an authenticated console.
89 // 2. Opening the app WITHOUT the token shows the LocalAuthGate; pasting the
910 // token connects.
1011 //
11−// `withLocalServer` (shared helper) runs `executor web` in a recorded terminal
12−// and hands the printed URL to a body; the terminal stays up until the body is
13−// done, then Ctrl-C shuts it (and its vite child) down so the PTY closes.
12+// `withLocalServer` (shared helper) runs `executor web --foreground` in a
13+// recorded terminal and hands the printed URL to a body; the terminal stays up
14+// until the body is done, then Ctrl-C shuts it (and its vite child) down so the
15+// PTY closes.
1416 import { expect } from "@effect/vitest";
1517 import { Effect } from "effect";
1618
@@ -30,7 +32,7 @@ scenario( · 13 unmodified lines
3032
3133 yield* withLocalServer(cli, runDir, ({ url, token }) =>
3234 browser.session(identity, async ({ page, step }) => {
33− await step("Open the ?_token URL printed by executor web", async () => {
35+ await step("Open the ?_token URL printed by executor web --foreground", async () => {
3436 await page.goto(url, { waitUntil: "domcontentloaded" });
3537 await page.getByRole("link", { name: "Secrets" }).first().waitFor({ timeout: 30_000 });
3638 // Integrations actually LOAD (the built-in Executor source) — proves
e2e/local/cli-open-stale-manifest.test.ts+5−1
@@ -77,7 +77,11 @@ scenario( · 76 unmodified lines
7777 });
7878 const output = `${stdout}\n${stderr}`;
7979 expect(output, "stale endpoint should be rejected before printing the token URL").toContain(
80− "No local Executor server is running.",
80+ "Executor is not running.",
81+ );
82+ expect(output, "the recovery path should point users at durable setup").toContain("install");
83+ expect(output, "the old foreground behavior should remain discoverable").toContain(
84+ "web --foreground",
8185 );
8286 expect(output, "the stale bearer must not be printed").not.toContain(token);
8387 expect(output, "the stale URL must not be opened").not.toContain(`127.0.0.1:${port}`);
e2e/local/cli-web-transition.test.ts+57−0
@@ -0,0 +1,57 @@
1+// Local CLI: `executor web` used to mean "start a foreground server". The
2+// first-time CLI setup path is now explicit: install the durable background
3+// service first, then use `executor web` to open it. A fresh `web` invocation
4+// should guide the user without minting local-server credentials/manifest state
5+// or binding ports.
6+import { execFile } from "node:child_process";
7+import { existsSync, mkdtempSync, rmSync } from "node:fs";
8+import { tmpdir } from "node:os";
9+import { join } from "node:path";
10+import { fileURLToPath } from "node:url";
11+import { promisify } from "node:util";
12+
13+import { expect } from "@effect/vitest";
14+import { Effect } from "effect";
15+
16+import { scenario } from "../src/scenario";
17+
18+const execFileAsync = promisify(execFile);
19+const repoRoot = fileURLToPath(new URL("../../", import.meta.url));
20+
21+scenario(
22+ "CLI web · a fresh install points users at setup instead of starting a foreground server",
23+ { timeout: 120_000 },
24+ Effect.promise(async () => {
25+ const root = mkdtempSync(join(tmpdir(), "executor-web-transition-"));
26+ const dataDir = join(root, "data");
27+ try {
28+ const { stdout, stderr } = await execFileAsync("bun", ["run", "dev:cli", "web"], {
29+ cwd: repoRoot,
30+ env: { ...process.env, EXECUTOR_DATA_DIR: dataDir },
31+ });
32+ const output = `${stdout}\n${stderr}`;
33+
34+ expect(output, "plain web should not start the old foreground server").not.toContain("Open:");
35+ expect(output, "plain web should explain that no service is running").toContain(
36+ "Executor is not running.",
37+ );
38+ expect(output, "plain web should direct first-time users to durable setup").toContain(
39+ "install",
40+ );
41+ expect(
42+ output,
43+ "plain web should keep the temporary-server escape hatch discoverable",
44+ ).toContain("web --foreground");
45+ expect(
46+ existsSync(join(dataDir, "server-control", "auth.json")),
47+ "plain web should not mint a local auth token",
48+ ).toBe(false);
49+ expect(
50+ existsSync(join(dataDir, "server-control", "server.json")),
51+ "plain web should not write a foreground server manifest",
52+ ).toBe(false);
53+ } finally {
54+ rmSync(root, { recursive: true, force: true });
55+ }
56+ }),
57+);
e2e/local/local-server.ts+15−12
@@ -1,9 +1,9 @@
1−// Shared helper for the `local` e2e project: boot a real `executor web` in a
2−// recorded terminal, parse its printed one-time `?_token=` URL, and run a body
3−// against it. Each scenario boots its OWN server (own throwaway data dir,
4−// `--port 0`) so files can run in parallel without colliding. The terminal
5−// stays up until the body settles, then Ctrl-C gives a graceful shutdown (so
6−// the vite child dies and the PTY closes).
1+// Shared helper for the `local` e2e project: boot a real temporary server with
2+// `executor web --foreground` in a recorded terminal, parse its printed one-time
3+// `?_token=` URL, and run a body against it. Each scenario boots its OWN server
4+// (own throwaway data dir, `--port 0`) so files can run in parallel without
5+// colliding. The terminal stays up until the body settles, then Ctrl-C gives a
6+// graceful shutdown (so the vite child dies and the PTY closes).
77 import { mkdtempSync, rmSync } from "node:fs";
88 import { tmpdir } from "node:os";
99 import { join } from "node:path";
@@ -29,10 +29,11 @@ export interface ServerHandle { · 19 unmodified lines
2929 }
3030
3131 /**
32− * Boot `executor web` and run `body` against the resulting {@link ServerHandle}.
33− * Keeps the server up until the body settles, then Ctrl-C for a graceful
34− * shutdown. Cleans up the throwaway data dir. The body may drive the browser,
35− * a typed API client, an MCP client — anything that needs the live server.
32+ * Boot `executor web --foreground` and run `body` against the resulting
33+ * {@link ServerHandle}. Keeps the server up until the body settles, then Ctrl-C
34+ * for a graceful shutdown. Cleans up the throwaway data dir. The body may drive
35+ * the browser, a typed API client, an MCP client — anything that needs the live
36+ * server.
3637 */
3738 export const withLocalServer = (
3839 cli: CliSurface,
@@ -54,7 +55,7 @@ export const withLocalServer = ( · 15 unmodified lines
5455 yield* Effect.all(
5556 [
5657 cli.session(
57− ["bun", "run", "dev:cli", "web", "--port", "0"],
58+ ["bun", "run", "dev:cli", "web", "--foreground", "--port", "0"],
5859 async (term) => {
5960 markRecordingStart(runDir, "terminal");
6061 markFocus(runDir, "terminal");
@@ -64,7 +65,9 @@ export const withLocalServer = ( · 3 unmodified lines
6465 );
6566 const url = TOKEN_URL.exec(snapshot.text)?.[0];
6667 if (!url) {
67− throw new Error(`executor web printed no ?_token URL:\n${snapshot.text.slice(-600)}`);
68+ throw new Error(
69+ `executor web --foreground printed no ?_token URL:\n${snapshot.text.slice(-600)}`,
70+ );
6871 }
6972 publishUrl(url);
7073 await bodyDone;
e2e/local/mcp-browser-approve.test.ts+6−5
@@ -5,11 +5,12 @@ · 4 unmodified lines
55 // the MCP `resume` tool (auth on the API path), so it never drives the browser
66 // page and could not catch this. This drives the real page in a real browser.
77 //
8−// Flow: boot `executor web` → create a require_approval policy on a built-in
9−// tool → an MCP client (bearer) executes that tool with elicitation_mode=browser
10−// → the server returns a paused `approvalUrl` → open it in the browser (with the
11−// `?_token` bootstrap) → click Approve → the MCP `resume` call completes. Plus a
12−// negative: the approval endpoint 401s without the bearer.
8+// Flow: boot `executor web --foreground` → create a require_approval policy on a
9+// built-in tool → an MCP client (bearer) executes that tool with
10+// elicitation_mode=browser → the server returns a paused `approvalUrl` → open it
11+// in the browser (with the `?_token` bootstrap) → click Approve → the MCP
12+// `resume` call completes. Plus a negative: the approval endpoint 401s without
13+// the bearer.
1314 import { expect } from "@effect/vitest";
1415 import { Effect } from "effect";
1516 import { HttpApiClient } from "effect/unstable/httpapi";