Showing the changes to apps/cli/src (4 of 865 files). Show all
Unified · Split
| @@ -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 | +} | |
| @@ -0,0 +1,4 @@ | ||
| 1 | +declare module "embedded-web-ui.gen.ts" { | |
| 2 | + const files: Record<string, string>; | |
| 3 | + export default files; | |
| 4 | +} | |
| @@ -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>); | |
| @@ -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(); | |