History

Update CLI web setup flow

Rhys Sullivan committed 5 months ago

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

Showing the changes to apps/cli/src (1 of 7 files). Show all

Unified · Split

apps/cli/src/main.ts+134−83
@@ -1916,28 +1916,45 @@ const serverCommand = Command.make("server").pipe( · 1915 unmodified lines
1916const webCommand = Command.make(1916const webCommand = Command.make(
1917 "web",1917 "web",
1918 {1918 {
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.")),
1920 hostname: Options.string("hostname")1929 hostname: Options.string("hostname")
1921 .pipe(Options.withDefault("127.0.0.1"))1930 .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 ),
1923 allowedHost: Options.string("allowed-host")1936 allowedHost: Options.string("allowed-host")
1924 .pipe(Options.atLeast(0))1937 .pipe(Options.atLeast(0))
1925 .pipe(1938 .pipe(
1926 Options.withDescription(1939 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.",
1928 ),1941 ),
1929 ),1942 ),
1930 authToken: Options.string("auth-token")1943 authToken: Options.string("auth-token")
1931 .pipe(Options.optional)1944 .pipe(Options.optional)
1932 .pipe(1945 .pipe(
1933 Options.withDescription(1946 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.",
1935 ),1948 ),
1936 ),1949 ),
1937 scope,1950 scope,
1938 },1951 },
1939 ({ port, scope, hostname, allowedHost, authToken }) =>1952 ({ foreground, port, scope, hostname, allowedHost, authToken }) =>
1940 Effect.gen(function* () {1953 Effect.gen(function* () {
1954 if (!foreground) {
1955 yield* openRunningLocalWebApp();
1956 return;
1957 }
1941 applyScope(scope);1958 applyScope(scope);
1942 yield* runForegroundSession({1959 yield* runForegroundSession({
1943 port,1960 port,
@@ -1946,7 +1963,7 @@ const webCommand = Command.make( · 2 unmodified lines
1946 authToken: Option.getOrUndefined(authToken),1963 authToken: Option.getOrUndefined(authToken),
1947 });1964 });
1948 }),1965 }),
1949).pipe(Command.withDescription("Start a foreground web session"));1966).pipe(Command.withDescription("Open the Executor web UI"));
1950 1967
1951const daemonRunCommand = Command.make(1968const daemonRunCommand = Command.make(
1952 "run",1969 "run",
@@ -2108,80 +2125,86 @@ const mcpCommand = Command.make( · 155 unmodified lines
2108 2125
2109const supervisedServiceOrigin = (port: number): string => `http://127.0.0.1:${port}`;2126const supervisedServiceOrigin = (port: number): string => `http://127.0.0.1:${port}`;
2110 2127
2111const serviceInstallCommand = Command.make(2128const servicePortOption = () =>
2112 "install",2129 Options.integer("port")
2113 {2130 .pipe(Options.withDefault(DEFAULT_SERVICE_PORT))
2114 port: Options.integer("port")2131 .pipe(Options.withDescription("Port the supervised daemon binds (loopback only)."));
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 }
2137 2132
2138 // Don't fight an already-running local server against the same data dir2133const installService = (port: number, commandName: string) =>
2139 // (a desktop sidecar, a foreground `executor web`, or an existing daemon).2134 Effect.gen(function* () {
2140 const active = yield* readActiveLocalServerManifest();2135 const command = `${cliPrefix} ${commandName}`;
2141 if (active) {2136 if (isDevMode) {
2142 const status = yield* backend.status();2137 return yield* Effect.fail(
2143 if (status.registered && status.running && active.kind === "cli-daemon") {2138 new Error(
2144 console.log(2139 [
2145 `Executor service already running at ${active.connection.origin} (pid ${active.pid}).`,2140 `\`${command}\` requires the compiled \`executor\` binary so the OS can run it directly.`,
2146 );2141 `In a dev checkout, run \`${cliPrefix} daemon run --foreground\` instead.`,
2147 return;2142 ].join("\n"),
2148 }2143 ),
2149 return yield* Effect.fail(2144 );
2150 new Error(2145 }
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 }
2158 2146
2159 // The unit carries no secret: the supervised daemon mints/loads its bearer2147 const backend = getServiceBackend();
2160 // from auth.json (under EXECUTOR_DATA_DIR) on first boot, and clients read2148 if (!backend.automated) {
2161 // the same file — so reachability is the credential-free /api/health probe.2149 // Unsupported platforms surface their manual steps via the install error.
2162 yield* backend.install({ executablePath: process.execPath, port, version: CLI_VERSION });2150 yield* backend.install({ executablePath: process.execPath, port, version: CLI_VERSION });
2151 return;
2152 }
2163 2153
2164 const origin = supervisedServiceOrigin(port);2154 // Don't fight an already-running local server against the same data dir
2165 const reachable = yield* waitForReachable({2155 // (a desktop sidecar, a foreground `executor web`, or an existing daemon).
2166 check: isServerReachable(origin),2156 const active = yield* readActiveLocalServerManifest();
2167 timeoutMs: DAEMON_BOOT_TIMEOUT_MS,2157 if (active) {
2168 intervalMs: DAEMON_BOOT_POLL_MS,2158 const status = yield* backend.status();
2169 });2159 if (status.registered && status.running && active.kind === "cli-daemon") {
2170 if (!reachable) {2160 console.log(
2171 return yield* Effect.fail(2161 `Executor service already running at ${active.connection.origin} (pid ${active.pid}).`,
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 ),
2178 );2162 );
2163 return;
2179 }2164 }
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 }
2180 2174
2181 console.log(`Executor is now running as a background service at ${origin}.`);2175 // The unit carries no secret: the supervised daemon mints/loads its bearer
2182 console.log("It keeps serving after you quit the app and restarts on login.");2176 // from auth.json (under EXECUTOR_DATA_DIR) on first boot, and clients read
2183 console.log(`Open it in your browser, already signed in, with: ${cliPrefix} open`);2177 // the same file — so reachability is the credential-free /api/health probe.
2184 }),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
2202const serviceInstallCommand = Command.make(
2203 "install",
2204 {
2205 port: servicePortOption(),
2206 },
2207 ({ port }) => installService(port, "service install"),
2185).pipe(2208).pipe(
2186 Command.withDescription("Install and start Executor as an OS-supervised background service"),2209 Command.withDescription("Install and start Executor as an OS-supervised background service"),
2187);2210);
@@ -2240,6 +2263,16 @@ const serviceCommand = Command.make("service").pipe( · 52 unmodified lines
2240 Command.withDescription("Manage the OS-supervised background service"),2263 Command.withDescription("Manage the OS-supervised background service"),
2241);2264);
2242 2265
2266const 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
2243// ---------------------------------------------------------------------------2276// ---------------------------------------------------------------------------
2244// Root command2277// Root command
2245// ---------------------------------------------------------------------------2278// ---------------------------------------------------------------------------
@@ -2263,17 +2296,28 @@ const openInBrowser = (url: string): Effect.Effect<void> => · 17 unmodified lines
2263 execFile(cmd, [...args], () => {});2296 execFile(cmd, [...args], () => {});
2264 });2297 });
2265 2298
2266/**2299const printNoRunningLocalWebApp = (): void => {
2267 * `executor open` — the friendly way back in. Reads the running local server's2300 console.log("Executor is not running.");
2268 * manifest and opens the browser straight to its `?_token=` URL, so the user2301 console.log("");
2269 * never has to copy a bearer token out of a terminal or auth.json by hand.2302 console.log("Install and start the background service:");
2270 */2303 console.log(` ${cliPrefix} install`);
2271const openCommand = Command.make("open", {}, () =>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
2312const openRunningLocalWebApp = (): Effect.Effect<
2313 void,
2314 never,
2315 FileSystem.FileSystem | PlatformPath.Path
2316> =>
2272 Effect.gen(function* () {2317 Effect.gen(function* () {
2273 const manifest = yield* readActiveLocalServerManifest().pipe(Effect.orElseSucceed(() => null));2318 const manifest = yield* readActiveLocalServerManifest().pipe(Effect.orElseSucceed(() => null));
2274 if (!manifest) {2319 if (!manifest) {
2275 console.log("No local Executor server is running.");2320 printNoRunningLocalWebApp();
2276 console.log(`Start one with: ${cliPrefix} web`);
2277 return;2321 return;
2278 }2322 }
2279 const { origin, auth } = manifest.connection;2323 const { origin, auth } = manifest.connection;
@@ -2281,8 +2325,14 @@ const openCommand = Command.make("open", {}, () => · 1 unmodified line
2281 const url = token ? `${origin}/?_token=${token}` : origin;2325 const url = token ? `${origin}/?_token=${token}` : origin;
2282 console.log(`Opening ${url}`);2326 console.log(`Opening ${url}`);
2283 yield* openInBrowser(url);2327 yield* openInBrowser(url);
2284 }),2328 });
2285).pipe(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 */
2335const openCommand = Command.make("open", {}, () => openRunningLocalWebApp()).pipe(
2286 Command.withDescription("Open the running Executor web app in your browser, already signed in"),2336 Command.withDescription("Open the running Executor web app in your browser, already signed in"),
2287);2337);
2288 2338
@@ -2291,6 +2341,7 @@ const root = Command.make("executor").pipe( · 2 unmodified lines
2291 callCommand,2341 callCommand,
2292 resumeCommand,2342 resumeCommand,
2293 toolsCommand,2343 toolsCommand,
2344 installCommand,
2294 serverCommand,2345 serverCommand,
2295 webCommand,2346 webCommand,
2296 daemonCommand,2347 daemonCommand,