* Distribute platform binaries via optionalDependencies The npm wrapper used to download the matching platform binary from GitHub Releases in a postinstall step. That broke `bun i -g executor` two ways: bun blocks postinstalls by default, and the launcher's `#!/usr/bin/env node` shebang fails outright on machines without node. Mirror the pattern opencode and the esbuild/swc family use: - Each per-platform package (executor-linux-x64, executor-darwin-arm64, ...) is published to npm with `os`/`cpu` fields. The wrapper lists them all under `optionalDependencies`, so npm/bun's filtering only fetches the matching one. - The launcher walks parent `node_modules` for the platform package and execs its binary, with a glibc/musl preference chain on Linux. - Postinstall is a fast-path: it hardlinks the resolved binary to `bin/.executor` so subsequent launches skip the walk. It always exits 0 — failures are non-fatal because the launcher does the same walk at runtime, which also covers the bun-blocks-postinstalls case. - The legacy `bin/runtime/` lookup is kept as a fallback so the pkg.pr.new preview wrapper (which downloads per-PR builds from R2) keeps working unchanged. - `publish` now ships every platform package in parallel before the wrapper, so optionalDependencies always resolve to packages that exist on npm. For the no-node-at-all case (e.g. `bun i -g` without node installed), add `scripts/install.sh`: a POSIX installer that downloads the matching release tarball/zip into `~/.executor/bin` and PATH-appends. Same pattern opencode uses for its curl-installer path. Smoke test rewritten to exercise the new flow: build wrapper + current-platform package, set up the install layout `<root>/executor/node_modules/executor-<plat>-<arch>`, run the launcher, boot `executor web`, and verify the bundled UI serves. * install.sh: drop dead bin/ branch, the tarball is flat * Drop postinstall, async signal forwarding, remove secure-exec Codex-style cleanups on top of the optionalDependencies switch: - Drop the postinstall script entirely. The launcher already does the same require.resolve at runtime, so the bin/.executor cache only saves ~1ms per launch and adds surface area. Also removes the bun-blocks- postinstalls failure mode — there's no postinstall to block. - Switch the launcher from spawnSync to async spawn with explicit forwarding of SIGINT/SIGTERM/SIGHUP, so Ctrl-C reaches the binary cleanly during long-running commands like `executor web`. - Detect bun vs npm in the launcher and tell the user the right reinstall command if the platform binary is missing. Remove secure-exec, which is no longer used: - @executor-js/runtime-secure-exec had zero consumers in the codebase (only its own tests imported it). Delete the package, drop its entry from .changeset/config.json. - Remove resolveSecureExecV8 and secureExecBundlePlugin from build.ts; the binary no longer copies a secure-exec-v8 sidecar or runs the esbuild plugin that inlined @secure-exec/nodejs polyfills + bridge code. - Drop the secure-exec mention from main.ts's PATH-augmentation comment and from install.sh's sidecar comment.
Showing the changes to apps/cli/src (2 of 12 files). Show all
Unified · Split
| @@ -106,33 +106,6 @@ const binaryName = (t: Target) => (t.os === "win32" ? "executor.exe" : "executor · 105 unmodified lines | |||
| 106 | const isCurrentPlatform = (t: Target) => | 106 | const isCurrentPlatform = (t: Target) => |
| 107 | t.os === process.platform && t.arch === process.arch && !t.abi; | 107 | t.os === process.platform && t.arch === process.arch && !t.abi; |
| 108 | 108 | ||
| 109 | /** Resolve the platform-specific secure-exec-v8 binary for a given target. */ | ||
| 110 | const resolveSecureExecV8 = (t: Target): string | null => { | ||
| 111 | const platformMap: Record<string, string> = { | ||
| 112 | "darwin-arm64": "@secure-exec/v8-darwin-arm64", | ||
| 113 | "darwin-x64": "@secure-exec/v8-darwin-x64", | ||
| 114 | "linux-arm64": "@secure-exec/v8-linux-arm64-gnu", | ||
| 115 | "linux-x64": "@secure-exec/v8-linux-x64-gnu", | ||
| 116 | }; | ||
| 117 | const key = `${t.os}-${t.arch}`; | ||
| 118 | const pkg = platformMap[key]; | ||
| 119 | if (!pkg) return null; | ||
| 120 | try { | ||
| 121 | // Resolve from @secure-exec/v8 which has these as optional deps | ||
| 122 | const req = createRequire(join(repoRoot, "node_modules", "secure-exec", "package.json")); | ||
| 123 | const pkgJson = req.resolve(`${pkg}/package.json`); | ||
| 124 | return join(dirname(pkgJson), "secure-exec-v8"); | ||
| 125 | } catch { | ||
| 126 | // Try bun's flat node_modules layout | ||
| 127 | const bunPath = join( | ||
| 128 | repoRoot, | ||
| 129 | `node_modules/.bun/${pkg.replace("/", "+")}@0.2.1/node_modules/${pkg}/secure-exec-v8`, | ||
| 130 | ); | ||
| 131 | if (existsSync(bunPath)) return bunPath; | ||
| 132 | return null; | ||
| 133 | } | ||
| 134 | }; | ||
| 135 | |||
| 136 | /** | 109 | /** |
| 137 | * Resolve the platform-specific @napi-rs/keyring native binding for a target. | 110 | * Resolve the platform-specific @napi-rs/keyring native binding for a target. |
| 138 | * | 111 | * |
| @@ -260,102 +233,6 @@ const createEmbeddedMigrationsSource = async () => { · 121 unmodified lines | |||
| 260 | ].join("\n"); | 233 | ].join("\n"); |
| 261 | }; | 234 | }; |
| 262 | 235 | ||
| 263 | // --------------------------------------------------------------------------- | ||
| 264 | // Bun.build plugin for @secure-exec bundling issues | ||
| 265 | // --------------------------------------------------------------------------- | ||
| 266 | |||
| 267 | /** | ||
| 268 | * Plugin that fixes two issues with @secure-exec in compiled binaries: | ||
| 269 | * | ||
| 270 | * 1. node-stdlib-browser / web-streams-polyfill eagerly call require.resolve() | ||
| 271 | * at import time, which fails in bunfs. We stub these out since our code | ||
| 272 | * never uses the polyfill functions. | ||
| 273 | * | ||
| 274 | * 2. bridge-loader.js reads bridge.js from disk via fs.readFileSync using | ||
| 275 | * __dirname. In a compiled binary __dirname points into bunfs where the | ||
| 276 | * file doesn't exist. We replace bridge-loader with a version that embeds | ||
| 277 | * bridge.js content directly. | ||
| 278 | */ | ||
| 279 | const secureExecBundlePlugin = async (): Promise<import("bun").BunPlugin> => { | ||
| 280 | // Read files at build time so we can inline them into the compiled binary. | ||
| 281 | // bridge.js is loaded via fs.readFileSync at runtime, which fails in bunfs. | ||
| 282 | // bridgeAttach comes from @secure-exec/core's generated isolate-runtime. | ||
| 283 | const secureExecNodejs = join( | ||
| 284 | repoRoot, | ||
| 285 | "node_modules/.bun/@secure-exec+nodejs@0.2.1/node_modules/@secure-exec/nodejs", | ||
| 286 | ); | ||
| 287 | const secureExecCore = join( | ||
| 288 | repoRoot, | ||
| 289 | "node_modules/.bun/@secure-exec+core@0.2.1/node_modules/@secure-exec/core", | ||
| 290 | ); | ||
| 291 | const bridgeCode = await Bun.file(join(secureExecNodejs, "dist/bridge.js")).text(); | ||
| 292 | const isolateRuntime = await import(join(secureExecCore, "dist/generated/isolate-runtime.js")); | ||
| 293 | const bridgeAttachCode = isolateRuntime.ISOLATE_RUNTIME_SOURCES.bridgeAttach; | ||
| 294 | const polyfillCodeMap = (await import(join(secureExecCore, "dist/generated/polyfills.js"))) | ||
| 295 | .POLYFILL_CODE_MAP as Record<string, string>; | ||
| 296 | |||
| 297 | return { | ||
| 298 | name: "secure-exec-bundle-fixes", | ||
| 299 | setup(build) { | ||
| 300 | // Stub polyfill modules that fail at import time in compiled binaries | ||
| 301 | const stubTargets = /node-stdlib-browser|web-streams-polyfill/; | ||
| 302 | build.onResolve({ filter: stubTargets }, (args) => ({ | ||
| 303 | path: args.path, | ||
| 304 | namespace: "stub", | ||
| 305 | })); | ||
| 306 | // Replace @secure-exec/nodejs polyfills (which use node-stdlib-browser + esbuild | ||
| 307 | // at runtime) with a shim that serves from pre-bundled POLYFILL_CODE_MAP. | ||
| 308 | build.onResolve({ filter: /polyfills/ }, (args) => { | ||
| 309 | if ( | ||
| 310 | args.importer.includes("@secure-exec/nodejs") || | ||
| 311 | args.importer.includes("@secure-exec+nodejs") | ||
| 312 | ) { | ||
| 313 | return { path: args.path, namespace: "polyfills-shim" }; | ||
| 314 | } | ||
| 315 | }); | ||
| 316 | build.onLoad({ filter: /.*/, namespace: "polyfills-shim" }, () => ({ | ||
| 317 | contents: ` | ||
| 318 | const POLYFILL_CODE_MAP = ${JSON.stringify(polyfillCodeMap)}; | ||
| 319 | const polyfillCache = new Map(); | ||
| 320 | export default {}; | ||
| 321 | export async function bundlePolyfill(moduleName) { | ||
| 322 | const cached = polyfillCache.get(moduleName); | ||
| 323 | if (cached) return cached; | ||
| 324 | const code = POLYFILL_CODE_MAP[moduleName]; | ||
| 325 | if (!code) throw new Error("No polyfill available for module: " + moduleName); | ||
| 326 | polyfillCache.set(moduleName, code); | ||
| 327 | return code; | ||
| 328 | } | ||
| 329 | export function getAvailableStdlib() { return Object.keys(POLYFILL_CODE_MAP); } | ||
| 330 | export function hasPolyfill(name) { return name.replace(/^node:/, "") in POLYFILL_CODE_MAP; } | ||
| 331 | export async function prebundleAllPolyfills() { return { ...POLYFILL_CODE_MAP }; } | ||
| 332 | `, | ||
| 333 | loader: "js", | ||
| 334 | })); | ||
| 335 | build.onLoad({ filter: /.*/, namespace: "stub" }, () => ({ | ||
| 336 | contents: "export default {};", | ||
| 337 | loader: "js", | ||
| 338 | })); | ||
| 339 | |||
| 340 | // Replace bridge-loader with pre-read content | ||
| 341 | build.onResolve({ filter: /bridge-loader/ }, (args) => { | ||
| 342 | if (args.importer.includes("secure-exec")) { | ||
| 343 | return { path: args.path, namespace: "bridge" }; | ||
| 344 | } | ||
| 345 | }); | ||
| 346 | build.onLoad({ filter: /.*/, namespace: "bridge" }, () => ({ | ||
| 347 | contents: ` | ||
| 348 | const bridgeCode = ${JSON.stringify(bridgeCode)}; | ||
| 349 | const bridgeAttachCode = ${JSON.stringify(bridgeAttachCode)}; | ||
| 350 | export function getRawBridgeCode() { return bridgeCode; } | ||
| 351 | export function getBridgeAttachCode() { return bridgeAttachCode; } | ||
| 352 | `, | ||
| 353 | loader: "js", | ||
| 354 | })); | ||
| 355 | }, | ||
| 356 | }; | ||
| 357 | }; | ||
| 358 | |||
| 359 | // --------------------------------------------------------------------------- | 236 | // --------------------------------------------------------------------------- |
| 360 | // Build platform binaries | 237 | // Build platform binaries |
| 361 | // --------------------------------------------------------------------------- | 238 | // --------------------------------------------------------------------------- |
| @@ -372,10 +249,9 @@ const buildBinaries = async (targets: Target[], mode: BuildMode) => { · 10 unmodified lines | |||
| 372 | await rm(distDir, { recursive: true, force: true }); | 249 | await rm(distDir, { recursive: true, force: true }); |
| 373 | 250 | ||
| 374 | // Cross-platform builds need every target's optional native packages | 251 | // Cross-platform builds need every target's optional native packages |
| 375 | // (e.g. @napi-rs/keyring-darwin-arm64, @secure-exec/v8-win32-x64) so we | 252 | // (e.g. @napi-rs/keyring-darwin-arm64) so we can copy the right .node next |
| 376 | // can copy the right .node / binary next to each target's executor. | 253 | // to each target's executor. `bun install --frozen-lockfile --cpu=* --os=*` |
| 377 | // `bun install --frozen-lockfile --cpu=* --os=*` extracts them all | 254 | // extracts them all without modifying the lockfile. |
| 378 | // without modifying the lockfile. | ||
| 379 | const needsCrossPlatform = targets.some((t) => !isCurrentPlatform(t)); | 255 | const needsCrossPlatform = targets.some((t) => !isCurrentPlatform(t)); |
| 380 | if (needsCrossPlatform) { | 256 | if (needsCrossPlatform) { |
| 381 | console.log("Installing optional native deps for all platforms..."); | 257 | console.log("Installing optional native deps for all platforms..."); |
| @@ -414,20 +290,11 @@ const buildBinaries = async (targets: Target[], mode: BuildMode) => { · 32 unmodified lines | |||
| 414 | target: bunTarget(target), | 290 | target: bunTarget(target), |
| 415 | outfile: join(binDir, binaryName(target)), | 291 | outfile: join(binDir, binaryName(target)), |
| 416 | }, | 292 | }, |
| 417 | plugins: [await secureExecBundlePlugin()], | ||
| 418 | }); | 293 | }); |
| 419 | 294 | ||
| 420 | // Copy QuickJS WASM next to binary — loaded at runtime by the server | 295 | // Copy QuickJS WASM next to binary — loaded at runtime by the server |
| 421 | await cp(quickJsWasmPath, join(binDir, "emscripten-module.wasm")); | 296 | await cp(quickJsWasmPath, join(binDir, "emscripten-module.wasm")); |
| 422 | 297 | ||
| 423 | // Copy secure-exec-v8 binary next to executor — needed for code execution | ||
| 424 | const secureExecBin = resolveSecureExecV8(target); | ||
| 425 | if (secureExecBin && existsSync(secureExecBin)) { | ||
| 426 | const destName = target.os === "win32" ? "secure-exec-v8.exe" : "secure-exec-v8"; | ||
| 427 | await cp(secureExecBin, join(binDir, destName)); | ||
| 428 | await chmod(join(binDir, destName), 0o755); | ||
| 429 | } | ||
| 430 | |||
| 431 | // Copy @napi-rs/keyring native binding next to executor — bun --compile | 298 | // Copy @napi-rs/keyring native binding next to executor — bun --compile |
| 432 | // doesn't bundle .node files, so the loader needs to find it on disk | 299 | // doesn't bundle .node files, so the loader needs to find it on disk |
| 433 | // via NAPI_RS_NATIVE_LIBRARY_PATH (set in main.ts). | 300 | // via NAPI_RS_NATIVE_LIBRARY_PATH (set in main.ts). |
| @@ -444,16 +311,23 @@ const buildBinaries = async (targets: Target[], mode: BuildMode) => { · 10 unmodified lines | |||
| 444 | console.log(` OK: ${version.trim()}`); | 311 | console.log(` OK: ${version.trim()}`); |
| 445 | } | 312 | } |
| 446 | 313 | ||
| 447 | // Platform package.json | 314 | // Platform package.json. No `bin` field on purpose — the wrapper's |
| 315 | // launcher walks node_modules to locate `bin/executor` directly. Adding | ||
| 316 | // a bin here would race with the wrapper for the global `executor` | ||
| 317 | // entry under flat installs. | ||
| 448 | await writeFile( | 318 | await writeFile( |
| 449 | join(outDir, "package.json"), | 319 | join(outDir, "package.json"), |
| 450 | JSON.stringify( | 320 | JSON.stringify( |
| 451 | { | 321 | { |
| 452 | name, | 322 | name, |
| 453 | version: meta.version, | 323 | version: meta.version, |
| 324 | description: `${meta.description} (${name})`, | ||
| 454 | os: [target.os], | 325 | os: [target.os], |
| 455 | cpu: [target.arch], | 326 | cpu: [target.arch], |
| 456 | bin: { executor: `bin/${binaryName(target)}` }, | 327 | homepage: meta.homepage, |
| 328 | bugs: meta.bugs, | ||
| 329 | repository: meta.repository, | ||
| 330 | license: meta.license, | ||
| 457 | }, | 331 | }, |
| 458 | null, | 332 | null, |
| 459 | 2, | 333 | 2, |
| @@ -481,14 +355,14 @@ const buildWrapperPackage = async (binaries: Record<string, string>) => { · 21 unmodified lines | |||
| 481 | 355 | ||
| 482 | await mkdir(binDir, { recursive: true }); | 356 | await mkdir(binDir, { recursive: true }); |
| 483 | 357 | ||
| 484 | // Node.js shim that spawns the downloaded platform binary | 358 | // Node.js launcher — resolves the platform binary via require.resolve |
| 359 | // against optionalDependencies and execs it. No postinstall: the binary | ||
| 360 | // ships as an os/cpu-filtered optional dep, the launcher resolves it at | ||
| 361 | // runtime. This works whether or not the package manager runs postinstalls | ||
| 362 | // (bun blocks them by default). | ||
| 485 | await writeFile(join(binDir, "executor"), NODE_SHIM); | 363 | await writeFile(join(binDir, "executor"), NODE_SHIM); |
| 486 | await chmod(join(binDir, "executor"), 0o755); | 364 | await chmod(join(binDir, "executor"), 0o755); |
| 487 | 365 | ||
| 488 | // Postinstall downloads the matching release asset from GitHub Releases | ||
| 489 | await writeFile(join(wrapperDir, "postinstall.cjs"), POSTINSTALL_SCRIPT); | ||
| 490 | |||
| 491 | // Package.json | ||
| 492 | await writeFile( | 366 | await writeFile( |
| 493 | join(wrapperDir, "package.json"), | 367 | join(wrapperDir, "package.json"), |
| 494 | JSON.stringify( | 368 | JSON.stringify( |
| @@ -502,9 +376,9 @@ const buildWrapperPackage = async (binaries: Record<string, string>) => { · 7 unmodified lines | |||
| 502 | repository: meta.repository, | 376 | repository: meta.repository, |
| 503 | license: meta.license, | 377 | license: meta.license, |
| 504 | bin: { executor: "bin/executor" }, | 378 | bin: { executor: "bin/executor" }, |
| 505 | scripts: { | 379 | // Per-platform compiled binaries. npm/bun install only the entry |
| 506 | postinstall: "node ./postinstall.cjs", | 380 | // matching the current os/cpu; everything else is a no-op. |
| 507 | }, | 381 | optionalDependencies: binaries, |
| 508 | engines: { | 382 | engines: { |
| 509 | node: ">=20", | 383 | node: ">=20", |
| 510 | }, | 384 | }, |
| @@ -514,7 +388,6 @@ const buildWrapperPackage = async (binaries: Record<string, string>) => { · 3 unmodified lines | |||
| 514 | ) + "\n", | 388 | ) + "\n", |
| 515 | ); | 389 | ); |
| 516 | 390 | ||
| 517 | // README | ||
| 518 | const readmePath = join(repoRoot, "README.md"); | 391 | const readmePath = join(repoRoot, "README.md"); |
| 519 | if (existsSync(readmePath)) { | 392 | if (existsSync(readmePath)) { |
| 520 | await cp(readmePath, join(wrapperDir, "README.md")); | 393 | await cp(readmePath, join(wrapperDir, "README.md")); |
| @@ -522,7 +395,7 @@ const buildWrapperPackage = async (binaries: Record<string, string>) => { · 1 unmodified line | |||
| 522 | 395 | ||
| 523 | console.log(`\nWrapper package: ${wrapperDir}`); | 396 | console.log(`\nWrapper package: ${wrapperDir}`); |
| 524 | console.log(` ${meta.name}@${meta.version}`); | 397 | console.log(` ${meta.name}@${meta.version}`); |
| 525 | console.log(` release assets: ${Object.keys(binaries).join(", ")}`); | 398 | console.log(` optionalDependencies: ${Object.keys(binaries).join(", ")}`); |
| 526 | }; | 399 | }; |
| 527 | 400 | ||
| 528 | // --------------------------------------------------------------------------- | 401 | // --------------------------------------------------------------------------- |
| @@ -669,9 +542,20 @@ const publishPackedPackage = async (pkgDir: string, channel: string) => { · 140 unmodified lines | |||
| 669 | const publish = async (channel: string) => { | 542 | const publish = async (channel: string) => { |
| 670 | const meta = await readMetadata(); | 543 | const meta = await readMetadata(); |
| 671 | 544 | ||
| 672 | // Publish only the wrapper npm package. Platform binaries are distributed via GitHub release assets. | 545 | // Publish per-platform packages first so the wrapper's |
| 546 | // optionalDependencies resolve to packages that already exist on npm | ||
| 547 | // by the time anyone installs the wrapper. | ||
| 548 | const platformDirs: string[] = []; | ||
| 549 | for (const entry of new Bun.Glob("executor-*/package.json").scanSync({ cwd: distDir })) { | ||
| 550 | platformDirs.push(join(distDir, dirname(entry))); | ||
| 551 | } | ||
| 552 | platformDirs.sort(); | ||
| 553 | |||
| 554 | console.log(`Publishing ${platformDirs.length} platform package(s)...`); | ||
| 555 | await Promise.all(platformDirs.map((dir) => publishPackedPackage(dir, channel))); | ||
| 556 | |||
| 673 | const wrapperDir = join(distDir, meta.name); | 557 | const wrapperDir = join(distDir, meta.name); |
| 674 | console.log(`Publishing ${wrapperDir}...`); | 558 | console.log(`Publishing wrapper ${wrapperDir}...`); |
| 675 | await publishPackedPackage(wrapperDir, channel); | 559 | await publishPackedPackage(wrapperDir, channel); |
| 676 | }; | 560 | }; |
| 677 | 561 | ||
| @@ -707,107 +591,48 @@ const createReleaseAssets = async () => { · 29 unmodified lines | |||
| 707 | }; | 591 | }; |
| 708 | 592 | ||
| 709 | // --------------------------------------------------------------------------- | 593 | // --------------------------------------------------------------------------- |
| 710 | // Node.js shim — finds the right platform binary and spawns it | 594 | // Node.js launcher — resolves the platform binary shipped as an |
| 595 | // optionalDependency and execs it. Resolution order: | ||
| 596 | // 1. EXECUTOR_BIN_PATH override | ||
| 597 | // 2. require.resolve("executor-<platform>-<arch>/package.json") | ||
| 598 | // 3. bin/runtime/<binary> (preview wrapper compat — pkg.pr.new previews | ||
| 599 | // download the binary here at install time instead of via | ||
| 600 | // optionalDependencies) | ||
| 601 | // | ||
| 602 | // Signals (SIGINT/SIGTERM/SIGHUP) are forwarded to the child so long-running | ||
| 603 | // commands like `executor web` shut down cleanly under Ctrl-C. | ||
| 711 | // --------------------------------------------------------------------------- | 604 | // --------------------------------------------------------------------------- |
| 712 | 605 | ||
| 713 | const NODE_SHIM = `#!/usr/bin/env node | 606 | const NODE_SHIM = `#!/usr/bin/env node |
| 714 | const childProcess = require("child_process"); | 607 | const childProcess = require("child_process"); |
| 715 | const fs = require("fs"); | 608 | const fs = require("fs"); |
| 716 | const path = require("path"); | 609 | const path = require("path"); |
| 610 | const os = require("os"); | ||
| 717 | 611 | ||
| 718 | function run(target) { | 612 | function spawnAndExit(target) { |
| 719 | const result = childProcess.spawnSync(target, process.argv.slice(2), { stdio: "inherit" }); | 613 | const child = childProcess.spawn(target, process.argv.slice(2), { stdio: "inherit" }); |
| 720 | if (result.error) { | 614 | child.on("error", (err) => { console.error(err.message); process.exit(1); }); |
| 721 | console.error(result.error.message); | 615 | const forward = (signal) => { if (!child.killed) { try { child.kill(signal); } catch {} } }; |
| 722 | process.exit(1); | 616 | ["SIGINT", "SIGTERM", "SIGHUP"].forEach((sig) => process.on(sig, () => forward(sig))); |
| 723 | } | 617 | child.on("exit", (code, signal) => { |
| 724 | process.exit(typeof result.status === "number" ? result.status : 0); | 618 | if (signal) process.kill(process.pid, signal); |
| 725 | } | 619 | else process.exit(typeof code === "number" ? code : 0); |
| 726 | |||
| 727 | if (process.env.EXECUTOR_BIN_PATH) run(process.env.EXECUTOR_BIN_PATH); | ||
| 728 | |||
| 729 | const scriptDir = path.dirname(fs.realpathSync(__filename)); | ||
| 730 | const binary = process.platform === "win32" ? "executor.exe" : "executor"; | ||
| 731 | const runtimeBinary = path.join(scriptDir, "runtime", binary); | ||
| 732 | const legacyCached = path.join(scriptDir, process.platform === "win32" ? ".executor.exe" : ".executor"); | ||
| 733 | |||
| 734 | const resolveInstalledBinary = () => { | ||
| 735 | if (fs.existsSync(runtimeBinary)) { | ||
| 736 | return runtimeBinary; | ||
| 737 | } | ||
| 738 | if (fs.existsSync(legacyCached)) { | ||
| 739 | return legacyCached; | ||
| 740 | } | ||
| 741 | return null; | ||
| 742 | }; | ||
| 743 | |||
| 744 | const installIfNeeded = () => { | ||
| 745 | const existing = resolveInstalledBinary(); | ||
| 746 | if (existing) { | ||
| 747 | return existing; | ||
| 748 | } | ||
| 749 | |||
| 750 | const installer = path.resolve(scriptDir, "..", "postinstall.cjs"); | ||
| 751 | if (!fs.existsSync(installer)) { | ||
| 752 | return null; | ||
| 753 | } | ||
| 754 | |||
| 755 | console.error("executor binary is missing; downloading release asset..."); | ||
| 756 | const result = childProcess.spawnSync(process.execPath, [installer], { | ||
| 757 | stdio: "inherit", | ||
| 758 | env: process.env, | ||
| 759 | }); | 620 | }); |
| 760 | |||
| 761 | if (result.error) { | ||
| 762 | console.error(result.error.message); | ||
| 763 | process.exit(1); | ||
| 764 | } | ||
| 765 | |||
| 766 | if (typeof result.status === "number" && result.status !== 0) { | ||
| 767 | process.exit(result.status); | ||
| 768 | } | ||
| 769 | |||
| 770 | return resolveInstalledBinary(); | ||
| 771 | }; | ||
| 772 | |||
| 773 | const installedBinary = installIfNeeded(); | ||
| 774 | if (!installedBinary) { | ||
| 775 | console.error("executor binary is missing. Reinstall the package or run 'npm rebuild executor'."); | ||
| 776 | process.exit(1); | ||
| 777 | } | 621 | } |
| 778 | 622 | ||
| 779 | run(installedBinary); | 623 | if (process.env.EXECUTOR_BIN_PATH) { |
| 780 | `; | 624 | spawnAndExit(process.env.EXECUTOR_BIN_PATH); |
| 781 | 625 | return; | |
| 782 | // --------------------------------------------------------------------------- | 626 | } |
| 783 | // Postinstall — hardlink/copy the platform binary for fast startup | ||
| 784 | // --------------------------------------------------------------------------- | ||
| 785 | |||
| 786 | const POSTINSTALL_SCRIPT = `#!/usr/bin/env node | ||
| 787 | const childProcess = require("child_process"); | ||
| 788 | const fs = require("fs"); | ||
| 789 | const path = require("path"); | ||
| 790 | const os = require("os"); | ||
| 791 | |||
| 792 | const packageDir = path.dirname(fs.realpathSync(__filename)); | ||
| 793 | const binDir = path.join(packageDir, "bin"); | ||
| 794 | const runtimeDir = path.join(binDir, "runtime"); | ||
| 795 | const packageJson = JSON.parse(fs.readFileSync(path.join(packageDir, "package.json"), "utf8")); | ||
| 796 | 627 | ||
| 797 | const repositoryUrl = typeof packageJson.repository === "string" | 628 | const isWin = process.platform === "win32"; |
| 798 | ? packageJson.repository | 629 | const binary = isWin ? "executor.exe" : "executor"; |
| 799 | : packageJson.repository && packageJson.repository.url; | ||
| 800 | const githubBase = String(packageJson.homepage || repositoryUrl || "https://github.com/RhysSullivan/executor") | ||
| 801 | .replace(/^git[+]/, "") | ||
| 802 | .replace(/.git$/, ""); | ||
| 803 | const version = packageJson.version; | ||
| 804 | 630 | ||
| 631 | // Package names use "windows" (not "win32") to match what the build script emits. | ||
| 805 | const platformMap = { darwin: "darwin", linux: "linux", win32: "windows" }; | 632 | const platformMap = { darwin: "darwin", linux: "linux", win32: "windows" }; |
| 633 | const archMap = { x64: "x64", arm64: "arm64" }; | ||
| 806 | const platform = platformMap[os.platform()] || os.platform(); | 634 | const platform = platformMap[os.platform()] || os.platform(); |
| 807 | const arch = os.arch() === "arm64" ? "arm64" : "x64"; | 635 | const arch = archMap[os.arch()] || os.arch(); |
| 808 | const binary = platform === "windows" ? "executor.exe" : "executor"; | ||
| 809 | const cachedBinary = path.join(runtimeDir, binary); | ||
| 810 | const legacyCachedBinary = path.join(binDir, platform === "windows" ? ".executor.exe" : ".executor"); | ||
| 811 | 636 | ||
| 812 | const isMusl = (() => { | 637 | const isMusl = (() => { |
| 813 | if (platform !== "linux") return false; | 638 | if (platform !== "linux") return false; |
| @@ -819,79 +644,61 @@ const isMusl = (() => { · 5 unmodified lines | |||
| 819 | return false; | 644 | return false; |
| 820 | })(); | 645 | })(); |
| 821 | 646 | ||
| 822 | const assetBase = (() => { | 647 | // Candidate package names in preference order. On linux a system might match |
| 648 | // glibc or musl; try musl first if detected, glibc first otherwise. | ||
| 649 | const candidates = (() => { | ||
| 823 | const base = "executor-" + platform + "-" + arch; | 650 | const base = "executor-" + platform + "-" + arch; |
| 824 | if (platform === "linux" && isMusl) return base + "-musl"; | ||
| 825 | return base; | ||
| 826 | })(); | ||
| 827 | const archiveName = platform === "linux" ? assetBase + ".tar.gz" : assetBase + ".zip"; | ||
| 828 | const downloadUrl = githubBase + "/releases/download/v" + version + "/" + archiveName; | ||
| 829 | const archivePath = path.join(packageDir, archiveName); | ||
| 830 | |||
| 831 | const run = (command, args) => { | ||
| 832 | const result = childProcess.spawnSync(command, args, { stdio: "inherit" }); | ||
| 833 | if (result.error) throw result.error; | ||
| 834 | if (typeof result.status === "number" && result.status !== 0) { | ||
| 835 | throw new Error(command + " exited with code " + result.status); | ||
| 836 | } | ||
| 837 | }; | ||
| 838 | |||
| 839 | const download = async () => { | ||
| 840 | const response = await fetch(downloadUrl, { redirect: "follow" }); | ||
| 841 | if (!response.ok) { | ||
| 842 | throw new Error("Failed to download " + downloadUrl + " (status " + response.status + ")"); | ||
| 843 | } | ||
| 844 | const arrayBuffer = await response.arrayBuffer(); | ||
| 845 | fs.writeFileSync(archivePath, Buffer.from(arrayBuffer)); | ||
| 846 | }; | ||
| 847 | |||
| 848 | const extract = () => { | ||
| 849 | fs.mkdirSync(binDir, { recursive: true }); | ||
| 850 | fs.rmSync(runtimeDir, { recursive: true, force: true }); | ||
| 851 | fs.mkdirSync(runtimeDir, { recursive: true }); | ||
| 852 | |||
| 853 | if (platform === "linux") { | 651 | if (platform === "linux") { |
| 854 | run("tar", ["-xzf", archivePath, "-C", runtimeDir]); | 652 | return isMusl ? [base + "-musl", base] : [base, base + "-musl"]; |
| 855 | return; | ||
| 856 | } | ||
| 857 | |||
| 858 | if (platform === "darwin") { | ||
| 859 | run("unzip", ["-o", archivePath, "-d", runtimeDir]); | ||
| 860 | return; | ||
| 861 | } | 653 | } |
| 654 | return [base]; | ||
| 655 | })(); | ||
| 862 | 656 | ||
| 863 | const psCommand = [ | 657 | function detectPackageManager() { |
| 864 | "$ErrorActionPreference = 'Stop'", | 658 | const ua = process.env.npm_config_user_agent || ""; |
| 865 | "Expand-Archive -LiteralPath '" + archivePath.replace(/'/g, "''") + "' -DestinationPath '" + runtimeDir.replace(/'/g, "''") + "' -Force", | 659 | if (/\\bbun\\//.test(ua)) return "bun"; |
| 866 | ].join("; "); | 660 | const execPath = process.env.npm_execpath || ""; |
| 867 | const psArgs = ["-NoLogo", "-NoProfile", "-Command", psCommand]; | 661 | if (execPath.includes("bun")) return "bun"; |
| 868 | // Prefer pwsh (PowerShell 7+) which reliably has Expand-Archive; fall back to powershell.exe | 662 | if (__dirname.includes(".bun/install/global") || __dirname.includes(".bun\\\\install\\\\global")) { |
| 869 | const pwshResult = childProcess.spawnSync("pwsh", psArgs, { stdio: "inherit" }); | 663 | return "bun"; |
| 870 | if (pwshResult.error || (typeof pwshResult.status === "number" && pwshResult.status !== 0)) { | ||
| 871 | run("powershell.exe", psArgs); | ||
| 872 | } | 664 | } |
| 873 | }; | 665 | return ua ? "npm" : null; |
| 666 | } | ||
| 874 | 667 | ||
| 875 | (async () => { | 668 | for (const name of candidates) { |
| 876 | try { | 669 | try { |
| 877 | await download(); | 670 | const pkgJson = require.resolve(name + "/package.json"); |
| 878 | extract(); | 671 | const candidate = path.join(path.dirname(pkgJson), "bin", binary); |
| 879 | 672 | if (fs.existsSync(candidate)) { | |
| 880 | if (!fs.existsSync(cachedBinary)) { | 673 | spawnAndExit(candidate); |
| 881 | throw new Error("Expected extracted binary at " + cachedBinary); | 674 | return; |
| 882 | } | ||
| 883 | |||
| 884 | if (fs.existsSync(legacyCachedBinary)) { | ||
| 885 | fs.unlinkSync(legacyCachedBinary); | ||
| 886 | } | 675 | } |
| 887 | if (platform !== "windows") fs.chmodSync(cachedBinary, 0o755); | 676 | } catch { |
| 888 | fs.rmSync(archivePath, { force: true }); | 677 | // package not installed for this platform; try next candidate |
| 889 | console.log("executor: installed " + assetBase + " from GitHub Releases"); | ||
| 890 | } catch (error) { | ||
| 891 | console.error("executor postinstall failed:", error && error.message ? error.message : error); | ||
| 892 | process.exit(1); | ||
| 893 | } | 678 | } |
| 894 | })(); | 679 | } |
| 680 | |||
| 681 | // Preview wrapper compat: pkg.pr.new previews download the platform binary | ||
| 682 | // to bin/runtime/ rather than via optionalDependencies. | ||
| 683 | const scriptDir = path.dirname(fs.realpathSync(__filename)); | ||
| 684 | const previewBinary = path.join(scriptDir, "runtime", binary); | ||
| 685 | if (fs.existsSync(previewBinary)) { | ||
| 686 | spawnAndExit(previewBinary); | ||
| 687 | return; | ||
| 688 | } | ||
| 689 | |||
| 690 | const pm = detectPackageManager(); | ||
| 691 | const reinstall = pm === "bun" | ||
| 692 | ? "bun install -g executor" | ||
| 693 | : pm === "npm" | ||
| 694 | ? "npm install -g executor" | ||
| 695 | : "reinstall executor"; | ||
| 696 | console.error( | ||
| 697 | "executor: could not locate a platform binary for " + os.platform() + "-" + os.arch() + ".\\n" + | ||
| 698 | "Tried optionalDependencies: " + candidates.map((n) => '"' + n + '"').join(", ") + "\\n" + | ||
| 699 | "To fix: " + reinstall | ||
| 700 | ); | ||
| 701 | process.exit(1); | ||
| 895 | `; | 702 | `; |
| 896 | 703 | ||
| 897 | // --------------------------------------------------------------------------- | 704 | // --------------------------------------------------------------------------- |
| @@ -1,6 +1,7 @@ | |||
| 1 | // Ensure binaries next to the executor (e.g. secure-exec-v8) are on $PATH | ||
| 2 | import { randomUUID } from "node:crypto"; | 1 | import { randomUUID } from "node:crypto"; |
| 3 | import { dirname, join, resolve } from "node:path"; | 2 | import { dirname, join, resolve } from "node:path"; |
| 3 | // Make sibling binaries (if any are added later) discoverable on $PATH so | ||
| 4 | // child processes spawned without an absolute path still find them. | ||
| 4 | const execDir = dirname(process.execPath); | 5 | const execDir = dirname(process.execPath); |
| 5 | if (process.env.PATH && !process.env.PATH.includes(execDir)) { | 6 | if (process.env.PATH && !process.env.PATH.includes(execDir)) { |
| 6 | process.env.PATH = `${execDir}:${process.env.PATH}`; | 7 | process.env.PATH = `${execDir}:${process.env.PATH}`; |