Skip to content

feat(sea): let users override the base Node binary#279

Open
cstrahan wants to merge 8 commits into
yao-pkg:mainfrom
cstrahan:cstrahan/sea-node-override
Open

feat(sea): let users override the base Node binary#279
cstrahan wants to merge 8 commits into
yao-pkg:mainfrom
cstrahan:cstrahan/sea-node-override

Conversation

@cstrahan

Copy link
Copy Markdown

Context: I work on a team that distributes software to on-prem customers, many of whom are on old systems like EL 7 with a glibc as old as 2.17. To date, this has kept us targeting very old versions of node, resulting in a web of old dependencies. I have recently built nodejs v24 on EL 7 (so the resulting binaries will run pretty much anywhere), and I would like to use it together with @yao-pkg/pkg in enhanced SEA mode so we can finally upgrade all of our on-prem software.

Unfortunately, SEA mode previously always downloaded a base Node binary from nodejs.org, so the resulting executable was pinned to an official build. That makes it impossible to ship a SEA on top of a custom runtime -- e.g. a Node built against an older glibc (to run on EL7 / older distros), or one configured differently.

The internal SeaOptions / SeaEnhancedOptions already accepted nodePath / useLocalNode; this just exposes them:

--sea-node-path embed a specific Node binary as the base
--sea-use-local-node embed the Node running pkg (process.execPath)

Both are also settable in the pkg config (seaNodePath / seaUseLocalNode) and via the programmatic exec() API. The two are mutually exclusive. The embedded binary's major version must match the target's (the existing version-skew checks still apply).

This is also a prerequisite for adopting node --build-sea (Node >= 25.5): that flag is only meaningful once the base Node is no longer a fixed download. I'll have a separate PR for that coming up as well.

SEA mode previously always downloaded a base Node binary from nodejs.org, so
the resulting executable was pinned to an official build. That makes it
impossible to ship a SEA on top of a custom runtime -- e.g. a Node built
against an older glibc (to run on EL7 / older distros), or one configured
differently.

The internal SeaOptions / SeaEnhancedOptions already accepted `nodePath` /
`useLocalNode`; this just exposes them:

  --sea-node-path <path>   embed a specific Node binary as the base
  --sea-use-local-node     embed the Node running pkg (process.execPath)

Both are also settable in the pkg config (seaNodePath / seaUseLocalNode) and
via the programmatic exec() API. The two are mutually exclusive. The embedded
binary's major version must match the target's (the existing version-skew
checks still apply).

This is also a prerequisite for adopting `node --build-sea` (Node >= 25.5):
that flag is only meaningful once the base Node is no longer a fixed download.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

@robertsLando robertsLando left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Recommend reworking around PKG_NODE_PATH rather than new SEA-only flags

pkg already has a documented way to supply a custom base Node binary — the PKG_NODE_PATH env var (pkg-fetch localPlace()), used by standard mode. It does not flow into SEA today: SEA downloads from nodejs.org via its own path in lib/sea.ts, and docs-site/guide/custom-node.md explicitly documents that gap ("SEA mode … does not honour PKG_NODE_PATH … For SEA, use process.execPath … directly").

This PR closes the gap but introduces a second, SEA-only mechanism instead of extending the one that already exists. I'd lean toward the smaller surface / one mental model:

Layer Recommendation
Honor PKG_NODE_PATH in SEA Add — fold the env var into the opts.nodePath fallback in getNodejsExecutable (~2 lines). Matches standard mode and retires the custom-node.md contradiction.
seaNodePath config key + programmatic option Keep — the only thing env can't do: live in package.json pkg config / be passed to exec({…}).
--sea-node-path CLI flag Optional — redundant with the env var; keep only for --help discoverability.
--sea-use-local-node flag Drop — it's exactly PKG_NODE_PATH="$(command -v node)".
Single-target / platform-match guard Required regardless of interface (see inline on lib/index.ts).

Precedence stays pkg's existing convention: CLI > config (seaNodePath) > PKG_NODE_PATH env.

