History

Distribute platform binaries via optionalDependencies (#488)

* 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.

Rhys Sullivan committed 6 months ago

Commit c0f618f1 parent 5d586d912 files · +544 −1393Browse files

Showing the changes to apps/cli/src (2 of 12 files). Show all

Unified · Split

2 files
  1. apps/cli/src/build.ts +108 −301
  2. apps/cli/src/main.ts +2 −1
apps/cli/src/build.ts+108−301
@@ -106,33 +106,6 @@ const binaryName = (t: Target) => (t.os === "win32" ? "executor.exe" : "executor · 105 unmodified lines
106const isCurrentPlatform = (t: Target) =>106const 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. */
110const 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 */
279const 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: `
318const POLYFILL_CODE_MAP = ${JSON.stringify(polyfillCodeMap)};
319const polyfillCache = new Map();
320export default {};
321export 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}
329export function getAvailableStdlib() { return Object.keys(POLYFILL_CODE_MAP); }
330export function hasPolyfill(name) { return name.replace(/^node:/, "") in POLYFILL_CODE_MAP; }
331export 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: `
348const bridgeCode = ${JSON.stringify(bridgeCode)};
349const bridgeAttachCode = ${JSON.stringify(bridgeAttachCode)};
350export function getRawBridgeCode() { return bridgeCode; }
351export function getBridgeAttachCode() { return bridgeAttachCode; }
352 `,
353 loader: "js",
354 }));
355 },
356 };
357};
358
359// ---------------------------------------------------------------------------236// ---------------------------------------------------------------------------
360// Build platform binaries237// 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 packages251 // Cross-platform builds need every target's optional native packages
375 // (e.g. @napi-rs/keyring-darwin-arm64, @secure-exec/v8-win32-x64) so we252 // (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 all254 // 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 server295 // 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 --compile298 // 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 disk299 // 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.json314 // 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 binary358 // 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
669const publish = async (channel: string) => {542const 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 it594// 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
713const NODE_SHIM = `#!/usr/bin/env node606const NODE_SHIM = `#!/usr/bin/env node
714const childProcess = require("child_process");607const childProcess = require("child_process");
715const fs = require("fs");608const fs = require("fs");
716const path = require("path");609const path = require("path");
610const os = require("os");
717 611
718function run(target) {612function 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
727if (process.env.EXECUTOR_BIN_PATH) run(process.env.EXECUTOR_BIN_PATH);
728
729const scriptDir = path.dirname(fs.realpathSync(__filename));
730const binary = process.platform === "win32" ? "executor.exe" : "executor";
731const runtimeBinary = path.join(scriptDir, "runtime", binary);
732const legacyCached = path.join(scriptDir, process.platform === "win32" ? ".executor.exe" : ".executor");
733
734const resolveInstalledBinary = () => {
735 if (fs.existsSync(runtimeBinary)) {
736 return runtimeBinary;
737 }
738 if (fs.existsSync(legacyCached)) {
739 return legacyCached;
740 }
741 return null;
742};
743
744const 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
773const installedBinary = installIfNeeded();
774if (!installedBinary) {
775 console.error("executor binary is missing. Reinstall the package or run 'npm rebuild executor'.");
776 process.exit(1);
777}621}
778 622
779run(installedBinary);623if (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
786const POSTINSTALL_SCRIPT = `#!/usr/bin/env node
787const childProcess = require("child_process");
788const fs = require("fs");
789const path = require("path");
790const os = require("os");
791
792const packageDir = path.dirname(fs.realpathSync(__filename));
793const binDir = path.join(packageDir, "bin");
794const runtimeDir = path.join(binDir, "runtime");
795const packageJson = JSON.parse(fs.readFileSync(path.join(packageDir, "package.json"), "utf8"));
796 627
797const repositoryUrl = typeof packageJson.repository === "string"628const isWin = process.platform === "win32";
798 ? packageJson.repository629const binary = isWin ? "executor.exe" : "executor";
799 : packageJson.repository && packageJson.repository.url;
800const githubBase = String(packageJson.homepage || repositoryUrl || "https://github.com/RhysSullivan/executor")
801 .replace(/^git[+]/, "")
802 .replace(/.git$/, "");
803const version = packageJson.version;
804 630
631// Package names use "windows" (not "win32") to match what the build script emits.
805const platformMap = { darwin: "darwin", linux: "linux", win32: "windows" };632const platformMap = { darwin: "darwin", linux: "linux", win32: "windows" };
633const archMap = { x64: "x64", arm64: "arm64" };
806const platform = platformMap[os.platform()] || os.platform();634const platform = platformMap[os.platform()] || os.platform();
807const arch = os.arch() === "arm64" ? "arm64" : "x64";635const arch = archMap[os.arch()] || os.arch();
808const binary = platform === "windows" ? "executor.exe" : "executor";
809const cachedBinary = path.join(runtimeDir, binary);
810const legacyCachedBinary = path.join(binDir, platform === "windows" ? ".executor.exe" : ".executor");
811 636
812const isMusl = (() => {637const 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
822const 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.
649const 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})();
827const archiveName = platform === "linux" ? assetBase + ".tar.gz" : assetBase + ".zip";
828const downloadUrl = githubBase + "/releases/download/v" + version + "/" + archiveName;
829const archivePath = path.join(packageDir, archiveName);
830
831const 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
839const 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
848const 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 = [657function 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.exe662 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 () => {668for (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.
683const scriptDir = path.dirname(fs.realpathSync(__filename));
684const previewBinary = path.join(scriptDir, "runtime", binary);
685if (fs.existsSync(previewBinary)) {
686 spawnAndExit(previewBinary);
687 return;
688}
689
690const pm = detectPackageManager();
691const reinstall = pm === "bun"
692 ? "bun install -g executor"
693 : pm === "npm"
694 ? "npm install -g executor"
695 : "reinstall executor";
696console.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);
701process.exit(1);
895`;702`;
896 703
897// ---------------------------------------------------------------------------704// ---------------------------------------------------------------------------
apps/cli/src/main.ts+2−1
@@ -1,6 +1,7 @@
1// Ensure binaries next to the executor (e.g. secure-exec-v8) are on $PATH
2import { randomUUID } from "node:crypto";1import { randomUUID } from "node:crypto";
3import { dirname, join, resolve } from "node:path";2import { 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.
4const execDir = dirname(process.execPath);5const execDir = dirname(process.execPath);
5if (process.env.PATH && !process.env.PATH.includes(execDir)) {6if (process.env.PATH && !process.env.PATH.includes(execDir)) {
6 process.env.PATH = `${execDir}:${process.env.PATH}`;7 process.env.PATH = `${execDir}:${process.env.PATH}`;