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
106106 const isCurrentPlatform = (t: Target) =>
107107 t.os === process.platform && t.arch === process.arch && !t.abi;
108108
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−
136109 /**
137110 * Resolve the platform-specific @napi-rs/keyring native binding for a target.
138111 *
@@ -260,102 +233,6 @@ const createEmbeddedMigrationsSource = async () => { · 121 unmodified lines
260233 ].join("\n");
261234 };
262235
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−
359236 // ---------------------------------------------------------------------------
360237 // Build platform binaries
361238 // ---------------------------------------------------------------------------
@@ -372,10 +249,9 @@ const buildBinaries = async (targets: Target[], mode: BuildMode) => { · 10 unmodified lines
372249 await rm(distDir, { recursive: true, force: true });
373250
374251 // 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
376− // can copy the right .node / binary next to each target's executor.
377− // `bun install --frozen-lockfile --cpu=* --os=*` extracts them all
378− // without modifying the lockfile.
252+ // (e.g. @napi-rs/keyring-darwin-arm64) so we can copy the right .node next
253+ // to each target's executor. `bun install --frozen-lockfile --cpu=* --os=*`
254+ // extracts them all without modifying the lockfile.
379255 const needsCrossPlatform = targets.some((t) => !isCurrentPlatform(t));
380256 if (needsCrossPlatform) {
381257 console.log("Installing optional native deps for all platforms...");
@@ -414,20 +290,11 @@ const buildBinaries = async (targets: Target[], mode: BuildMode) => { · 32 unmodified lines
414290 target: bunTarget(target),
415291 outfile: join(binDir, binaryName(target)),
416292 },
417− plugins: [await secureExecBundlePlugin()],
418293 });
419294
420295 // Copy QuickJS WASM next to binary — loaded at runtime by the server
421296 await cp(quickJsWasmPath, join(binDir, "emscripten-module.wasm"));
422297
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−
431298 // Copy @napi-rs/keyring native binding next to executor — bun --compile
432299 // doesn't bundle .node files, so the loader needs to find it on disk
433300 // via NAPI_RS_NATIVE_LIBRARY_PATH (set in main.ts).
@@ -444,16 +311,23 @@ const buildBinaries = async (targets: Target[], mode: BuildMode) => { · 10 unmodified lines
444311 console.log(` OK: ${version.trim()}`);
445312 }
446313
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.
448318 await writeFile(
449319 join(outDir, "package.json"),
450320 JSON.stringify(
451321 {
452322 name,
453323 version: meta.version,
324+ description: `${meta.description} (${name})`,
454325 os: [target.os],
455326 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,
457331 },
458332 null,
459333 2,
@@ -481,14 +355,14 @@ const buildWrapperPackage = async (binaries: Record<string, string>) => { · 21 unmodified lines
481355
482356 await mkdir(binDir, { recursive: true });
483357
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).
485363 await writeFile(join(binDir, "executor"), NODE_SHIM);
486364 await chmod(join(binDir, "executor"), 0o755);
487365
488− // Postinstall downloads the matching release asset from GitHub Releases
489− await writeFile(join(wrapperDir, "postinstall.cjs"), POSTINSTALL_SCRIPT);
490−
491− // Package.json
492366 await writeFile(
493367 join(wrapperDir, "package.json"),
494368 JSON.stringify(
@@ -502,9 +376,9 @@ const buildWrapperPackage = async (binaries: Record<string, string>) => { · 7 unmodified lines
502376 repository: meta.repository,
503377 license: meta.license,
504378 bin: { executor: "bin/executor" },
505− scripts: {
506− postinstall: "node ./postinstall.cjs",
507− },
379+ // Per-platform compiled binaries. npm/bun install only the entry
380+ // matching the current os/cpu; everything else is a no-op.
381+ optionalDependencies: binaries,
508382 engines: {
509383 node: ">=20",
510384 },
@@ -514,7 +388,6 @@ const buildWrapperPackage = async (binaries: Record<string, string>) => { · 3 unmodified lines
514388 ) + "\n",
515389 );
516390
517− // README
518391 const readmePath = join(repoRoot, "README.md");
519392 if (existsSync(readmePath)) {
520393 await cp(readmePath, join(wrapperDir, "README.md"));
@@ -522,7 +395,7 @@ const buildWrapperPackage = async (binaries: Record<string, string>) => { · 1 unmodified line
522395
523396 console.log(`\nWrapper package: ${wrapperDir}`);
524397 console.log(` ${meta.name}@${meta.version}`);
525− console.log(` release assets: ${Object.keys(binaries).join(", ")}`);
398+ console.log(` optionalDependencies: ${Object.keys(binaries).join(", ")}`);
526399 };
527400
528401 // ---------------------------------------------------------------------------
@@ -669,9 +542,20 @@ const publishPackedPackage = async (pkgDir: string, channel: string) => { · 140 unmodified lines
669542 const publish = async (channel: string) => {
670543 const meta = await readMetadata();
671544
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+
673557 const wrapperDir = join(distDir, meta.name);
674− console.log(`Publishing ${wrapperDir}...`);
558+ console.log(`Publishing wrapper ${wrapperDir}...`);
675559 await publishPackedPackage(wrapperDir, channel);
676560 };
677561
@@ -707,107 +591,48 @@ const createReleaseAssets = async () => { · 29 unmodified lines
707591 };
708592
709593 // ---------------------------------------------------------------------------
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.
711604 // ---------------------------------------------------------------------------
712605
713606 const NODE_SHIM = `#!/usr/bin/env node
714607 const childProcess = require("child_process");
715608 const fs = require("fs");
716609 const path = require("path");
610+const os = require("os");
717611
718−function run(target) {
719− const result = childProcess.spawnSync(target, process.argv.slice(2), { stdio: "inherit" });
720− if (result.error) {
721− console.error(result.error.message);
722− process.exit(1);
723− }
724− process.exit(typeof result.status === "number" ? result.status : 0);
725−}
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,
612+function spawnAndExit(target) {
613+ const child = childProcess.spawn(target, process.argv.slice(2), { stdio: "inherit" });
614+ child.on("error", (err) => { console.error(err.message); process.exit(1); });
615+ const forward = (signal) => { if (!child.killed) { try { child.kill(signal); } catch {} } };
616+ ["SIGINT", "SIGTERM", "SIGHUP"].forEach((sig) => process.on(sig, () => forward(sig)));
617+ child.on("exit", (code, signal) => {
618+ if (signal) process.kill(process.pid, signal);
619+ else process.exit(typeof code === "number" ? code : 0);
759620 });
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);
777621 }
778622
779−run(installedBinary);
780−`;
781−
782−// ---------------------------------------------------------------------------
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"));
623+if (process.env.EXECUTOR_BIN_PATH) {
624+ spawnAndExit(process.env.EXECUTOR_BIN_PATH);
625+ return;
626+}
796627
797−const repositoryUrl = typeof packageJson.repository === "string"
798− ? packageJson.repository
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;
628+const isWin = process.platform === "win32";
629+const binary = isWin ? "executor.exe" : "executor";
804630
631+// Package names use "windows" (not "win32") to match what the build script emits.
805632 const platformMap = { darwin: "darwin", linux: "linux", win32: "windows" };
633+const archMap = { x64: "x64", arm64: "arm64" };
806634 const platform = platformMap[os.platform()] || os.platform();
807−const arch = os.arch() === "arm64" ? "arm64" : "x64";
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");
635+const arch = archMap[os.arch()] || os.arch();
811636
812637 const isMusl = (() => {
813638 if (platform !== "linux") return false;
@@ -819,79 +644,61 @@ const isMusl = (() => { · 5 unmodified lines
819644 return false;
820645 })();
821646
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 = (() => {
823650 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−
853651 if (platform === "linux") {
854− run("tar", ["-xzf", archivePath, "-C", runtimeDir]);
855− return;
856− }
857−
858− if (platform === "darwin") {
859− run("unzip", ["-o", archivePath, "-d", runtimeDir]);
860− return;
652+ return isMusl ? [base + "-musl", base] : [base, base + "-musl"];
861653 }
654+ return [base];
655+})();
862656
863− const psCommand = [
864− "$ErrorActionPreference = 'Stop'",
865− "Expand-Archive -LiteralPath '" + archivePath.replace(/'/g, "''") + "' -DestinationPath '" + runtimeDir.replace(/'/g, "''") + "' -Force",
866− ].join("; ");
867− const psArgs = ["-NoLogo", "-NoProfile", "-Command", psCommand];
868− // Prefer pwsh (PowerShell 7+) which reliably has Expand-Archive; fall back to powershell.exe
869− const pwshResult = childProcess.spawnSync("pwsh", psArgs, { stdio: "inherit" });
870− if (pwshResult.error || (typeof pwshResult.status === "number" && pwshResult.status !== 0)) {
871− run("powershell.exe", psArgs);
657+function detectPackageManager() {
658+ const ua = process.env.npm_config_user_agent || "";
659+ if (/\\bbun\\//.test(ua)) return "bun";
660+ const execPath = process.env.npm_execpath || "";
661+ if (execPath.includes("bun")) return "bun";
662+ if (__dirname.includes(".bun/install/global") || __dirname.includes(".bun\\\\install\\\\global")) {
663+ return "bun";
872664 }
873−};
665+ return ua ? "npm" : null;
666+}
874667
875−(async () => {
668+for (const name of candidates) {
876669 try {
877− await download();
878− extract();
879−
880− if (!fs.existsSync(cachedBinary)) {
881− throw new Error("Expected extracted binary at " + cachedBinary);
882− }
883−
884− if (fs.existsSync(legacyCachedBinary)) {
885− fs.unlinkSync(legacyCachedBinary);
670+ const pkgJson = require.resolve(name + "/package.json");
671+ const candidate = path.join(path.dirname(pkgJson), "bin", binary);
672+ if (fs.existsSync(candidate)) {
673+ spawnAndExit(candidate);
674+ return;
886675 }
887− if (platform !== "windows") fs.chmodSync(cachedBinary, 0o755);
888− fs.rmSync(archivePath, { force: true });
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);
676+ } catch {
677+ // package not installed for this platform; try next candidate
893678 }
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);
895702 `;
896703
897704 // ---------------------------------------------------------------------------
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
21 import { randomUUID } from "node:crypto";
32 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.
45 const execDir = dirname(process.execPath);
56 if (process.env.PATH && !process.env.PATH.includes(execDir)) {
67 process.env.PATH = `${execDir}:${process.env.PATH}`;