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(); | ||