| @@ -1916,28 +1916,45 @@ const serverCommand = Command.make("server").pipe( · 1915 unmodified lines |
| 1916 | 1916 | const webCommand = Command.make( |
| 1917 | 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 | 1929 | hostname: Options.string("hostname") |
| 1921 | 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 | 1936 | allowedHost: Options.string("allowed-host") |
| 1924 | 1937 | .pipe(Options.atLeast(0)) |
| 1925 | 1938 | .pipe( |
| 1926 | 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 | 1943 | authToken: Options.string("auth-token") |
| 1931 | 1944 | .pipe(Options.optional) |
| 1932 | 1945 | .pipe( |
| 1933 | 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 | 1950 | scope, |
| 1938 | 1951 | }, |
| 1939 | | − ({ port, scope, hostname, allowedHost, authToken }) => |
| 1952 | + ({ foreground, port, scope, hostname, allowedHost, authToken }) => |
| 1940 | 1953 | Effect.gen(function* () { |
| 1954 | + if (!foreground) { |
| 1955 | + yield* openRunningLocalWebApp(); |
| 1956 | + return; |
| 1957 | + } |
| 1941 | 1958 | applyScope(scope); |
| 1942 | 1959 | yield* runForegroundSession({ |
| 1943 | 1960 | port, |
| @@ -1946,7 +1963,7 @@ const webCommand = Command.make( · 2 unmodified lines |
| 1946 | 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 | |
| 1951 | 1968 | const daemonRunCommand = Command.make( |
| 1952 | 1969 | "run", |
| @@ -2108,80 +2125,86 @@ const mcpCommand = Command.make( · 155 unmodified lines |
| 2108 | 2125 | |
| 2109 | 2126 | const supervisedServiceOrigin = (port: number): string => `http://127.0.0.1:${port}`; |
| 2110 | 2127 | |
| 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).")); |
| 2137 | 2132 | |
| 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 | + } |
| 2158 | 2146 | |
| 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. |
| 2162 | 2150 | yield* backend.install({ executablePath: process.execPath, port, version: CLI_VERSION }); |
| 2151 | + return; |
| 2152 | + } |
| 2163 | 2153 | |
| 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}).`, |
| 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}.`); |
| 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"), |
| 2185 | 2208 | ).pipe( |
| 2186 | 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 | 2263 | Command.withDescription("Manage the OS-supervised background service"), |
| 2241 | 2264 | ); |
| 2242 | 2265 | |
| 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 | + |
| 2243 | 2276 | // --------------------------------------------------------------------------- |
| 2244 | 2277 | // Root command |
| 2245 | 2278 | // --------------------------------------------------------------------------- |
| @@ -2263,17 +2296,28 @@ const openInBrowser = (url: string): Effect.Effect<void> => · 17 unmodified lines |
| 2263 | 2296 | execFile(cmd, [...args], () => {}); |
| 2264 | 2297 | }); |
| 2265 | 2298 | |
| 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 | +> => |
| 2272 | 2317 | Effect.gen(function* () { |
| 2273 | 2318 | const manifest = yield* readActiveLocalServerManifest().pipe(Effect.orElseSucceed(() => null)); |
| 2274 | 2319 | if (!manifest) { |
| 2275 | | − console.log("No local Executor server is running."); |
| 2276 | | − console.log(`Start one with: ${cliPrefix} web`); |
| 2320 | + printNoRunningLocalWebApp(); |
| 2277 | 2321 | return; |
| 2278 | 2322 | } |
| 2279 | 2323 | const { origin, auth } = manifest.connection; |
| @@ -2281,8 +2325,14 @@ const openCommand = Command.make("open", {}, () => · 1 unmodified line |
| 2281 | 2325 | const url = token ? `${origin}/?_token=${token}` : origin; |
| 2282 | 2326 | console.log(`Opening ${url}`); |
| 2283 | 2327 | 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( |
| 2286 | 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 | 2341 | callCommand, |
| 2292 | 2342 | resumeCommand, |
| 2293 | 2343 | toolsCommand, |
| 2344 | + installCommand, |
| 2294 | 2345 | serverCommand, |
| 2295 | 2346 | webCommand, |
| 2296 | 2347 | daemonCommand, |