Net: env var + config key + guard is a smaller change than this PR while covering more — it makes PKG_NODE_PATH work in SEA, which the flags alone don't.

The inline threads below are correctness/doc/test findings that apply whichever interface ships.

Nit (not in this diff): pre-existing typo PriovidedProvided at lib/sea.ts:364, in the explicit-path branch your nodePath override now exercises — worth fixing while here.

Comment thread lib/index.ts Outdated
// Base-node override (both forms are mutually exclusive). nodePath embeds a
// specific binary; useLocalNode embeds the Node running pkg. Either lets you
// ship a SEA built on a custom runtime (e.g. one linked against older glibc).
if (flags.seaNodePath && flags.seaUseLocalNode) {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 Correctness — multi-target silently bakes the wrong binary. The mutual-exclusion guard is good, but the bigger footgun is unguarded: getNodejsExecutable (lib/sea.ts) returns this single binary for every target regardless of platform/arch. With --targets node22-linux-x64,node22-macos-arm64 --sea-node-path /linux/node, both outputs get the linux binary — the macOS output is silently corrupt. --sea-use-local-node is worse: the host's node gets baked into every cross-platform target.

Add a guard here: when seaNodePath / seaUseLocalNode (or PKG_NODE_PATH) is set, require a single target whose platform+arch match the supplied binary. Standard-mode PKG_NODE_PATH carries the same "single target only" caveat (documented in custom-node.md), but there it falls back to fetching the other platforms — here it produces a broken artifact instead.

Comment thread lib/types.ts Outdated
/**
* 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). Its major version must match the target's.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟠 This claim isn't enforced. "Its major version must match the target's" — nothing checks it. assertSingleTargetMajor only compares the targets' nodeRange to each other, never to the supplied binary's actual version. So --targets node24 --sea-node-path /node22/bin/node silently produces a working node22 SEA and drops the requested node24.

Either validate major(nodePath --version) === major(targetRange) (cheap — resolveTargetNodeVersion already runs node --version), or reword the doc to say the supplied binary overrides the requested nodeRange.

Comment thread lib/help.ts Outdated
--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 (must match the target's major version)
--sea-use-local-node SEA mode: embed the Node binary running pkg (process.execPath) as the base

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟠 Docs now stale/contradictory. docs-site/guide/custom-node.md currently tells users SEA "does not honour PKG_NODE_PATH … For SEA, use process.execPath … directly." After this PR that page is wrong and needs updating in the same PR. (If you adopt the PKG_NODE_PATH-in-SEA approach from the review summary, this becomes a one-line "now works in SEA too" rather than documenting a second mechanism.)

Comment thread test/test-95-sea-local-node/main.js Outdated
// `--sea-use-local-node` embeds the Node binary running pkg as the SEA base
// instead of downloading one. Exercises the base-node override end to end (and,
// on Node >= 25.5 hosts, the in-core `--build-sea` injection path).
utils.runSeaHostOnly(input, testName, ['--sea-use-local-node']);

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 --sea-node-path has no e2e coverage. Only --sea-use-local-node gets a runtime test here; the explicit --sea-node-path branch (the exists() check + node --version probe in lib/sea.ts) is never exercised end to end. Cheap add: a second host-only case passing --sea-node-path = the host's process.execPath to hit that branch.

@cstrahan

Copy link
Copy Markdown
Author

pkg already has a documented way to supply a custom base Node binary — the PKG_NODE_PATH env var (pkg-fetch localPlace()), used by standard mode. It does not flow into SEA today: SEA downloads from nodejs.org via its own path in lib/sea.ts,

Ah, I initially attempted to use PKG_NODE_PATH, from reading the docs at https://www.npmjs.com/package/@yao-pkg/pkg-fetch, thinking that PKG_NODE_PATH might be honored by the SEA code path, and then found (empirically) that this wasn't the case.

and docs-site/guide/custom-node.md explicitly documents that gap ("SEA mode … does not honour PKG_NODE_PATH … For SEA, use process.execPath … directly").

D'oh! I completely missed https://yao-pkg.github.io/pkg/guide/custom-node -- sorry.

I'll see if I can implement the suggestions from review shortly 👍

…back)

Addresses the review on yao-pkg#279 — extend the existing mechanism rather than add a
SEA-only one:

- Honour PKG_NODE_PATH in SEA: fold it into getNodejsExecutable's nodePath
  fallback (and the version resolver), matching standard mode. Precedence is
  CLI (--sea-node-path) > config (seaNodePath) > PKG_NODE_PATH.
- Drop the --sea-use-local-node flag + seaUseLocalNode config key — it was just
  PKG_NODE_PATH="$(command -v node)". Keep --sea-node-path (for --help
  discoverability) and the seaNodePath config / exec() option.
- Required guard: a custom base binary is restricted to a single target whose
  major matches the binary; otherwise pkg errors instead of silently baking the
  wrong binary into other targets' outputs. SEA has no per-platform fetch
  fallback like standard mode, and assertSingleTargetMajor only compares the
  targets to each other, never to the supplied binary.
- Update docs-site/guide/custom-node.md (it previously said SEA does not honour
  PKG_NODE_PATH).
- Tests: drop the seaUseLocalNode unit cases; rework the e2e (now
  test-95-sea-custom-node) to cover both --sea-node-path and PKG_NODE_PATH.
- Fix pre-existing typo "Priovided" -> "Provided" in lib/sea.ts:364.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@cstrahan

Copy link
Copy Markdown
Author

@robertsLando I think I've addressed everything from review.

When this is ready, I can squash the history down to a single commit, if you'd like (left the commits split for now, in case that helps with review).

… target

Follow-up to the yao-pkg#279 review. The earlier single-target guard stopped
multi-target runs, but a SINGLE target with a mismatched binary still slipped
through — e.g. `--targets node24-linux-x64 --sea-node-path <macOS node>`
silently produced a corrupt linux output. Per the reviewer's ask ("require a
single target whose platform+arch match the supplied binary"):

- Sniff the supplied binary's container format (ELF / Mach-O / PE) and CPU arch
  from its magic bytes (sniffBinaryTarget) and reject a mismatch against the
  target — a Mach-O binary for a `linux` target, or an x64 binary for `arm64`.
- Reject targets that span more than one distinct platform+arch: one binary
  can't be several at once, including linux vs alpine vs linuxstatic (mutually
  exclusive glibc / musl / static — indistinguishable from the header, but the
  user clearly can't have meant them simultaneously). This replaces the blunt
  single-target-count check with a per-(platform,arch) one.
- The ELF glibc/musl/static flavor isn't in the header, so matching that to
  linux / alpine / linuxstatic remains the user's responsibility (documented).

Tests: a unit test for sniffBinaryTarget (crafted ELF/Mach-O/PE headers), and
unhappy-path e2e cases in test-95-sea-custom-node (multi platform/arch, multi
linux-flavor, wrong platform, wrong major — all host-independent, exit 2).
Updates --help + docs-site/guide/custom-node.md.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@cstrahan

Copy link
Copy Markdown
Author

Sorry, I just just realized I missed part of this:

With --targets node22-linux-x64,node22-macos-arm64 --sea-node-path /linux/node, both outputs get the linux binary — the macOS output is silently corrupt.

Latest commit sniffs the executable's headers to determine container format and architecture, and then I additionally assert that there isn't some disagreement between the given node binary and the requested targets.

With that, I think that's everything.

@robertsLando robertsLando left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

assertCustomBaseNodeTarget / sniffBinaryTarget — simpler approach

The binary-format sniffing here is more machinery than the problem needs. sniffBinaryTarget parses ELF/Mach-O/PE magic bytes with three machine-code lookup tables (ELF_MACHINE, MACHO_CPU, PE_MACHINE), plus BinaryFormat, formatForPlatform, and FORMAT_LABEL — ~90 lines whose whole job is to guess a binary's platform/arch from its header.

But the base binary is a Node executable, and step 3 already runs it (execFileAsync(binPath, ['--version'])). If we're running it anyway, just ask it everything in one shot:

node -p "process.version + ' ' + process.platform + ' ' + process.arch"

The key point: sniffing's only theoretical edge over running the binary is validating a base that can't run on the build host (a cross-platform base). But step 3's version check already execs the binary unconditionally for a custom path — and so does resolveTargetNodeVersion. So a non-host-runnable base already fails today, before sniffing helps. Sniffing buys nothing the PR actually delivers; it's just inconsistent with the exec already happening (and runs --version twice).

Suggested shape

Keep step 1 (the multi-target guard — simple and useful). Replace the sniff + version exec with one probe:

/** What a Node binary reports about itself when run. */
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 { stdout } = await execFileAsync(binPath, [
    '-p',
    "process.version + ' ' + process.platform + ' ' + process.arch",
  ]);
  const [version, platform, arch] = stdout.trim().split(' ');
  return { version, platform, arch };
}

