| @@ -1916,28 +1916,45 @@ const serverCommand = Command.make("server").pipe( · 1915 unmodified lines |
| 1916 | const webCommand = Command.make( | 1916 | const 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 | |
| 1951 | const daemonRunCommand = Command.make( | 1968 | const daemonRunCommand = Command.make( |
| 1952 | "run", | 1969 | "run", |
| @@ -2108,80 +2125,86 @@ const mcpCommand = Command.make( · 155 unmodified lines |
| 2108 | | 2125 | |
| 2109 | const supervisedServiceOrigin = (port: number): string => `http://127.0.0.1:${port}`; | 2126 | const supervisedServiceOrigin = (port: number): string => `http://127.0.0.1:${port}`; |
| 2110 | | 2127 | |
| 2111 | const serviceInstallCommand = Command.make( | 2128 | const 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 dir | 2133 | const 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 bearer | 2147 | const backend = getServiceBackend(); |
| 2160 | // from auth.json (under EXECUTOR_DATA_DIR) on first boot, and clients read | 2148 | 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 | |
| | 2202 | const 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 | |
| | 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 | // Root command | 2277 | // 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 | /** | 2299 | const printNoRunningLocalWebApp = (): void => { |
| 2267 | * `executor open` — the friendly way back in. Reads the running local server's | 2300 | console.log("Executor is not running."); |
| 2268 | * manifest and opens the browser straight to its `?_token=` URL, so the user | 2301 | 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`); |
| 2271 | const 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 | |
| | 2312 | const 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 | */ |
| | 2335 | const 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, |