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 @@
1import { cp, mkdir, rm, writeFile, chmod } from "node:fs/promises";
2import { existsSync } from "node:fs";
3import { createRequire } from "node:module";
4import { resolve, join, dirname } from "node:path";
5import { fileURLToPath } from "node:url";
6import { $ } from "bun";
7
8const repoRoot = resolve(dirname(fileURLToPath(import.meta.url)), "../../..");
9const cliRoot = resolve(repoRoot, "apps/cli");
10const webRoot = resolve(repoRoot, "apps/web");
11const distDir = resolve(cliRoot, "dist");
12
13const 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
28const 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
47type Target = {
48 os: "linux" | "darwin" | "win32";
49 arch: "x64" | "arm64";
50 abi?: "musl";
51};
52
53const 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
64const platformName = (t: Target) =>
65 t.os === "win32" ? "windows" : t.os;
66
67const targetPackageName = (t: Target) =>
68 ["executor", platformName(t), t.arch, t.abi].filter(Boolean).join("-");
69
70const bunTarget = (t: Target) =>
71 ["bun", platformName(t), t.arch, t.abi].filter(Boolean).join("-");
72
73const binaryName = (t: Target) =>
74 t.os === "win32" ? "executor.exe" : "executor";
75
76const isCurrentPlatform = (t: Target) =>
77 t.os === process.platform && t.arch === process.arch && !t.abi;
78
79// ---------------------------------------------------------------------------
80// Build web app
81// ---------------------------------------------------------------------------
82
83const 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
98const 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
124const 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
191const 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
244const 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
262const 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
277const 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
297const 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
306const 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
326const NODE_SHIM = `#!/usr/bin/env node
327const childProcess = require("child_process");
328const fs = require("fs");
329const path = require("path");
330const os = require("os");
331
332function 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
339if (process.env.EXECUTOR_BIN_PATH) run(process.env.EXECUTOR_BIN_PATH);
340
341// Check cached binary from postinstall
342const scriptDir = path.dirname(fs.realpathSync(__filename));
343const cached = path.join(scriptDir, ".executor");
344if (fs.existsSync(cached)) run(cached);
345
346// Resolve platform
347const platformMap = { darwin: "darwin", linux: "linux", win32: "windows" };
348const platform = platformMap[os.platform()] || os.platform();
349const arch = os.arch() === "arm64" ? "arm64" : "x64";
350const binary = platform === "windows" ? "executor.exe" : "executor";
351
352// Detect musl
353const 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
364const base = "executor-" + platform + "-" + arch;
365const 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
376function 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
392const resolved = findBinary(scriptDir);
393if (!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}
397run(resolved);
398`;
399
400// ---------------------------------------------------------------------------
401// Postinstall — hardlink/copy the platform binary for fast startup
402// ---------------------------------------------------------------------------
403
404const POSTINSTALL_SCRIPT = `#!/usr/bin/env node
405const fs = require("fs");
406const path = require("path");
407const os = require("os");
408const { createRequire } = require("module");
409
410const __dirname_resolved = path.dirname(fs.realpathSync(__filename));
411const require_ = createRequire(__filename);
412
413const platformMap = { darwin: "darwin", linux: "linux", win32: "windows" };
414const platform = platformMap[os.platform()] || os.platform();
415const arch = os.arch() === "arm64" ? "arm64" : "x64";
416const binary = platform === "windows" ? "executor.exe" : "executor";
417const base = "executor-" + platform + "-" + arch;
418
419const names = platform === "linux" ? [base, base + "-musl"] : [base];
420
421for (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
445const command = process.argv[2];
446const singleFlag = process.argv.includes("--single");
447
448if (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 @@
1declare 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
2import { dirname, join } from "node:path";
3const wasmOnDisk = join(dirname(process.execPath), "emscripten-module.wasm");
4if (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
21import { spawn } from "node:child_process";
22import { resolve } from "node:path";
23import { Command, Options, Args } from "@effect/cli";
24import { BunRuntime } from "@effect/platform-bun";
25import { FetchHttpClient, HttpApiClient } from "@effect/platform";
26import * as Effect from "effect/Effect";
27import * as Option from "effect/Option";
28import * as Cause from "effect/Cause";
29
30import { ExecutorApi } from "@executor/api";
31import { createServerHandlers, runMcpStdioServer, getExecutor } from "@executor/server";
32
33// ---------------------------------------------------------------------------
34// Constants
35// ---------------------------------------------------------------------------
36
37const CLI_NAME = "executor";
38const { version: CLI_VERSION } = await import("../package.json");
39const DEFAULT_PORT = 8788;
40const DEFAULT_BASE_URL = `http://localhost:${DEFAULT_PORT}`;
41
42// Embedded web UI — baked into compiled binaries via `with { type: "file" }`
43const 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
52const 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
63const appendUrlPath = (baseUrl: string, pathname: string): string =>
64 new URL(pathname, baseUrl.endsWith("/") ? baseUrl : `${baseUrl}/`).toString();
65
66const 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
92const 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
101const script = process.argv[1];
102const isDevMode = script?.endsWith(".ts") || script?.endsWith(".js");
103const cliPrefix = isDevMode ? `bun run ${script}` : "executor";
104
105const 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
116const 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
138const 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
147const WEB_DIST_DIR = resolve(import.meta.dirname, "../../web/dist");
148
149const 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
189const 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
231const 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
241const 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
281const 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
316const 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
346const 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
354const 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
364const 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
373const runCli = Command.run(root, {
374 name: CLI_NAME,
375 version: CLI_VERSION,
376 executable: CLI_NAME,
377});
378
379const 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
388BunRuntime.runMain(program as Effect.Effect<void, never, never>);
apps/cli/src/release.ts+270−0
@@ -0,0 +1,270 @@
1import { existsSync } from "node:fs";
2import { mkdir, readdir, rename, rm } from "node:fs/promises";
3import { dirname, join, resolve } from "node:path";
4import { fileURLToPath } from "node:url";
5
6type ReleaseChannel = "latest" | "beta";
7
8type ReleaseCliOptions = {
9 readonly dryRun: boolean;
10};
11
12type CommandInput = {
13 readonly command: string;
14 readonly args: ReadonlyArray<string>;
15 readonly cwd: string;
16 readonly captureOutput?: boolean;
17};
18
19type CommandOutput = {
20 readonly stdout: string;
21 readonly stderr: string;
22};
23
24const repoRoot = resolve(dirname(fileURLToPath(import.meta.url)), "../../..");
25const cliRoot = resolve(repoRoot, "apps/cli");
26const distDir = resolve(cliRoot, "dist");
27const releaseDir = resolve(distDir, "release");
28const wrapperDir = resolve(distDir, "executor");
29const versionPackagePath = resolve(cliRoot, "package.json");
30const 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
33const 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
48const 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
77const 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
88const validateVersion = (version: string): void => {
89 if (!semverPattern.test(version)) {
90 throw new Error(`${versionPackagePath} version is not valid semver: ${version}`);
91 }
92};
93
94const resolveChannel = (version: string): ReleaseChannel =>
95 version.includes("-") ? "beta" : "latest";
96
97const 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
111const 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
120const 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
150const 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
161const 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
171const 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
217const 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
270await main();