diff --git a/docs-site/guide/custom-node.md b/docs-site/guide/custom-node.md index a79df4e2..d83ac125 100644 --- a/docs-site/guide/custom-node.md +++ b/docs-site/guide/custom-node.md @@ -25,8 +25,17 @@ PKG_NODE_PATH=/path/to/node pkg app.js ## Caveats - The binary must be a **compatible** Node.js version — `pkg` still injects its bootstrap prelude and payload, and depends on specific symbol offsets. Use a version supported by `pkg-fetch` unless you know what you're doing. -- `PKG_NODE_PATH` affects a **single target**. Multi-target builds still fetch the other platforms from `pkg-fetch` unless you set it per-run. -- SEA mode uses stock Node.js by its own mechanism and does **not** honour `PKG_NODE_PATH` in the same way. For SEA, use `process.execPath` of the Node.js you want directly. +- `PKG_NODE_PATH` affects a **single target**. In standard mode, multi-target builds still fetch the other platforms from `pkg-fetch` unless you set it per-run. + +### SEA mode caveats + +SEA mode honours `PKG_NODE_PATH`, and also has its own overrides: the `--sea-node-path` CLI flag or the `seaNodePath` pkg-config key (both take precedence over `PKG_NODE_PATH`). To embed the Node.js you're currently running, pass `PKG_NODE_PATH="$(command -v node)"` (or `--sea-node-path "$(command -v node)"`). + +Because SEA injects the payload into the supplied binary directly (there is no per-platform fetch fallback), supplying a custom base binary imposes strict limits to avoid silently baking a mismatched binary into the output: + +- **Single target only** — the custom binary applies to exactly one platform/arch, so pkg rejects a run whose targets span more than one (including `linux` vs `alpine` vs `linuxstatic`, which a single binary can't be all of at once). +- **No cross-compilation** — pkg validates the binary by **running it** and checking its `process.platform`, `process.arch`, and major version against the requested target. So the custom base binary must be **natively runnable on your build host** (e.g. you can't supply a Linux binary while building on macOS). Standard, non-SEA builds don't have this restriction and still cross-compile via `pkg-fetch`. +- **Flavor matching is yours** — `process.platform` reports `linux` for glibc, musl (`alpine`), and static (`linuxstatic`) alike, so matching the specific flavor of your binary to the target remains your responsibility. ## See also diff --git a/lib/config.ts b/lib/config.ts index 716febe0..09e59971 100644 --- a/lib/config.ts +++ b/lib/config.ts @@ -138,6 +138,18 @@ const FLAG_SPECS: readonly FlagSpec[] = [ default: false, }, { cli: 'sea', cfg: 'sea', resolved: 'sea', kind: 'bool', default: false }, + { + // SEA mode: use a specific Node binary as the base for the executable + // instead of downloading one from nodejs.org. Lets you embed a custom + // build (e.g. one that runs on older glibc, or a differently-configured + // runtime). Equivalent to the PKG_NODE_PATH env var (which this overrides); + // the binary's major version must match the target's, and it applies to a + // single target. Also settable as the `seaNodePath` pkg-config key. + cli: 'sea-node-path', + cfg: 'seaNodePath', + resolved: 'seaNodePath', + kind: 'string', + }, { cli: 'compress', cfg: 'compress', @@ -485,6 +497,8 @@ export interface ResolvedFlags { fallbackToSource: boolean; public: boolean; sea: boolean; + /** SEA mode: path to a base Node binary to embed (overrides the download / PKG_NODE_PATH). */ + seaNodePath: string | undefined; publicPackages: string[] | undefined; noDictionary: string[] | undefined; bakeOptions: string[] | undefined; diff --git a/lib/help.ts b/lib/help.ts index 31f0e613..9f3c9410 100644 --- a/lib/help.ts +++ b/lib/help.ts @@ -26,10 +26,11 @@ export default function help() { --signature enable macOS binary signing (default; use to override signature:false in config) --no-signature skip macOS binary signing [default: sign] --sea (Experimental) compile given file using node's SEA feature. Requires node v20.0.0 or higher and only single file is supported + --sea-node-path SEA mode: path to a base Node binary to embed instead of downloading one (overrides PKG_NODE_PATH; must match the target's platform/arch/major; single target only) All build-shaping flags above (compress, fallback-to-source, public, public-packages, - options, bytecode, native-build, no-dict, debug, signature, sea) can also be set in - the pkg config file (camelCase keys). CLI flags override config values. + options, bytecode, native-build, no-dict, debug, signature, sea, sea-node-path) can also + be set in the pkg config file (camelCase keys). CLI flags override config values. ${pc.dim('Examples:')} diff --git a/lib/index.ts b/lib/index.ts index 4b224def..5387ca1c 100644 --- a/lib/index.ts +++ b/lib/index.ts @@ -192,6 +192,11 @@ export async function exec( } if (flags.sea) { + // Base-node override: embed a custom Node binary instead of downloading one. + // From --sea-node-path / the seaNodePath config key, falling back to the + // PKG_NODE_PATH env var (resolved in lib/sea.ts). Lets you ship a SEA built + // on a custom runtime (e.g. one linked against an older glibc). + const seaBase = { nodePath: flags.seaNodePath }; if (inputJson || configJson) { // Enhanced SEA mode — use walker pipeline. // seaEnhanced validates the host Node version and minTargetMajor itself. @@ -202,6 +207,7 @@ export async function exec( params: { ...params, seaMode: true }, addition: isConfiguration(input) ? input : undefined, doCompress: flags.compress, + ...seaBase, }); } else { // Simple SEA mode — plain .js file without package.json. @@ -216,6 +222,7 @@ export async function exec( await sea(inputFin, { targets, signature: flags.signature, + ...seaBase, }); } return; diff --git a/lib/sea.ts b/lib/sea.ts index a2e976b2..f9b0e7f7 100644 --- a/lib/sea.ts +++ b/lib/sea.ts @@ -53,6 +53,13 @@ const execFileAsync = util.promisify(cExecFile); const SEA_SENTINEL_FUSE = 'NODE_SEA' + '_FUSE_fce680ab2cc467b6e072b8b5df1996b2'; +/** + * Minimum Node.js major a SEA *target* (and thus a custom base binary) must be. + * node:sea exists from Node 20, but pkg's enhanced VFS bootstrap requires 22 + * (see {@link seaEnhanced} and engines.node / the @roberts_lando/vfs dep). + */ +const MIN_SEA_TARGET_MAJOR = 22; + /** Returns stat of path when exits, false otherwise */ const exists = async (path: string) => { try { @@ -329,6 +336,265 @@ async function getNodeVersion( return latest.version as NodeVersion; } +/** + * The custom base Node binary to embed for SEA, or `undefined` to download one. + * + * Precedence matches standard mode: an explicit `opts.nodePath` (from the + * `--sea-node-path` CLI flag or the `seaNodePath` pkg-config key) wins over the + * `PKG_NODE_PATH` environment variable. `PKG_NODE_PATH` is the same env var + * pkg-fetch honours for the traditional build path (`localPlace()`); folding it + * in here makes it work for SEA too, instead of a separate SEA-only mechanism. + */ +function resolveCustomBaseNode( + opts: GetNodejsExecutableOptions, +): string | undefined { + if (opts.nodePath) return resolve(opts.nodePath); + if (process.env.PKG_NODE_PATH) return resolve(process.env.PKG_NODE_PATH); + return undefined; +} + +/** + * pkg target platform/arch identifiers -> the values Node reports as + * `process.platform` / `process.arch`. + * + * A custom base binary is validated by *running* it (see {@link probeNode}) and + * comparing what it reports against the target, so pkg-fetch's identifiers must + * be translated into Node's: pkg's `macos`/`win` are Node's `darwin`/`win32`; + * `alpine`/`linuxstatic` are pkg-fetch *flavors* of Linux that Node still + * reports as `linux` (the musl/static distinction isn't in `process.platform`); + * pkg's `armv7`/`armv7l`/`x86` are Node's `arm`/`ia32`. + * + * Keyed over the full `knownPlatforms`/`knownArchs` sets (asserted complete by + * `sea-target-identity.test.ts`) so a newly-added target can't silently fall + * through to a wrong identity default — which would falsely reject a legitimate + * single target such as a musl-on-musl (`alpine`) or 32-bit-ARM (`armv7l`) + * custom runtime, the very case this feature exists for. + */ +const TARGET_PLATFORM_TO_PROCESS: Record = { + alpine: 'linux', + freebsd: 'freebsd', + linux: 'linux', + linuxstatic: 'linux', + macos: 'darwin', + win: 'win32', +}; + +const TARGET_ARCH_TO_PROCESS: Record = { + x64: 'x64', + x86: 'ia32', + armv7: 'arm', + armv7l: 'arm', + arm64: 'arm64', + ppc64: 'ppc64', + s390x: 's390x', + riscv64: 'riscv64', + loong64: 'loong64', +}; + +/** The `process.platform` a base Node binary for `targetPlatform` must report. */ +export function expectedProcessPlatform(targetPlatform: string): string { + const platform = TARGET_PLATFORM_TO_PROCESS[targetPlatform]; + if (platform === undefined) { + throw wasReported( + `No process.platform mapping for target platform "${targetPlatform}" — ` + + `please report this so pkg can learn it.`, + ); + } + return platform; +} + +/** The `process.arch` a base Node binary for `targetArch` must report. */ +export function expectedProcessArch(targetArch: string): string { + const arch = TARGET_ARCH_TO_PROCESS[targetArch]; + if (arch === undefined) { + throw wasReported( + `No process.arch mapping for target arch "${targetArch}" — ` + + `please report this so pkg can learn it.`, + ); + } + return arch; +} + +/** + * Assert a custom base binary's major version is compatible with the requested + * target `nodeRange`. Pure (no process spawn) so it's unit-testable. + * + * - Concrete target major (e.g. `node24`): the binary's major must equal it. + * - `latest` / unparseable range: there's no specific major to match, but the + * binary must still be new enough for SEA ({@link MIN_SEA_TARGET_MAJOR}). + * seaEnhanced's later `minTargetMajor` check resolves `latest` to the host + * major (>= 22), so without this floor a too-old *custom* binary (e.g. Node + * 18 with `-t latest-...`) would slip through and break at injection instead + * of failing cleanly here. + */ +export function assertBaseMajorSatisfiesTarget( + binVersion: string, + nodeRange: string, +): void { + const binMajor = parseInt(binVersion.replace(/^v/, ''), 10); + const targetMajor = parseInt(nodeRange.replace('node', ''), 10); + if (Number.isNaN(targetMajor)) { + if (binMajor < MIN_SEA_TARGET_MAJOR) { + throw wasReported( + `Custom base Node binary is ${binVersion}, but SEA requires Node ` + + `${MIN_SEA_TARGET_MAJOR} or newer.`, + ); + } + return; + } + if (binMajor !== targetMajor) { + throw wasReported( + `Custom base Node binary is ${binVersion} (major ${binMajor}), but ` + + `target "${nodeRange}" requests Node ${targetMajor}. The binary's ` + + `major version must match the target.`, + ); + } +} + +/** Memoized {@link probeNode} results, keyed by binary path. */ +const probeCache = new Map< + string, + { version: string; platform: string; arch: string } +>(); + +/** + * What a Node binary reports about itself when run (`process.version` / + * `process.platform` / `process.arch`), emitted as JSON so parsing can't be + * tripped by an unexpected delimiter. The custom base binary must therefore be + * runnable on the build host; a foreign, non-runnable binary fails here with a + * clear message instead of a cryptic ENOEXEC, and unexpected output fails with a + * clear parse error rather than silently yielding undefined fields. The result + * is memoized so the two callers (the target guard and the version resolver) + * share a single spawn. + */ +async function probeNode( + binPath: string, +): Promise<{ version: string; platform: string; arch: string }> { + if (binPath === process.execPath) { + return { + version: process.version, + platform: process.platform, + arch: process.arch, + }; + } + const cached = probeCache.get(binPath); + if (cached) return cached; + let stdout: string; + try { + ({ stdout } = await execFileAsync(binPath, [ + '-p', + 'JSON.stringify({ version: process.version, platform: process.platform, arch: process.arch })', + ])); + } catch (err) { + const reason = err instanceof Error ? err.message : String(err); + throw wasReported( + `Couldn't run the custom base Node binary "${binPath}" on this host. ` + + `pkg runs it to read its version/platform/arch and validate it against ` + + `the target, so the binary must be runnable here (got: ${reason}).`, + ); + } + let result: { version: string; platform: string; arch: string }; + try { + const parsed = JSON.parse(stdout.trim()); + if ( + typeof parsed?.version !== 'string' || + typeof parsed?.platform !== 'string' || + typeof parsed?.arch !== 'string' + ) { + throw new Error('missing version/platform/arch'); + } + result = { + version: parsed.version, + platform: parsed.platform, + arch: parsed.arch, + }; + } catch (err) { + const reason = err instanceof Error ? err.message : String(err); + throw wasReported( + `Custom base Node binary "${binPath}" produced unexpected output when ` + + `probed for its identity (${reason}): ${JSON.stringify(stdout.trim())}`, + ); + } + probeCache.set(binPath, result); + return result; +} + +/** + * Guard a custom base Node binary against multi-target / wrong-platform / + * version-skew footguns. + * + * A supplied binary (via `--sea-node-path` / `seaNodePath` / `PKG_NODE_PATH`, or + * `useLocalNode`) is returned by {@link getNodejsExecutable} for *every* target, + * so a multi-target run would bake that one binary into outputs for other + * platforms/arches — silently producing broken artifacts. We: + * + * 1. Reject if the requested targets span more than one distinct + * `platform`+`arch` — one binary can't be several mutually-exclusive things + * at once (incl. linux vs alpine vs linuxstatic, which we can't tell apart + * from the binary but the user clearly can't have meant simultaneously). + * 2. Run the binary and reject if what it reports (`process.platform` / + * `process.arch`) disagrees with that single target (e.g. a macOS binary + * for a `linux` target, or x64 for `arm64`). We exec it for its version + * regardless (here and in {@link resolveTargetNodeVersion}), so asking it + * everything at once is simpler and stricter than sniffing its header — at + * the cost of requiring the base to be runnable on the build host, which is + * already true in practice. The glibc/musl/static flavor isn't reported, so + * that sub-distinction stays the user's responsibility. + * 3. Verify the binary's major matches the requested `nodeRange` + * (`assertSingleTargetMajor` only compares the targets to each other). + */ +async function assertCustomBaseNodeTarget( + targets: (NodeTarget & Partial)[], + opts: GetNodejsExecutableOptions, +): Promise { + const customNode = resolveCustomBaseNode(opts); + if (!customNode && !opts.useLocalNode) return; + const binPath = customNode ?? process.execPath; + + // 1. A single binary maps to exactly one platform+arch. Key on the raw target + // suffix strings so linux / alpine / linuxstatic stay distinct (they collapse + // under getNodeOs, but they're mutually exclusive runtimes). + const combos = new Map(); + for (const t of targets) { + const platform = String(t.platform); + const arch = String(t.arch); + combos.set(`${platform}-${arch}`, { platform, arch }); + } + if (combos.size > 1) { + throw wasReported( + `A custom base Node binary applies to a single platform/arch, but the ` + + `requested targets span ${combos.size}: ${[...combos.keys()].join(', ')}. ` + + `One binary can't be all of them — run pkg once per target with a ` + + `matching binary.`, + ); + } + const { platform, arch } = [...combos.values()][0]; + + // 2. Ask the binary what it actually is and reject any disagreement. pkg's + // target identifiers differ from Node's process.platform/arch (macos→darwin, + // armv7l→arm, alpine/linuxstatic→linux, …), so translate before comparing. + const node = await probeNode(binPath); + + const expectedPlatform = expectedProcessPlatform(platform); + if (node.platform !== expectedPlatform) { + throw wasReported( + `Custom base Node binary reports platform "${node.platform}", but target ` + + `"${platform}" needs a "${expectedPlatform}" binary.`, + ); + } + const expectedArch = expectedProcessArch(arch); + if (node.arch !== expectedArch) { + throw wasReported( + `Custom base Node binary reports arch "${node.arch}", but target "${arch}" ` + + `needs a "${expectedArch}" binary. It must match the target's architecture.`, + ); + } + + // 3. Major version compatibility (assertSingleTargetMajor only compares the + // targets to each other, never to the supplied binary). + assertBaseMajorSatisfiesTarget(node.version, targets[0].nodeRange); +} + /** * Resolve the concrete Node.js version (e.g. `v22.22.2`) pkg will use * for `target` — mirrors the version selection done inside @@ -341,11 +607,11 @@ async function resolveTargetNodeVersion( opts: GetNodejsExecutableOptions, ): Promise { if (opts.useLocalNode) return process.version as NodeVersion; - if (opts.nodePath) { + const customNode = resolveCustomBaseNode(opts); + if (customNode) { // A user-supplied binary can be any version — don't assume it // matches the host. Ask it directly. - const { stdout } = await execFileAsync(opts.nodePath, ['--version']); - return stdout.trim() as NodeVersion; + return (await probeNode(customNode)).version as NodeVersion; } const os = getNodeOs(target.platform); const arch = getNodeArch(target.arch); @@ -357,15 +623,16 @@ async function getNodejsExecutable( target: NodeTarget, opts: GetNodejsExecutableOptions, ): Promise { - if (opts.nodePath) { - // check if the nodePath exists - if (!(await exists(opts.nodePath))) { + const customNode = resolveCustomBaseNode(opts); + if (customNode) { + // check if the custom base binary exists + if (!(await exists(customNode))) { throw new Error( - `Priovided node executable path "${opts.nodePath}" does not exist`, + `Provided node executable path "${customNode}" does not exist`, ); } - return opts.nodePath; + return customNode; } if (opts.useLocalNode) { @@ -552,9 +819,9 @@ async function withSeaTmpDir( */ function assertHostSeaNodeVersion(): number { const nodeMajor = parseInt(process.version.slice(1).split('.')[0], 10); - if (nodeMajor < 22) { + if (nodeMajor < MIN_SEA_TARGET_MAJOR) { throw new Error( - `SEA support requires at least node v22.0.0, actual node version is ${process.version}`, + `SEA support requires at least node v${MIN_SEA_TARGET_MAJOR}.0.0, actual node version is ${process.version}`, ); } return nodeMajor; @@ -765,11 +1032,12 @@ export async function seaEnhanced( } assertSingleTargetMajor(opts.targets); + await assertCustomBaseNodeTarget(opts.targets, opts); const minTargetMajor = resolveMinTargetMajor(opts.targets); - if (minTargetMajor < 22) { + if (minTargetMajor < MIN_SEA_TARGET_MAJOR) { throw wasReported( - `Enhanced SEA mode requires Node >= 22 targets. ` + + `Enhanced SEA mode requires Node >= ${MIN_SEA_TARGET_MAJOR} targets. ` + `Minimum target version resolved to Node ${minTargetMajor}.`, ); } @@ -883,6 +1151,7 @@ export async function seaEnhanced( export default async function sea(entryPoint: string, opts: SeaOptions) { assertHostSeaNodeVersion(); assertSingleTargetMajor(opts.targets); + await assertCustomBaseNodeTarget(opts.targets, opts); entryPoint = resolve(process.cwd(), entryPoint); diff --git a/lib/types.ts b/lib/types.ts index 8f1121f6..54b33b5d 100644 --- a/lib/types.ts +++ b/lib/types.ts @@ -66,6 +66,12 @@ export interface PkgOptions { debug?: boolean; signature?: boolean; sea?: boolean; + /** + * SEA mode: path to a base Node binary to embed instead of downloading one + * (overrides the PKG_NODE_PATH env var). Its major version must match the + * target's, and it applies to a single target. + */ + seaNodePath?: string; } export interface PackageJson { @@ -200,4 +206,11 @@ export interface PkgExecOptions { noDictionary?: string[]; /** Sign macOS binaries when applicable. Default `true`. */ signature?: boolean; + /** + * SEA mode: path to a base Node binary to embed instead of downloading one + * from nodejs.org. Use to embed a custom build (e.g. one linked against an + * older glibc). Overrides the PKG_NODE_PATH env var. Its major version must + * match the target's, and it applies to a single target. + */ + seaNodePath?: string; } diff --git a/test/test-95-sea-custom-node/index.js b/test/test-95-sea-custom-node/index.js new file mode 100644 index 00000000..0755ef03 --- /dev/null +++ b/test/test-95-sea-custom-node/index.js @@ -0,0 +1,6 @@ +'use strict'; + +// Packaged into a SEA on top of a custom base Node binary (the host's own node, +// supplied via --sea-node-path or PKG_NODE_PATH). Output is asserted by main.js; +// process.pkg confirms the enhanced SEA bootstrap is active. +console.log('custom-node SEA OK:' + (process.pkg != null)); diff --git a/test/test-95-sea-custom-node/main.js b/test/test-95-sea-custom-node/main.js new file mode 100644 index 00000000..d34eb43c --- /dev/null +++ b/test/test-95-sea-custom-node/main.js @@ -0,0 +1,168 @@ +#!/usr/bin/env node + +'use strict'; + +const assert = require('assert'); +const fs = require('fs'); +const os = require('os'); +const path = require('path'); +const utils = require('../utils.js'); + +// Enhanced SEA requires Node.js >= 22 +if (utils.getNodeMajorVersion() < 22) { + return; +} + +assert(__dirname === process.cwd()); + +const input = './package.json'; +const expected = 'custom-node SEA OK:true\n'; + +// Both forms supply a custom base Node binary instead of downloading one. We +// use the host's own node (process.execPath) so the embedded runtime matches +// the single `host` target's platform/arch/major (required by the guard in +// lib/sea.ts). runSeaHostOnly builds for `host` only — a single target. + +// 1. Explicit --sea-node-path flag (exercises the exists() + `node --version` +// branch in getNodejsExecutable / the version guard). +{ + const testName = 'test-95-sea-custom-node'; + const newcomers = utils.seaHostOutputs(testName); + const before = utils.filesBefore(newcomers); + utils.runSeaHostOnly(input, testName, ['--sea-node-path', process.execPath]); + utils.assertSeaOutput(testName, expected); + utils.filesAfter(before, newcomers, { tolerateWindowsEbusy: true }); +} + +// 2. PKG_NODE_PATH env var (exercises the env fallback folded into SEA). pkg +// runs in a child process that inherits this env. +{ + const testName = 'test-95-sea-custom-node-env'; + const newcomers = utils.seaHostOutputs(testName); + const before = utils.filesBefore(newcomers); + process.env.PKG_NODE_PATH = process.execPath; + try { + utils.runSeaHostOnly(input, testName); + } finally { + delete process.env.PKG_NODE_PATH; + } + utils.assertSeaOutput(testName, expected); + utils.filesAfter(before, newcomers, { tolerateWindowsEbusy: true }); +} + +// 3. Unhappy paths — the guard rejects a custom base binary that can't satisfy +// the requested target(s). These error before any build (no output produced), +// so they're fast and host-independent. We feed the host's own node and pick +// targets it provably can't be. +const M = utils.getNodeMajorVersion(); +const hostPlatform = + process.platform === 'darwin' + ? 'macos' + : process.platform === 'win32' + ? 'win' + : 'linux'; +const hostArch = process.arch; // 'x64' | 'arm64' +// The guard runs the supplied node and compares its reported process.platform +// to the target. We feed the host's own node (process.execPath, which the guard +// reads without exec'ing it — see probeNode), so any non-host OS target +// mismatches. Pick a platform that isn't the host's — covers macOS, Linux, and +// Windows hosts. (Deliberately not alpine/linuxstatic: those report +// process.platform 'linux' like a Linux host, so they wouldn't be a mismatch.) +const otherPlatform = hostPlatform === 'macos' ? 'linux' : 'macos'; +const otherMajor = M === 22 ? 20 : 22; +const out = path.join(os.tmpdir(), 'pkg-sea-guard-out'); + +function expectGuardError(targets, re, label, nodePath = process.execPath) { + const r = utils.pkg.sync( + [ + input, + '--sea', + '--sea-node-path', + nodePath, + '--targets', + targets, + '--output', + out, + ], + { stdio: 'pipe', expect: 2 }, + ); + assert( + re.test(r.stdout + r.stderr), + `${label}: expected /${re.source}/, got:\n${r.stdout}\n${r.stderr}`, + ); +} + +// a) Multiple distinct platform/arch targets — one binary can't be all of them. +expectGuardError( + `node${M}-linux-x64,node${M}-macos-arm64`, + /single platform\/arch|span \d/i, + 'multi platform/arch', +); + +// b) Multiple mutually-exclusive Linux flavors (glibc vs musl) — same guard. +expectGuardError( + `node${M}-linux-x64,node${M}-alpine-x64`, + /single platform\/arch|span \d/i, + 'multi linux flavor', +); + +// c) Single target, wrong platform: the binary reports a process.platform that +// can't match a different-OS target (e.g. a darwin node for a linux target). +expectGuardError( + `node${M}-${otherPlatform}-${hostArch}`, + /reports platform "\w+", but target/i, + 'platform mismatch', +); + +// d) Single target, right platform but wrong major. +expectGuardError( + `node${otherMajor}-${hostPlatform}-${hostArch}`, + /major version must match|requests Node/i, + 'major mismatch', +); + +// e+f) Drive probeNode's real exec + JSON-parse path with a *non-host* binary. +// Cases a-d all pass process.execPath, which short-circuits probeNode (no exec, +// no parse). Here we hand pkg a tiny mock "node" that prints a controlled +// identity, so the guard fails with a known error rather than letting pkg march +// on into postject against a non-Node file (which would blow up unpredictably). +// +// POSIX-only: a shebang script is directly execFile-able, but on Windows +// execFile can't run a .bat/.cmd without a shell and a real .exe isn't +// practical to author here. The parse/guard logic is OS-agnostic JS, so +// Linux/macOS coverage is sufficient. +if (process.platform !== 'win32') { + const mockDir = fs.mkdtempSync(path.join(os.tmpdir(), 'pkg-sea-mock-')); + + // e) Valid JSON, but a mismatched version. The mock reports the *real* host + // platform/arch (via node) so those checks pass and we reach the major check; + // it claims v22.0.0 while we request node24, so assertBaseMajorSatisfiesTarget + // throws. Exercises probeNode exec + JSON.parse + the major-version guard. + const versionMock = path.join(mockDir, 'node'); + fs.writeFileSync( + versionMock, + '#!/usr/bin/env node\n' + + "console.log(JSON.stringify({ version: 'v22.0.0', " + + 'platform: process.platform, arch: process.arch }));\n', + ); + fs.chmodSync(versionMock, 0o755); + expectGuardError( + `node24-${hostPlatform}-${hostArch}`, + /major version must match|requests Node 24/i, + 'mock binary: version mismatch', + versionMock, + ); + + // f) Non-JSON output. Exercises the parse-failure branch: probeNode runs the + // binary fine, but the output isn't the expected JSON, so it must fail with a + // clear "unexpected output" error rather than silently using undefined fields. + const garbageMock = path.join(mockDir, 'garbage-node'); + fs.writeFileSync(garbageMock, '#!/bin/sh\necho "this is not json"\n'); + fs.chmodSync(garbageMock, 0o755); + expectGuardError( + `node${M}-${hostPlatform}-${hostArch}`, + /produced unexpected output/i, + 'mock binary: non-JSON output', + garbageMock, + ); +} diff --git a/test/test-95-sea-custom-node/package.json b/test/test-95-sea-custom-node/package.json new file mode 100644 index 00000000..43bdfc94 --- /dev/null +++ b/test/test-95-sea-custom-node/package.json @@ -0,0 +1,6 @@ +{ + "name": "test-95-sea-custom-node", + "version": "1.0.0", + "main": "index.js", + "bin": "index.js" +} diff --git a/test/test.js b/test/test.js index 88c701af..3fa8544b 100644 --- a/test/test.js +++ b/test/test.js @@ -84,6 +84,7 @@ const npmTests = [ 'test-91-sea-esm-entry', 'test-92-sea-tla', 'test-94-sea-esm-import-meta', + 'test-95-sea-custom-node', ]; if (testFilter) { diff --git a/test/unit/config-parse.test.ts b/test/unit/config-parse.test.ts index 49b02293..af7fdcb5 100644 --- a/test/unit/config-parse.test.ts +++ b/test/unit/config-parse.test.ts @@ -165,6 +165,15 @@ describe('parseInput — CLI argv', () => { it('--options "" preserves the explicit empty signal', () => { assert.equal(parseInput(['--options', '', 'a.js']).flags.options, ''); }); + + it('string flag --sea-node-path (kebab key preserved)', () => { + assert.equal( + parseInput(['--sea-node-path', '/opt/node/bin/node', 'a.js']).flags[ + 'sea-node-path' + ], + '/opt/node/bin/node', + ); + }); }); describe('negation', () => { @@ -410,6 +419,20 @@ describe('resolveFlags — CLI > config > default', () => { assert.equal(f.bytecode, false); }); + it('SEA base-node override (seaNodePath) — defaults, config, CLI precedence', () => { + assert.equal(resolveFlags({}, {}).seaNodePath, undefined); + + // config key (cfg name) resolves when CLI is absent + assert.equal(resolveFlags({}, { seaNodePath: '/n' }).seaNodePath, '/n'); + + // CLI (kebab cli name) overrides config + assert.equal( + resolveFlags({ 'sea-node-path': '/cli' }, { seaNodePath: '/cfg' }) + .seaNodePath, + '/cli', + ); + }); + describe('list handling', () => { it('CLI "" clears the configured list (empty wins)', () => { const f = resolveFlags({ options: '' }, { options: ['expose-gc'] }); diff --git a/test/unit/sea-target-identity.test.ts b/test/unit/sea-target-identity.test.ts new file mode 100644 index 00000000..f1b06f96 --- /dev/null +++ b/test/unit/sea-target-identity.test.ts @@ -0,0 +1,130 @@ +import assert from 'node:assert/strict'; +import { describe, it } from 'node:test'; + +import { system } from '@yao-pkg/pkg-fetch'; + +import { + expectedProcessPlatform, + expectedProcessArch, + assertBaseMajorSatisfiesTarget, +} from '../../lib/sea'; + +// The SEA custom-base guard validates a supplied node binary by *running* it +// and comparing its reported process.platform / process.arch to the target. +// pkg's target identifiers (alpine, linuxstatic, macos, win, armv7l, x86, …) +// are NOT the values Node reports, so they must be translated first. These maps +// are easy to get wrong in a way that only bites a non-host target — which the +// e2e can't reach (it feeds the host's own node, hitting probeNode's +// process.execPath short-circuit). So pin the translation here, over the full +// known* sets, so a wrong/missing mapping fails in CI rather than shipping. + +describe('expectedProcessPlatform', () => { + it('translates every known pkg platform to a Node process.platform', () => { + // Total over knownPlatforms: a newly-added target with no mapping throws + // here instead of silently falling through to a wrong identity default. + for (const platform of system.knownPlatforms) { + const reported = expectedProcessPlatform(platform); + assert.equal( + typeof reported, + 'string', + `${platform} should map to a process.platform string`, + ); + assert.ok( + reported.length > 0, + `${platform} should map to a non-empty value`, + ); + } + }); + + it('maps the pkg-specific aliases to their Node names', () => { + assert.equal(expectedProcessPlatform('macos'), 'darwin'); + assert.equal(expectedProcessPlatform('win'), 'win32'); + // alpine / linuxstatic are pkg-fetch flavors of Linux; Node reports 'linux' + // for all of them (the musl/static distinction isn't in process.platform). + assert.equal(expectedProcessPlatform('linux'), 'linux'); + assert.equal(expectedProcessPlatform('alpine'), 'linux'); + assert.equal(expectedProcessPlatform('linuxstatic'), 'linux'); + assert.equal(expectedProcessPlatform('freebsd'), 'freebsd'); + }); + + it('throws (rather than guessing) for an unknown platform', () => { + assert.throws( + () => expectedProcessPlatform('plan9'), + /no process\.platform mapping/i, + ); + }); +}); + +describe('expectedProcessArch', () => { + it('translates every known pkg arch to a Node process.arch', () => { + for (const arch of system.knownArchs) { + const reported = expectedProcessArch(arch); + assert.equal( + typeof reported, + 'string', + `${arch} should map to a process.arch string`, + ); + assert.ok(reported.length > 0, `${arch} should map to a non-empty value`); + } + }); + + it('maps the pkg-specific aliases to their Node names', () => { + // pkg uses armv7/armv7l and x86; Node reports 'arm' and 'ia32'. + assert.equal(expectedProcessArch('armv7'), 'arm'); + assert.equal(expectedProcessArch('armv7l'), 'arm'); + assert.equal(expectedProcessArch('x86'), 'ia32'); + // The rest pass through unchanged. + assert.equal(expectedProcessArch('x64'), 'x64'); + assert.equal(expectedProcessArch('arm64'), 'arm64'); + assert.equal(expectedProcessArch('ppc64'), 'ppc64'); + assert.equal(expectedProcessArch('s390x'), 's390x'); + assert.equal(expectedProcessArch('riscv64'), 'riscv64'); + assert.equal(expectedProcessArch('loong64'), 'loong64'); + }); + + it('throws (rather than guessing) for an unknown arch', () => { + assert.throws( + () => expectedProcessArch('sparc64'), + /no process\.arch mapping/i, + ); + }); +}); + +describe('assertBaseMajorSatisfiesTarget', () => { + it('accepts a binary whose major matches a concrete target', () => { + assert.doesNotThrow(() => + assertBaseMajorSatisfiesTarget('v24.16.0', 'node24'), + ); + }); + + it('rejects a binary whose major differs from a concrete target', () => { + assert.throws( + () => assertBaseMajorSatisfiesTarget('v22.22.2', 'node24'), + /major version must match|requests Node 24/i, + ); + }); + + it('accepts a new-enough binary for a "latest" target', () => { + assert.doesNotThrow(() => + assertBaseMajorSatisfiesTarget('v24.16.0', 'latest'), + ); + // Exactly the floor is allowed. + assert.doesNotThrow(() => + assertBaseMajorSatisfiesTarget('v22.0.0', 'latest'), + ); + }); + + it('rejects a too-old binary for a "latest" target', () => { + // 'latest' has no concrete major, and seaEnhanced resolves it to the host + // major (>= 22), so this floor is the only thing catching an old custom + // binary. Node 18 / 21 must be rejected here. + assert.throws( + () => assertBaseMajorSatisfiesTarget('v18.20.4', 'latest'), + /SEA requires Node 22 or newer/i, + ); + assert.throws( + () => assertBaseMajorSatisfiesTarget('v21.7.3', 'latest'), + /SEA requires Node 22 or newer/i, + ); + }); +});