History

Replace repository contents with v4 snapshot

Rhys Sullivan committed 7 months ago

Commit 4fcdba41 parent be4bbf5865 files · +36247 −404676Browse files

Showing the changes to apps/cli/src (4 of 865 files). Show all

Unified · Split

4 files
  1. apps/cli/src/build.ts +465 −0
  2. apps/cli/src/embedded-web-ui.gen.d.ts +4 −0
  3. apps/cli/src/main.ts +388 −0
  4. apps/cli/src/release.ts +270 −0
apps/cli/src/build.ts+465−0
@@ -0,0 +1,465 @@
1+import { cp, mkdir, rm, writeFile, chmod } from "node:fs/promises";
2+import { existsSync } from "node:fs";
3+import { createRequire } from "node:module";
4+import { resolve, join, dirname } from "node:path";
5+import { fileURLToPath } from "node:url";
6+import { $ } from "bun";
7+
8+const repoRoot = resolve(dirname(fileURLToPath(import.meta.url)), "../../..");
9+const cliRoot = resolve(repoRoot, "apps/cli");
10+const webRoot = resolve(repoRoot, "apps/web");
11+const distDir = resolve(cliRoot, "dist");
12+
13+const resolveQuickJsWasmPath = (): string => {
14+ const req = createRequire(join(repoRoot, "packages/kernel/runtime-quickjs/package.json"));
15+ const quickJsPkg = req.resolve("quickjs-emscripten/package.json");
16+ const wasmPath = resolve(
17+ dirname(quickJsPkg),
18+ "../@jitl/quickjs-wasmfile-release-sync/dist/emscripten-module.wasm",
19+ );
20+ if (!existsSync(wasmPath)) throw new Error(`QuickJS WASM not found at ${wasmPath}`);
21+ return wasmPath;
22+};
23+
24+// ---------------------------------------------------------------------------
25+// Metadata
26+// ---------------------------------------------------------------------------
27+
28+const readMetadata = async () => {
29+ const rootPkg = await Bun.file(join(repoRoot, "package.json")).json();
30+ const cliPkg = await Bun.file(join(cliRoot, "package.json")).json();
31+ return {
32+ name: "executor",
33+ version: process.env.EXECUTOR_VERSION ?? cliPkg.version ?? rootPkg.version ?? "0.0.0",
34+ description: rootPkg.description ?? "Local AI executor with a CLI, local API server, and web UI.",
35+ keywords: rootPkg.keywords ?? [],
36+ homepage: rootPkg.homepage,
37+ bugs: rootPkg.bugs,
38+ repository: rootPkg.repository,
39+ license: rootPkg.license ?? "MIT",
40+ };
41+};
42+
43+// ---------------------------------------------------------------------------
44+// Targets
45+// ---------------------------------------------------------------------------
46+
47+type Target = {
48+ os: "linux" | "darwin" | "win32";
49+ arch: "x64" | "arm64";
50+ abi?: "musl";
51+};
52+
53+const ALL_TARGETS: Target[] = [
54+ { os: "linux", arch: "x64" },
55+ { os: "linux", arch: "arm64" },
56+ { os: "linux", arch: "x64", abi: "musl" },
57+ { os: "linux", arch: "arm64", abi: "musl" },
58+ { os: "darwin", arch: "x64" },
59+ { os: "darwin", arch: "arm64" },
60+ { os: "win32", arch: "x64" },
61+ { os: "win32", arch: "arm64" },
62+];
63+
64+const platformName = (t: Target) =>
65+ t.os === "win32" ? "windows" : t.os;
66+
67+const targetPackageName = (t: Target) =>
68+ ["executor", platformName(t), t.arch, t.abi].filter(Boolean).join("-");
69+
70+const bunTarget = (t: Target) =>
71+ ["bun", platformName(t), t.arch, t.abi].filter(Boolean).join("-");
72+
73+const binaryName = (t: Target) =>
74+ t.os === "win32" ? "executor.exe" : "executor";
75+
76+const isCurrentPlatform = (t: Target) =>
77+ t.os === process.platform && t.arch === process.arch && !t.abi;
78+
79+// ---------------------------------------------------------------------------
80+// Build web app
81+// ---------------------------------------------------------------------------
82+
83+const buildWeb = async () => {
84+ const webDist = join(webRoot, "dist");
85+ if (existsSync(webDist)) return webDist;
86+
87+ console.log("Building web app...");
88+ const proc = Bun.spawn(["bun", "run", "build"], { cwd: webRoot, stdio: ["ignore", "inherit", "inherit"] });
89+ if ((await proc.exited) !== 0) throw new Error("Web build failed");
90+ return webDist;
91+};
92+
93+// ---------------------------------------------------------------------------
94+// Embedded web UI — generates a virtual module that imports all web assets
95+// using `with { type: "file" }` so Bun bakes them into the compiled binary.
96+// ---------------------------------------------------------------------------
97+
98+const createEmbeddedWebUISource = async () => {
99+ const webDist = await buildWeb();
100+ const files = (await Array.fromAsync(new Bun.Glob("**/*").scan({ cwd: webDist })))
101+ .map((f) => f.replaceAll("\\", "/"))
102+ .sort();
103+
104+ const imports = files.map((file, i) => {
105+ const spec = join(webDist, file).replaceAll("\\", "/");
106+ return `import file_${i} from ${JSON.stringify(spec)} with { type: "file" };`;
107+ });
108+
109+ const entries = files.map((file, i) => ` ${JSON.stringify(file)}: file_${i},`);
110+
111+ return [
112+ "// Auto-generated — maps web UI paths to embedded file references",
113+ ...imports,
114+ "export default {",
115+ ...entries,
116+ "} as Record<string, string>;",
117+ ].join("\n");
118+};
119+
120+// ---------------------------------------------------------------------------
121+// Build platform binaries
122+// ---------------------------------------------------------------------------
123+
124+const buildBinaries = async (targets: Target[]) => {
125+ const meta = await readMetadata();
126+ const binaries: Record<string, string> = {};
127+
128+ await rm(distDir, { recursive: true, force: true });
129+
130+ console.log("Generating embedded web UI bundle...");
131+ const embeddedWebUI = await createEmbeddedWebUISource();
132+ const quickJsWasmPath = resolveQuickJsWasmPath();
133+
134+ for (const target of targets) {
135+ const name = targetPackageName(target);
136+ const outDir = join(distDir, name);
137+ const binDir = join(outDir, "bin");
138+ await mkdir(binDir, { recursive: true });
139+
140+ console.log(`Building ${name}...`);
141+
142+ await Bun.build({
143+ entrypoints: [join(cliRoot, "src/main.ts"), "embedded-web-ui.gen.ts"],
144+ minify: true,
145+ files: {
146+ "embedded-web-ui.gen.ts": embeddedWebUI,
147+ },
148+ compile: {
149+ target: bunTarget(target) as any,
150+ outfile: join(binDir, binaryName(target)),
151+ },
152+ });
153+
154+ // Copy QuickJS WASM next to binary — loaded at runtime by the server
155+ await cp(quickJsWasmPath, join(binDir, "emscripten-module.wasm"));
156+
157+ // Smoke test on current platform
158+ if (isCurrentPlatform(target)) {
159+ const bin = join(binDir, binaryName(target));
160+ console.log(` Smoke test: ${bin} --version`);
161+ const version = await $`${bin} --version`.text();
162+ console.log(` OK: ${version.trim()}`);
163+ }
164+
165+ // Platform package.json
166+ await writeFile(
167+ join(outDir, "package.json"),
168+ JSON.stringify(
169+ {
170+ name,
171+ version: meta.version,
172+ os: [target.os],
173+ cpu: [target.arch],
174+ bin: { executor: `bin/${binaryName(target)}` },
175+ },
176+ null,
177+ 2,
178+ ) + "\n",
179+ );
180+
181+ binaries[name] = meta.version;
182+ }
183+
184+ return binaries;
185+};
186+
187+// ---------------------------------------------------------------------------
188+// Build wrapper npm package
189+// ---------------------------------------------------------------------------
190+
191+const buildWrapperPackage = async (binaries: Record<string, string>) => {
192+ const meta = await readMetadata();
193+ const wrapperDir = join(distDir, meta.name);
194+ const binDir = join(wrapperDir, "bin");
195+
196+ await mkdir(binDir, { recursive: true });
197+
198+ // Node.js shim that finds + spawns the right platform binary
199+ await writeFile(join(binDir, "executor"), NODE_SHIM);
200+ await chmod(join(binDir, "executor"), 0o755);
201+
202+ // Postinstall: hardlink the platform binary for faster startup
203+ await writeFile(join(wrapperDir, "postinstall.mjs"), POSTINSTALL_SCRIPT);
204+
205+ // Package.json
206+ await writeFile(
207+ join(wrapperDir, "package.json"),
208+ JSON.stringify(
209+ {
210+ name: meta.name,
211+ version: meta.version,
212+ description: meta.description,
213+ keywords: meta.keywords,
214+ homepage: meta.homepage,
215+ bugs: meta.bugs,
216+ repository: meta.repository,
217+ license: meta.license,
218+ bin: { executor: "bin/executor" },
219+ scripts: {
220+ postinstall: "node ./postinstall.mjs",
221+ },
222+ optionalDependencies: binaries,
223+ },
224+ null,
225+ 2,
226+ ) + "\n",
227+ );
228+
229+ // README
230+ const readmePath = join(repoRoot, "README.md");
231+ if (existsSync(readmePath)) {
232+ await cp(readmePath, join(wrapperDir, "README.md"));
233+ }
234+
235+ console.log(`\nWrapper package: ${wrapperDir}`);
236+ console.log(` ${meta.name}@${meta.version}`);
237+ console.log(` optionalDependencies: ${Object.keys(binaries).join(", ")}`);
238+};
239+
240+// ---------------------------------------------------------------------------
241+// Publish
242+// ---------------------------------------------------------------------------
243+
244+const packageAlreadyPublished = async (pkgDir: string) => {
245+ const pkg = await Bun.file(join(pkgDir, "package.json")).json() as {
246+ name?: string;
247+ version?: string;
248+ };
249+
250+ if (!pkg.name || !pkg.version) {
251+ throw new Error(`Missing name/version in ${join(pkgDir, "package.json")}`);
252+ }
253+
254+ const proc = Bun.spawn(["npm", "view", `${pkg.name}@${pkg.version}`, "version"], {
255+ cwd: pkgDir,
256+ stdio: ["ignore", "ignore", "ignore"],
257+ });
258+
259+ return (await proc.exited) === 0;
260+};
261+
262+const publishPackedPackage = async (pkgDir: string, channel: string) => {
263+ if (await packageAlreadyPublished(pkgDir)) {
264+ console.log(`Skipping ${pkgDir}; package version already exists on npm.`);
265+ return;
266+ }
267+
268+ await $`bun pm pack`.cwd(pkgDir);
269+
270+ if (process.env.GITHUB_ACTIONS === "true") {
271+ await $`npm publish *.tgz --access public --tag ${channel} --provenance`.cwd(pkgDir);
272+ } else {
273+ await $`npm publish *.tgz --access public --tag ${channel}`.cwd(pkgDir);
274+ }
275+};
276+
277+const publish = async (channel: string) => {
278+ const meta = await readMetadata();
279+
280+ // Publish platform packages
281+ for (const entry of new Bun.Glob("executor-*/package.json").scanSync({ cwd: distDir })) {
282+ const pkgDir = join(distDir, dirname(entry));
283+ console.log(`Publishing ${pkgDir}...`);
284+ await publishPackedPackage(pkgDir, channel);
285+ }
286+
287+ // Publish wrapper package
288+ const wrapperDir = join(distDir, meta.name);
289+ console.log(`Publishing ${wrapperDir}...`);
290+ await publishPackedPackage(wrapperDir, channel);
291+};
292+
293+// ---------------------------------------------------------------------------
294+// GitHub release assets
295+// ---------------------------------------------------------------------------
296+
297+const ZIP_ASSET_SCRIPT = [
298+ "import pathlib, sys, zipfile",
299+ "output = pathlib.Path(sys.argv[1])",
300+ "with zipfile.ZipFile(output, 'w', compression=zipfile.ZIP_DEFLATED) as archive:",
301+ " for path in pathlib.Path('.').rglob('*'):",
302+ " if path.is_file():",
303+ " archive.write(path, path.as_posix())",
304+].join("\n");
305+
306+const createReleaseAssets = async () => {
307+ for (const entry of new Bun.Glob("executor-*/package.json").scanSync({ cwd: distDir })) {
308+ const pkgDir = join(distDir, dirname(entry));
309+ const pkg = await Bun.file(join(pkgDir, "package.json")).json();
310+ const name = pkg.name as string;
311+
312+ if (name.includes("linux")) {
313+ await $`tar -czf ${join(distDir, `${name}.tar.gz`)} *`.cwd(join(pkgDir, "bin"));
314+ } else {
315+ await $`python3 -c ${ZIP_ASSET_SCRIPT} ${join(distDir, `${name}.zip`)}`.cwd(join(pkgDir, "bin"));
316+ }
317+
318+ console.log(`Created release asset: ${name}`);
319+ }
320+};
321+
322+// ---------------------------------------------------------------------------
323+// Node.js shim — finds the right platform binary and spawns it
324+// ---------------------------------------------------------------------------
325+
326+const NODE_SHIM = `#!/usr/bin/env node
327+const childProcess = require("child_process");
328+const fs = require("fs");
329+const path = require("path");
330+const os = require("os");
331+
332+function run(target) {
333+ const result = childProcess.spawnSync(target, process.argv.slice(2), { stdio: "inherit" });
334+ if (result.error) { console.error(result.error.message); process.exit(1); }
335+ process.exit(typeof result.status === "number" ? result.status : 0);
336+}
337+
338+// Check env override
339+if (process.env.EXECUTOR_BIN_PATH) run(process.env.EXECUTOR_BIN_PATH);
340+
341+// Check cached binary from postinstall
342+const scriptDir = path.dirname(fs.realpathSync(__filename));
343+const cached = path.join(scriptDir, ".executor");
344+if (fs.existsSync(cached)) run(cached);
345+
346+// Resolve platform
347+const platformMap = { darwin: "darwin", linux: "linux", win32: "windows" };
348+const platform = platformMap[os.platform()] || os.platform();
349+const arch = os.arch() === "arm64" ? "arm64" : "x64";
350+const binary = platform === "windows" ? "executor.exe" : "executor";
351+
352+// Detect musl
353+const isMusl = (() => {
354+ if (platform !== "linux") return false;
355+ try { if (fs.existsSync("/etc/alpine-release")) return true; } catch {}
356+ try {
357+ const r = childProcess.spawnSync("ldd", ["--version"], { encoding: "utf8" });
358+ if (((r.stdout || "") + (r.stderr || "")).toLowerCase().includes("musl")) return true;
359+ } catch {}
360+ return false;
361+})();
362+
363+// Build candidate list
364+const base = "executor-" + platform + "-" + arch;
365+const names = (() => {
366+ if (platform === "linux" && isMusl) {
367+ return [base + "-musl", base];
368+ }
369+ if (platform === "linux") {
370+ return [base, base + "-musl"];
371+ }
372+ return [base];
373+})();
374+
375+// Walk up to find node_modules
376+function findBinary(startDir) {
377+ let current = startDir;
378+ for (;;) {
379+ const modules = path.join(current, "node_modules");
380+ if (fs.existsSync(modules)) {
381+ for (const name of names) {
382+ const candidate = path.join(modules, name, "bin", binary);
383+ if (fs.existsSync(candidate)) return candidate;
384+ }
385+ }
386+ const parent = path.dirname(current);
387+ if (parent === current) return;
388+ current = parent;
389+ }
390+}
391+
392+const resolved = findBinary(scriptDir);
393+if (!resolved) {
394+ console.error("Could not find executor binary for your platform. Try installing " + names.map(n => '"' + n + '"').join(" or "));
395+ process.exit(1);
396+}
397+run(resolved);
398+`;
399+
400+// ---------------------------------------------------------------------------
401+// Postinstall — hardlink/copy the platform binary for fast startup
402+// ---------------------------------------------------------------------------
403+
404+const POSTINSTALL_SCRIPT = `#!/usr/bin/env node
405+const fs = require("fs");
406+const path = require("path");
407+const os = require("os");
408+const { createRequire } = require("module");
409+
410+const __dirname_resolved = path.dirname(fs.realpathSync(__filename));
411+const require_ = createRequire(__filename);
412+
413+const platformMap = { darwin: "darwin", linux: "linux", win32: "windows" };
414+const platform = platformMap[os.platform()] || os.platform();
415+const arch = os.arch() === "arm64" ? "arm64" : "x64";
416+const binary = platform === "windows" ? "executor.exe" : "executor";
417+const base = "executor-" + platform + "-" + arch;
418+
419+const names = platform === "linux" ? [base, base + "-musl"] : [base];
420+
421+for (const name of names) {
422+ try {
423+ const pkgJson = require_.resolve(name + "/package.json");
424+ const binaryPath = path.join(path.dirname(pkgJson), "bin", binary);
425+ if (!fs.existsSync(binaryPath)) continue;
426+
427+ const target = path.join(__dirname_resolved, "bin", ".executor");
428+ const binDir = path.join(__dirname_resolved, "bin");
429+ if (!fs.existsSync(binDir)) fs.mkdirSync(binDir, { recursive: true });
430+ if (fs.existsSync(target)) fs.unlinkSync(target);
431+
432+ try { fs.linkSync(binaryPath, target); }
433+ catch { fs.copyFileSync(binaryPath, target); }
434+ fs.chmodSync(target, 0o755);
435+ console.log("executor: binary linked for " + name);
436+ break;
437+ } catch {}
438+}
439+`;
440+
441+// ---------------------------------------------------------------------------
442+// CLI
443+// ---------------------------------------------------------------------------
444+
445+const command = process.argv[2];
446+const singleFlag = process.argv.includes("--single");
447+
448+if (command === "binary") {
449+ const targets = singleFlag
450+ ? ALL_TARGETS.filter(isCurrentPlatform)
451+ : ALL_TARGETS;
452+ const binaries = await buildBinaries(targets);
453+ await buildWrapperPackage(binaries);
454+} else if (command === "release-assets") {
455+ await createReleaseAssets();
456+} else if (command === "publish") {
457+ const channel = process.argv[3] ?? "latest";
458+ await publish(channel);
459+} else {
460+ console.log(`Usage:
461+ bun run build.ts binary [--single] Build platform binaries + wrapper package
462+ bun run build.ts release-assets Create .tar.gz/.zip from built binaries
463+ bun run build.ts publish [channel] Publish all packages to npm`);
464+ process.exit(1);
465+}
apps/cli/src/embedded-web-ui.gen.d.ts+4−0
@@ -0,0 +1,4 @@
1+declare module "embedded-web-ui.gen.ts" {
2+ const files: Record<string, string>;
3+ export default files;
4+}
apps/cli/src/main.ts+388−0
@@ -0,0 +1,388 @@
1+// Pre-load QuickJS WASM for compiled binaries — must run before server imports
2+import { dirname, join } from "node:path";
3+const wasmOnDisk = join(dirname(process.execPath), "emscripten-module.wasm");
4+if (typeof Bun !== "undefined" && await Bun.file(wasmOnDisk).exists()) {
5+ const { setQuickJSModule } = await import("@executor/runtime-quickjs");
6+ const { newQuickJSWASMModule } = await import("quickjs-emscripten");
7+ const wasmBinary = await Bun.file(wasmOnDisk).arrayBuffer();
8+ const variant = {
9+ type: "sync" as const,
10+ importFFI: () => import("@jitl/quickjs-wasmfile-release-sync/ffi").then((m: any) => m.QuickJSFFI),
11+ importModuleLoader: () =>
12+ import("@jitl/quickjs-wasmfile-release-sync/emscripten-module").then((m: any) => {
13+ const original = m.default;
14+ return (moduleArg: any = {}) => original({ ...moduleArg, wasmBinary });
15+ }),
16+ };
17+ const mod = await newQuickJSWASMModule(variant as any);
18+ setQuickJSModule(mod);
19+}
20+
21+import { spawn } from "node:child_process";
22+import { resolve } from "node:path";
23+import { Command, Options, Args } from "@effect/cli";
24+import { BunRuntime } from "@effect/platform-bun";
25+import { FetchHttpClient, HttpApiClient } from "@effect/platform";
26+import * as Effect from "effect/Effect";
27+import * as Option from "effect/Option";
28+import * as Cause from "effect/Cause";
29+
30+import { ExecutorApi } from "@executor/api";
31+import { createServerHandlers, runMcpStdioServer, getExecutor } from "@executor/server";
32+
33+// ---------------------------------------------------------------------------
34+// Constants
35+// ---------------------------------------------------------------------------
36+
37+const CLI_NAME = "executor";
38+const { version: CLI_VERSION } = await import("../package.json");
39+const DEFAULT_PORT = 8788;
40+const DEFAULT_BASE_URL = `http://localhost:${DEFAULT_PORT}`;
41+
42+// Embedded web UI — baked into compiled binaries via `with { type: "file" }`
43+const embeddedWebUI: Record<string, string> | null =
44+ await import("embedded-web-ui.gen.ts")
45+ .then((m) => m.default as Record<string, string>)
46+ .catch(() => null);
47+
48+// ---------------------------------------------------------------------------
49+// Helpers
50+// ---------------------------------------------------------------------------
51+
52+const waitForShutdownSignal = () =>
53+ Effect.async<void, never>((resume) => {
54+ const shutdown = () => resume(Effect.void);
55+ process.once("SIGINT", shutdown);
56+ process.once("SIGTERM", shutdown);
57+ return Effect.sync(() => {
58+ process.off("SIGINT", shutdown);
59+ process.off("SIGTERM", shutdown);
60+ });
61+ });
62+
63+const appendUrlPath = (baseUrl: string, pathname: string): string =>
64+ new URL(pathname, baseUrl.endsWith("/") ? baseUrl : `${baseUrl}/`).toString();
65+
66+const renderSessionSummary = (kind: "web" | "mcp", baseUrl: string): string => {
67+ const displayKind = kind === "mcp" ? "MCP" : "web";
68+ const primaryLabel = kind === "web" ? "Web" : "MCP";
69+ const primaryUrl = kind === "web" ? baseUrl : appendUrlPath(baseUrl, "mcp");
70+ const secondaryLabel = kind === "web" ? "MCP" : "Web";
71+ const secondaryUrl = kind === "web" ? appendUrlPath(baseUrl, "mcp") : baseUrl;
72+ const guidance =
73+ kind === "web"
74+ ? "Keep this process running while you use the browser session."
75+ : "Use this MCP URL in your client and keep this process running.";
76+
77+ return [
78+ `Executor ${displayKind} session is ready.`,
79+ `${primaryLabel}: ${primaryUrl}`,
80+ `${secondaryLabel}: ${secondaryUrl}`,
81+ `OpenAPI: ${appendUrlPath(baseUrl, "docs")}`,
82+ "",
83+ guidance,
84+ "Press Ctrl+C to stop.",
85+ ].join("\n");
86+};
87+
88+// ---------------------------------------------------------------------------
89+// Background server management
90+// ---------------------------------------------------------------------------
91+
92+const isServerReachable = async (baseUrl: string): Promise<boolean> => {
93+ try {
94+ const res = await fetch(`${baseUrl}/docs`, { signal: AbortSignal.timeout(2000) });
95+ return res.ok;
96+ } catch {
97+ return false;
98+ }
99+};
100+
101+const script = process.argv[1];
102+const isDevMode = script?.endsWith(".ts") || script?.endsWith(".js");
103+const cliPrefix = isDevMode ? `bun run ${script}` : "executor";
104+
105+const startBackgroundServer = (port: number): void => {
106+ const args = isDevMode
107+ ? ["run", script, "web", "--port", String(port)]
108+ : ["web", "--port", String(port)];
109+ const child = spawn(process.execPath, args, {
110+ detached: true,
111+ stdio: "ignore",
112+ });
113+ child.unref();
114+};
115+
116+const ensureServer = (baseUrl: string) =>
117+ Effect.gen(function* () {
118+ if (yield* Effect.promise(() => isServerReachable(baseUrl))) return;
119+
120+ const url = new URL(baseUrl);
121+ const port = Number(url.port) || DEFAULT_PORT;
122+ console.error(`Starting background server on port ${port}...`);
123+ startBackgroundServer(port);
124+
125+ const deadline = Date.now() + 30_000;
126+ while (Date.now() < deadline) {
127+ yield* Effect.promise(() => new Promise((r) => setTimeout(r, 200)));
128+ if (yield* Effect.promise(() => isServerReachable(baseUrl))) return;
129+ }
130+
131+ return yield* Effect.fail(new Error(`Server failed to start within 30s at ${baseUrl}`));
132+ });
133+
134+// ---------------------------------------------------------------------------
135+// Typed API client
136+// ---------------------------------------------------------------------------
137+
138+const makeApiClient = (baseUrl: string) =>
139+ HttpApiClient.make(ExecutorApi, { baseUrl }).pipe(
140+ Effect.provide(FetchHttpClient.layer),
141+ );
142+
143+// ---------------------------------------------------------------------------
144+// Static file serving from embedded web UI
145+// ---------------------------------------------------------------------------
146+
147+const WEB_DIST_DIR = resolve(import.meta.dirname, "../../web/dist");
148+
149+const serveStatic = async (pathname: string): Promise<Response | null> => {
150+ const key = pathname.replace(/^\//, "");
151+
152+ // Compiled binary: serve from embedded bunfs
153+ if (embeddedWebUI) {
154+ const match = embeddedWebUI[key] ?? embeddedWebUI["index.html"] ?? null;
155+ if (!match) return null;
156+ const file = Bun.file(match);
157+ if (await file.exists()) {
158+ return new Response(file, {
159+ headers: { "content-type": file.type || "application/octet-stream" },
160+ });
161+ }
162+ return null;
163+ }
164+
165+ // Dev mode: serve from apps/web/dist on disk
166+ const filePath = resolve(WEB_DIST_DIR, key);
167+ if (!filePath.startsWith(WEB_DIST_DIR)) return null;
168+
169+ const file = Bun.file(filePath);
170+ if (await file.exists()) {
171+ return new Response(file, {
172+ headers: { "content-type": file.type || "application/octet-stream" },
173+ });
174+ }
175+
176+ // SPA fallback
177+ const index = Bun.file(resolve(WEB_DIST_DIR, "index.html"));
178+ if (await index.exists()) {
179+ return new Response(index, { headers: { "content-type": "text/html" } });
180+ }
181+
182+ return null;
183+};
184+
185+// ---------------------------------------------------------------------------
186+// Foreground session — API + MCP + Web UI on one Bun.serve()
187+// ---------------------------------------------------------------------------
188+
189+const runForegroundSession = (input: { kind: "web" | "mcp"; port: number }) =>
190+ Effect.gen(function* () {
191+ const handlers = yield* Effect.promise(() => createServerHandlers());
192+
193+ const server = Bun.serve({
194+ port: input.port,
195+ async fetch(request) {
196+ const url = new URL(request.url);
197+
198+ if (url.pathname.startsWith("/mcp")) {
199+ return handlers.mcp.handleRequest(request);
200+ }
201+
202+ if (
203+ url.pathname.startsWith("/v1/") ||
204+ url.pathname.startsWith("/docs") ||
205+ url.pathname === "/openapi.json"
206+ ) {
207+ return handlers.api.handler(request);
208+ }
209+
210+ const staticResponse = await serveStatic(url.pathname);
211+ if (staticResponse) return staticResponse;
212+
213+ return new Response("Not Found", { status: 404 });
214+ },
215+ });
216+
217+ const baseUrl = `http://localhost:${server.port}`;
218+ console.log(renderSessionSummary(input.kind, baseUrl));
219+
220+ yield* waitForShutdownSignal();
221+
222+ server.stop(true);
223+ yield* Effect.promise(() => handlers.mcp.close());
224+ yield* Effect.promise(() => handlers.api.dispose());
225+ });
226+
227+// ---------------------------------------------------------------------------
228+// Stdio MCP session
229+// ---------------------------------------------------------------------------
230+
231+const runStdioMcpSession = () =>
232+ Effect.gen(function* () {
233+ const executor = yield* Effect.promise(() => getExecutor());
234+ yield* Effect.promise(() => runMcpStdioServer({ executor }));
235+ });
236+
237+// ---------------------------------------------------------------------------
238+// Code resolution — positional arg > --file > stdin
239+// ---------------------------------------------------------------------------
240+
241+const readCode = (input: {
242+ code: Option.Option<string>;
243+ file: Option.Option<string>;
244+ stdin: boolean;
245+}): Effect.Effect<string, Error> =>
246+ Effect.gen(function* () {
247+ const code = Option.getOrUndefined(input.code);
248+ if (code && code.trim().length > 0) return code;
249+
250+ const file = Option.getOrUndefined(input.file);
251+ if (file && file.trim().length > 0) {
252+ const contents = yield* Effect.tryPromise({
253+ try: () => Bun.file(file).text(),
254+ catch: (e) => new Error(`Failed to read file: ${e}`),
255+ });
256+ if (contents.trim().length > 0) return contents;
257+ }
258+
259+ if (input.stdin || !process.stdin.isTTY) {
260+ const chunks: string[] = [];
261+ process.stdin.setEncoding("utf8");
262+ const contents = yield* Effect.tryPromise({
263+ try: async () => {
264+ for await (const chunk of process.stdin) chunks.push(chunk as string);
265+ return chunks.join("");
266+ },
267+ catch: (e) => new Error(`Failed to read stdin: ${e}`),
268+ });
269+ if (contents.trim().length > 0) return contents;
270+ }
271+
272+ return yield* Effect.fail(
273+ new Error("No code provided. Pass code as an argument, --file, or pipe to stdin."),
274+ );
275+ });
276+
277+// ---------------------------------------------------------------------------
278+// Commands
279+// ---------------------------------------------------------------------------
280+
281+const callCommand = Command.make(
282+ "call",
283+ {
284+ code: Args.text({ name: "code" }).pipe(Args.optional),
285+ file: Options.text("file").pipe(Options.optional),
286+ stdin: Options.boolean("stdin").pipe(Options.withDefault(false)),
287+ baseUrl: Options.text("base-url").pipe(Options.withDefault(DEFAULT_BASE_URL)),
288+ },
289+ ({ code, file, stdin, baseUrl }) =>
290+ Effect.gen(function* () {
291+ const resolvedCode = yield* readCode({ code, file, stdin });
292+ yield* ensureServer(baseUrl);
293+
294+ const client = yield* makeApiClient(baseUrl);
295+ const result = yield* client.executions.execute({ payload: { code: resolvedCode } });
296+
297+ if (result.status === "completed") {
298+ if (result.isError) {
299+ console.error(result.text);
300+ process.exitCode = 1;
301+ } else {
302+ console.log(result.text);
303+ }
304+ } else {
305+ console.log(result.text);
306+ const executionId = (result.structured as any)?.executionId;
307+ if (executionId) {
308+ console.log(
309+ `\nTo resume:\n ${cliPrefix} resume --execution-id ${executionId} --action accept`,
310+ );
311+ }
312+ }
313+ }),
314+).pipe(Command.withDescription("Execute code against the local executor"));
315+
316+const resumeCommand = Command.make(
317+ "resume",
318+ {
319+ executionId: Options.text("execution-id"),
320+ action: Options.text("action").pipe(Options.withDefault("accept")),
321+ content: Options.text("content").pipe(Options.optional),
322+ baseUrl: Options.text("base-url").pipe(Options.withDefault(DEFAULT_BASE_URL)),
323+ },
324+ ({ executionId, action, content, baseUrl }) =>
325+ Effect.gen(function* () {
326+ yield* ensureServer(baseUrl);
327+
328+ const parsedContent = Option.getOrUndefined(content);
329+ const contentObj = parsedContent ? JSON.parse(parsedContent) : undefined;
330+
331+ const client = yield* makeApiClient(baseUrl);
332+ const result = yield* client.executions.resume({
333+ path: { executionId },
334+ payload: { action: action as "accept" | "decline" | "cancel", content: contentObj },
335+ });
336+
337+ if (result.isError) {
338+ console.error(result.text);
339+ process.exitCode = 1;
340+ } else {
341+ console.log(result.text);
342+ }
343+ }),
344+).pipe(Command.withDescription("Resume a paused execution"));
345+
346+const webCommand = Command.make(
347+ "web",
348+ {
349+ port: Options.integer("port").pipe(Options.withDefault(DEFAULT_PORT)),
350+ },
351+ ({ port }) => runForegroundSession({ kind: "web", port }),
352+).pipe(Command.withDescription("Start a foreground web session"));
353+
354+const mcpCommand = Command.make(
355+ "mcp",
356+ {},
357+ () => runStdioMcpSession(),
358+).pipe(Command.withDescription("Start an MCP server over stdio"));
359+
360+// ---------------------------------------------------------------------------
361+// Root command
362+// ---------------------------------------------------------------------------
363+
364+const root = Command.make("executor").pipe(
365+ Command.withSubcommands([callCommand, resumeCommand, webCommand, mcpCommand] as const),
366+ Command.withDescription("Executor local CLI"),
367+);
368+
369+// ---------------------------------------------------------------------------
370+// Run
371+// ---------------------------------------------------------------------------
372+
373+const runCli = Command.run(root, {
374+ name: CLI_NAME,
375+ version: CLI_VERSION,
376+ executable: CLI_NAME,
377+});
378+
379+const program = runCli(process.argv).pipe(
380+ Effect.catchAllCause((cause) =>
381+ Effect.sync(() => {
382+ console.error(Cause.pretty(cause));
383+ process.exitCode = 1;
384+ }),
385+ ),
386+);
387+
388+BunRuntime.runMain(program as Effect.Effect<void, never, never>);
apps/cli/src/release.ts+270−0
@@ -0,0 +1,270 @@
1+import { existsSync } from "node:fs";
2+import { mkdir, readdir, rename, rm } from "node:fs/promises";
3+import { dirname, join, resolve } from "node:path";
4+import { fileURLToPath } from "node:url";
5+
6+type ReleaseChannel = "latest" | "beta";
7+
8+type ReleaseCliOptions = {
9+ readonly dryRun: boolean;
10+};
11+
12+type CommandInput = {
13+ readonly command: string;
14+ readonly args: ReadonlyArray<string>;
15+ readonly cwd: string;
16+ readonly captureOutput?: boolean;
17+};
18+
19+type CommandOutput = {
20+ readonly stdout: string;
21+ readonly stderr: string;
22+};
23+
24+const repoRoot = resolve(dirname(fileURLToPath(import.meta.url)), "../../..");
25+const cliRoot = resolve(repoRoot, "apps/cli");
26+const distDir = resolve(cliRoot, "dist");
27+const releaseDir = resolve(distDir, "release");
28+const wrapperDir = resolve(distDir, "executor");
29+const versionPackagePath = resolve(cliRoot, "package.json");
30+const semverPattern =
31+ /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-((?:0|[1-9]\d*|\d*[A-Za-z-][0-9A-Za-z-]*)(?:\.(?:0|[1-9]\d*|\d*[A-Za-z-][0-9A-Za-z-]*))*))?(?:\+([0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*))?$/;
32+
33+const parseArgs = (argv: ReadonlyArray<string>): ReleaseCliOptions => {
34+ let dryRun = false;
35+
36+ for (const arg of argv) {
37+ if (arg === "--dry-run") {
38+ dryRun = true;
39+ continue;
40+ }
41+
42+ throw new Error(`Unknown argument: ${arg}`);
43+ }
44+
45+ return { dryRun };
46+};
47+
48+const runCommand = async (input: CommandInput): Promise<CommandOutput> => {
49+ const proc = Bun.spawn([input.command, ...input.args], {
50+ cwd: input.cwd,
51+ env: process.env,
52+ stdio: input.captureOutput ? ["ignore", "pipe", "pipe"] : ["ignore", "inherit", "inherit"],
53+ });
54+
55+ const exitCode = await proc.exited;
56+ const stdout = input.captureOutput && proc.stdout ? await new Response(proc.stdout).text() : "";
57+ const stderr = input.captureOutput && proc.stderr ? await new Response(proc.stderr).text() : "";
58+
59+ if (exitCode !== 0) {
60+ throw new Error(
61+ [
62+ `${input.command} ${input.args.join(" ")} exited with code ${exitCode}`,
63+ stdout.trim().length > 0 ? `stdout:\n${stdout.trim()}` : null,
64+ stderr.trim().length > 0 ? `stderr:\n${stderr.trim()}` : null,
65+ ]
66+ .filter((part) => part !== null)
67+ .join("\n\n"),
68+ );
69+ }
70+
71+ return {
72+ stdout: stdout.trim(),
73+ stderr: stderr.trim(),
74+ };
75+};
76+
77+const readVersion = async (): Promise<string> => {
78+ const pkg = await Bun.file(versionPackagePath).json() as { version?: string };
79+ const version = pkg.version?.trim();
80+
81+ if (!version) {
82+ throw new Error(`Missing version in ${versionPackagePath}`);
83+ }
84+
85+ return version;
86+};
87+
88+const validateVersion = (version: string): void => {
89+ if (!semverPattern.test(version)) {
90+ throw new Error(`${versionPackagePath} version is not valid semver: ${version}`);
91+ }
92+};
93+
94+const resolveChannel = (version: string): ReleaseChannel =>
95+ version.includes("-") ? "beta" : "latest";
96+
97+const resolveTagFromEnvironment = (): string | undefined => {
98+ const refName = process.env.GITHUB_REF_NAME?.trim();
99+ if (process.env.GITHUB_REF_TYPE === "tag" && refName) {
100+ return refName;
101+ }
102+
103+ const ref = process.env.GITHUB_REF?.trim();
104+ if (ref?.startsWith("refs/tags/")) {
105+ return ref.slice("refs/tags/".length);
106+ }
107+
108+ return undefined;
109+};
110+
111+const resolveGitHubRepository = (): string => {
112+ const repository = process.env.GH_REPO?.trim() || process.env.GITHUB_REPOSITORY?.trim();
113+ if (!repository) {
114+ throw new Error("Set GH_REPO or GITHUB_REPOSITORY before creating a GitHub release.");
115+ }
116+
117+ return repository;
118+};
119+
120+const packWrapperPackage = async (): Promise<string> => {
121+ if (!existsSync(wrapperDir)) {
122+ throw new Error(`Wrapper package directory not found: ${wrapperDir}`);
123+ }
124+
125+ await mkdir(releaseDir, { recursive: true });
126+
127+ const before = new Set((await readdir(wrapperDir)).filter((entry) => entry.endsWith(".tgz")));
128+ await runCommand({
129+ command: "bun",
130+ args: ["pm", "pack"],
131+ cwd: wrapperDir,
132+ });
133+
134+ const after = (await readdir(wrapperDir)).filter((entry) => entry.endsWith(".tgz"));
135+ const archiveName = after.find((entry) => !before.has(entry)) ?? after[0];
136+
137+ if (!archiveName) {
138+ throw new Error(`bun pm pack did not create a .tgz archive in ${wrapperDir}`);
139+ }
140+
141+ const sourcePath = join(wrapperDir, archiveName);
142+ const destinationPath = join(releaseDir, archiveName);
143+
144+ await rm(destinationPath, { force: true });
145+ await rename(sourcePath, destinationPath);
146+
147+ return destinationPath;
148+};
149+
150+const collectReleaseAssetPaths = async (wrapperArchivePath: string): Promise<ReadonlyArray<string>> => {
151+ const assetNames = (await readdir(distDir))
152+ .filter((entry) => /^executor-.*\.(?:tar\.gz|zip)$/.test(entry))
153+ .sort();
154+
155+ return [
156+ wrapperArchivePath,
157+ ...assetNames.map((entry) => join(distDir, entry)),
158+ ];
159+};
160+
161+const githubReleaseExists = async (tag: string, repository: string): Promise<boolean> => {
162+ const proc = Bun.spawn(["gh", "release", "view", tag, "--repo", repository], {
163+ cwd: repoRoot,
164+ env: process.env,
165+ stdio: ["ignore", "ignore", "ignore"],
166+ });
167+
168+ return (await proc.exited) === 0;
169+};
170+
171+const syncGitHubRelease = async (input: {
172+ readonly tag: string;
173+ readonly channel: ReleaseChannel;
174+ readonly assetPaths: ReadonlyArray<string>;
175+}): Promise<void> => {
176+ if (!process.env.GH_TOKEN?.trim()) {
177+ throw new Error("GH_TOKEN is required to create or update a GitHub release.");
178+ }
179+
180+ const repository = resolveGitHubRepository();
181+
182+ if (await githubReleaseExists(input.tag, repository)) {
183+ await runCommand({
184+ command: "gh",
185+ args: ["release", "upload", input.tag, ...input.assetPaths, "--repo", repository, "--clobber"],
186+ cwd: repoRoot,
187+ });
188+ return;
189+ }
190+
191+ const args = [
192+ "release",
193+ "create",
194+ input.tag,
195+ ...input.assetPaths,
196+ "--repo",
197+ repository,
198+ "--title",
199+ input.tag,
200+ "--generate-notes",
201+ "--verify-tag",
202+ ];
203+
204+ if (input.channel === "beta") {
205+ args.push("--prerelease");
206+ } else {
207+ args.push("--latest");
208+ }
209+
210+ await runCommand({
211+ command: "gh",
212+ args,
213+ cwd: repoRoot,
214+ });
215+};
216+
217+const main = async () => {
218+ const options = parseArgs(process.argv.slice(2));
219+ const version = await readVersion();
220+ const tag = `v${version}`;
221+ const refTag = resolveTagFromEnvironment();
222+ const channel = resolveChannel(version);
223+
224+ validateVersion(version);
225+
226+ if (refTag && refTag !== tag) {
227+ throw new Error(`GitHub tag ${refTag} does not match ${versionPackagePath} version ${version}`);
228+ }
229+
230+ await rm(releaseDir, { recursive: true, force: true });
231+ await mkdir(releaseDir, { recursive: true });
232+
233+ await runCommand({
234+ command: "bun",
235+ args: ["run", "src/build.ts", "binary"],
236+ cwd: cliRoot,
237+ });
238+
239+ await runCommand({
240+ command: "bun",
241+ args: ["run", "src/build.ts", "release-assets"],
242+ cwd: cliRoot,
243+ });
244+
245+ const wrapperArchivePath = await packWrapperPackage();
246+ const assetPaths = await collectReleaseAssetPaths(wrapperArchivePath);
247+
248+ console.log(`Prepared executor@${version} for ${channel}`);
249+ for (const assetPath of assetPaths) {
250+ console.log(`- ${assetPath}`);
251+ }
252+
253+ if (options.dryRun) {
254+ return;
255+ }
256+
257+ await runCommand({
258+ command: "bun",
259+ args: ["run", "src/build.ts", "publish", channel],
260+ cwd: cliRoot,
261+ });
262+
263+ await syncGitHubRelease({
264+ tag,
265+ channel,
266+ assetPaths,
267+ });
268+};
269+
270+await main();