/** Target-suffix -> the value Node's process.platform reports. */
const PROCESS_PLATFORM: Record<string, string> = {
  macos: 'darwin',
  win: 'win32',
  // linux / alpine / linuxstatic / freebsd report their own name verbatim
};

async function assertCustomBaseNodeTarget(
  targets: (NodeTarget & Partial<Target>)[],
  opts: GetNodejsExecutableOptions,
): Promise<void> {
  const customNode = resolveCustomBaseNode(opts);
  if (!customNode && !opts.useLocalNode) return;
  const binPath = customNode ?? process.execPath;

  // 1. One binary = one platform+arch. (linux/alpine/linuxstatic stay distinct.)
  const combos = new Map<string, { platform: string; arch: string }>();
  for (const t of targets) {
    combos.set(`${t.platform}-${t.arch}`, {
      platform: String(t.platform),
      arch: String(t.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(', ')}. Run pkg once per target.`,
    );
  }
  const { platform, arch } = [...combos.values()][0];

  // 2. Ask the binary what it actually is.
  const node = await probeNode(binPath);

  const expectedPlatform = PROCESS_PLATFORM[platform] ?? platform;
  if (node.platform !== expectedPlatform) {
    throw wasReported(
      `Custom base Node binary is for "${node.platform}", but target "${platform}" ` +
        `needs "${expectedPlatform}".`,
    );
  }
  if (node.arch !== arch) {
    throw wasReported(
      `Custom base Node binary is ${node.arch}, but target arch is "${arch}".`,
    );
  }

  const targetMajor = parseInt(targets[0].nodeRange.replace('node', ''), 10);
  const binMajor = parseInt(node.version.replace(/^v/, ''), 10);
  if (!Number.isNaN(targetMajor) && binMajor !== targetMajor) {
    throw wasReported(
      `Custom base Node binary is ${node.version} (major ${binMajor}), but target ` +
        `"${targets[0].nodeRange}" requests Node ${targetMajor}.`,
    );
  }
}

Net effect

  • Deletes sniffBinaryTarget, BinaryFormat, ELF_MACHINE / MACHO_CPU / PE_MACHINE, formatForPlatform, FORMAT_LABEL, and the open import — ~90 lines and one whole concept gone.
  • process.platform reports linux for glibc/musl/static alike, so the linux-family caveat is preserved for free; process.arch matches the NodeArch values directly, so no arch table is needed.
  • probeNode can also replace the duplicate --version exec in resolveTargetNodeVersion.

One thing worth stating explicitly in the PR/docs: this makes "the base binary must be runnable on the build host" an honest, documented constraint rather than a half-supported one. That's already true in practice (both code paths exec it), so it isn't a regression — just no longer implying cross-platform bases work.

…dback)

robertsLando pointed out that the ELF/Mach-O/PE header sniffing
(sniffBinaryTarget + ELF_MACHINE/MACHO_CPU/PE_MACHINE tables,
formatForPlatform, FORMAT_LABEL — ~90 lines) is redundant: the custom base
binary is already exec'd for its version in assertCustomBaseNodeTarget and in
resolveTargetNodeVersion, and sniffing's only theoretical edge is validating a
base that can't run on the build host — but that base already fails the version
exec, before sniffing helps.

Replace it with one probe:

    node -p "process.version + ' ' + process.platform + ' ' + process.arch"

- add probeNode(binPath) (short-circuits to process.* when binPath is the host
  node, so no exec for --sea-use-local-node / host targets)
- assertCustomBaseNodeTarget: keep the multi-target guard (step 1), then compare
  the binary's reported process.platform/arch/major to the single target via
  probeNode (steps 2-3). Adds a PROCESS_PLATFORM map (macos->darwin, win->win32)
- resolveTargetNodeVersion: reuse probeNode instead of a second `--version` exec
- delete sniffBinaryTarget + its unit test; update custom-node.md to state the
  base must be runnable on the build host (already true in practice)

This makes "the base binary must be runnable on the build host" an honest,
documented constraint rather than a half-supported one.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@cstrahan

Copy link
Copy Markdown
Author

@robertsLando I believe the last commit should address your last comment.

@robertsLando robertsLando left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Verdict: needs work — approach is sound (expose nodePath/useLocalNode to SEA, fold in PKG_NODE_PATH, guard the multi-target footgun), but the platform/arch guard falsely rejects single alpine/linuxstatic/armv7l targets, and the tests only exercise the process.execPath short-circuit so CI can't catch it. Strengths: the probeNode consolidation, PKG_NODE_PATH parity with standard mode, and the thorough docs/tests. Findings inline.

Comment thread lib/sea.ts Outdated
}

/** Target-suffix -> the value Node reports as `process.platform`. */
const PROCESS_PLATFORM: Record<string, string> = {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Major] · Correctness

The comment claims alpine/linuxstatic "report their own name verbatim" — they don't: a musl/static Node binary reports process.platform === 'linux' ('alpine'/'linuxstatic' are pkg-fetch concepts, not Node process.platform values). So a single alpine/linuxstatic target hits expectedPlatform = 'alpine' (line 431) vs node.platform = 'linux' and is falsely rejected.

Why: the PR's own docs promise a single alpine/linuxstatic target works ("matching that to linux/alpine/linuxstatic remains your responsibility") — exactly the musl / old-glibc custom-runtime case this PR exists for. The guard runs before any getNodeOs, so nothing upstream filters it; a legit musl-on-musl build dies on a contradictory error.

Fix: map alpinelinux and linuxstaticlinux here. Cleanest as one total map over knownPlatforms so a newly-added target can't silently fall through ?? platform again.

Comment thread lib/sea.ts Outdated
`"${platform}" needs "${expectedPlatform}".`,
);
}
if (node.arch !== arch) {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Major] · Correctness

Same defect as the platform map, but arch has no mapping at all. pkg's armv7l target vs Node's process.arch === 'arm''arm' !== 'armv7l' → false reject of a single armv7l target with a matching custom node.

Why: blocks a valid 32-bit-ARM custom-runtime build with a confusing error.

Fix: add an arch map (armv7larm) alongside the platform one, total over knownArchs.

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

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Major] · Tests

Every test supplies process.execPath, which trips probeNode's binPath === process.execPath short-circuit — so execFileAsync and the PROCESS_PLATFORM/arch comparison for a non-host target never run. The linux,alpine case only trips check #1 (combos.size > 1) and throws before the platform comparison is reached.

Why: the platform/arch mapping bug ships green — CI can't catch it.

Fix: extract the pkg→Node platform/arch normalization into a small pure helper and unit-test it over the full knownPlatforms/knownArchs set (mirrors the config-parse.test.ts style already added in this PR).

Comment thread lib/sea.ts
};

/** What a Node binary reports about itself when run. */
async function probeNode(

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Minor] · Robustness

Returning platform/arch is justified — but narrower than it looks. arch is genuinely valuable (Rosetta: an arm64 host runs an x64 node, reports x64, catching an arm64-target mismatch that would otherwise embed the wrong binary). platform: since the binary must be runnable to probe, a successful probe necessarily reports the host's platform, so this effectively enforces "target platform == host platform" — reachable mainly via the useLocalNode/execPath short-circuit. For a genuinely foreign, non-runnable binary, execFileAsync throws ENOEXEC here before the friendly platform/arch error can fire.

Fix: keep the probe (it's free — you already exec for the version), but wrap the exec failure with context: "couldn't run the custom base Node on this host; it must be runnable here to validate platform/arch/version (got …)." Turns a cryptic ENOEXEC into a clear message.

Comment thread lib/sea.ts
// 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;

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[FYI] · Performance

probeNode runs twice per build — once in assertCustomBaseNodeTarget, once here — each a child-process spawn of the custom node. Negligible for a build, but trivially memoizable if desired.

Comment thread lib/sea.ts
* 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(

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Nit] · Consistency

PKG_NODE_PATH is passed through resolve() (line 345) but opts.nodePath (the --sea-node-path flag) is returned verbatim — so a relative --sea-node-path is left un-normalized. Harmless today (exec/exists resolve against cwd) but inconsistent.

Comment thread lib/sea.ts
* 3. Verify the binary's major matches the requested `nodeRange`
* (`assertSingleTargetMajor` only compares the targets to each other).
*/
async function assertCustomBaseNodeTarget(

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Nit] · Types

(NodeTarget & Partial<Target>)[] makes platform/arch optionally undefined; String(t.platform) would then silently key combos as 'undefined-undefined' rather than asserting. Targets are fully resolved by here, so only a type smell.

@codecov

codecov Bot commented Jun 18, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 57.14286% with 75 lines in your changes missing coverage. Please review.
✅ Project coverage is 85.71%. Comparing base (546bbf0) to head (cc6e680).
⚠️ Report is 1 commits behind head on main.

Files with missing lines Patch % Lines
lib/sea.ts 48.55% 71 Missing ⚠️
lib/help.ts 0.00% 3 Missing ⚠️
lib/index.ts 85.71% 1 Missing ⚠️
Additional details and impacted files

Impacted file tree graph

@@            Coverage Diff             @@
##             main     #279      +/-   ##
==========================================
- Coverage   86.36%   85.71%   -0.65%     
==========================================
  Files          22       22              
  Lines        7297     7462     +165     
  Branches     1047     1052       +5     
==========================================
+ Hits         6302     6396      +94     
- Misses        988     1058      +70     
- Partials        7        8       +1     
Files with missing lines Coverage Δ
lib/config.ts 96.21% <100.00%> (+0.05%) ⬆️
lib/types.ts 100.00% <100.00%> (ø)
lib/index.ts 82.54% <85.71%> (-0.84%) ⬇️
lib/help.ts 3.33% <0.00%> (-0.06%) ⬇️
lib/sea.ts 70.32% <48.55%> (-2.96%) ⬇️
🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@cstrahan

Copy link
Copy Markdown
Author

@robertsLando I'll aim to fix everything from review today, and give it another once over.

It's kind of a weird time in software development. I've contributed quite a bit to OSS over the years, but this is the first time I've ever used AI assistance. I'm painfully self conscious about the possibility of, despite my best intentions, doing anything that remotely looks like shoving AI slop into a PR and making it a maintainer's problem. While I believe the feature and the general approach is sound (and it looks like there's agreement there), I am very new to not only this code base, but the software itself, so there's undoubtedly some things I'd miss even in a pre-AI world.

Given that, and that it seems you have a decent AI assisted review workflow, if you have any tips I can adopt to spare you the time spent in review, I'd be more than happy to integrate them. At the very least, I'll see if I can set up some sort of adversarial prompting to catch things I missed.

Many thanks for maintaining pkg :)

cstrahan and others added 3 commits June 18, 2026 18:46
…ase guard

robertsLando's review caught that the guard compared pkg target identifiers
directly against Node's process.platform/arch, which falsely rejects legitimate
single targets:

- alpine / linuxstatic report process.platform === 'linux' (they're pkg-fetch
  flavors, not Node platform values), so `expectedPlatform = 'alpine'` could
  never match — breaking the musl/old-glibc custom-runtime case this feature
  exists for.
- armv7l has process.arch === 'arm', and arch had no mapping at all, so a single
  armv7l target with a matching custom node was rejected too.

Fixes:
- add total TARGET_PLATFORM_TO_PROCESS / TARGET_ARCH_TO_PROCESS maps and pure
  expectedProcessPlatform() / expectedProcessArch() helpers (alpine/linuxstatic
  ->linux, macos->darwin, win->win32, armv7/armv7l->arm, x86->ia32). They throw
  on an unmapped target instead of silently falling through to a wrong identity
  default.
- unit-test the helpers over the full knownPlatforms/knownArchs sets
  (sea-target-identity.test.ts) — the e2e can't reach this because every case
  feeds the host's own node and trips probeNode's process.execPath short-circuit.
- wrap probeNode's exec failure with a clear "must be runnable on the build
  host" message instead of a cryptic ENOEXEC.
- memoize probeNode so the target guard and version resolver share one spawn.
- resolve() opts.nodePath (the --sea-node-path flag) for parity with the
  PKG_NODE_PATH branch.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
A second review round flagged that the custom-base major check returned early
when the target nodeRange is unparseable ('latest'): targetMajor is NaN, so the
guard skipped validation entirely. seaEnhanced's later minTargetMajor check
resolves 'latest' to the host major (>= 22), so a too-old custom binary (e.g.
`--sea-node-path ./node18 -t latest-...`) slipped through both checks and would
break at injection instead of failing cleanly.

- extract the major-compatibility logic into a pure, exported
  assertBaseMajorSatisfiesTarget(binVersion, nodeRange) and unit-test all
  branches (concrete match/mismatch, 'latest' with new/floor/too-old binaries) —
  the e2e can't reach the too-old path host-independently.
- for a 'latest'/unparseable target, require the binary major >= the SEA floor
  instead of returning unchecked.
- introduce MIN_SEA_TARGET_MAJOR (22) and reuse it in assertHostSeaNodeVersion
  and seaEnhanced's target check, so the floor lives in one place.
- document that cross-compilation is unsupported with a custom SEA binary
  (the binary must be runnable on the build host to be probed).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
probeNode ran `node -p "process.version + ' ' + process.platform + ' ' +
process.arch"` and split the result on spaces. The fields can't contain spaces
so it wasn't buggy, but JSON is clearer and more robust: emit
`JSON.stringify({version, platform, arch})` and parse it, validating the three
fields are strings. Unexpected output now fails with a clear parse error
instead of silently yielding undefined fields that would later trip a confusing
platform/arch mismatch.

Also add e2e coverage for probeNode's real exec + parse path. Cases a-d all pass
process.execPath, which short-circuits probeNode (no spawn), so the parse was
never exercised in CI. Two POSIX-only mock "node" binaries close that gap: a
shebang script reporting a mismatched version (valid JSON -> major-version
guard error) and one emitting non-JSON (-> the new "unexpected output" error).
Windows is skipped (execFile can't run a .bat/.cmd without a shell, and a real
.exe isn't practical here); the parse logic is OS-agnostic JS.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@robertsLando

Copy link
Copy Markdown
Member

Hi @cstrahan ! Thanks for your appreciation. While the review is produced by ai it is mostly driven by my inputs, what I do is to check the changes spot what doesn't convince me and tell the ai to review the changes based on my thoughts, what I generally find is that ai generated reviews are focused on find issues with the current implementation while them are not good to spot when the implementation is actually not following the best path to solve the issue, for a more concrete example on first review I remember I already had the support for that in legacy pkg way (not sea) so I told the ai to focus the implementation around what exists, in the second round I found that the code you were using to check the binary was too complicated while the problem was much easier so I ask him to simplify that till he proposed me a nice more elegant solution. I have nothing really automated here, AI is just a tool but in order to use it at it's most powers you need to know what you are doing and this still means to study the code first, you can tell the ai to help you in this task and also I suggest to spend LOT of time in the plan phase, that's the most important part, do a plan and ask the ai to be critic against it till you find the "best" way to do it :)

Hope this helps

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants