Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 23 additions & 0 deletions lib/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -138,6 +138,25 @@ 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). The binary's major version must match the target's.
cli: 'sea-node-path',
cfg: 'seaNodePath',
resolved: 'seaNodePath',
kind: 'string',
},
{
// SEA mode: use the Node binary currently running pkg (process.execPath)
// as the base. Shorthand for `--sea-node-path "$(command -v node)"`.
cli: 'sea-use-local-node',
cfg: 'seaUseLocalNode',
resolved: 'seaUseLocalNode',
kind: 'bool',
default: false,
},
{
cli: 'compress',
cfg: 'compress',
Expand Down Expand Up @@ -485,6 +504,10 @@ export interface ResolvedFlags {
fallbackToSource: boolean;
public: boolean;
sea: boolean;
/** SEA mode: path to a base Node binary to embed (overrides the download). */
seaNodePath: string | undefined;
/** SEA mode: embed the Node binary running pkg (process.execPath) as the base. */
seaUseLocalNode: boolean;
publicPackages: string[] | undefined;
noDictionary: string[] | undefined;
bakeOptions: string[] | undefined;
Expand Down
7 changes: 5 additions & 2 deletions lib/help.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,10 +26,13 @@ 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 (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.)


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,
sea-use-local-node) can also be set in the pkg config file (camelCase keys). CLI flags
override config values.

${pc.dim('Examples:')}

Expand Down
14 changes: 14 additions & 0 deletions lib/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -192,6 +192,18 @@ export async function exec(
}

if (flags.sea) {
// 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.

throw wasReported(
"Specify either '--sea-node-path' or '--sea-use-local-node', not both",
);
}
const seaBase = {
nodePath: flags.seaNodePath,
useLocalNode: flags.seaUseLocalNode,
};
if (inputJson || configJson) {
// Enhanced SEA mode — use walker pipeline.
// seaEnhanced validates the host Node version and minTargetMajor itself.
Expand All @@ -202,6 +214,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.
Expand All @@ -216,6 +229,7 @@ export async function exec(
await sea(inputFin, {
targets,
signature: flags.signature,
...seaBase,
});
}
return;
Expand Down
12 changes: 12 additions & 0 deletions lib/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,10 @@ export interface PkgOptions {
debug?: boolean;
signature?: boolean;
sea?: boolean;
/** SEA mode: path to a base Node binary to embed (overrides the download). */
seaNodePath?: string;
/** SEA mode: embed the Node binary running pkg (process.execPath) as the base. */
seaUseLocalNode?: boolean;
}

export interface PackageJson {
Expand Down Expand Up @@ -200,4 +204,12 @@ 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). 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.

*/
seaNodePath?: string;
/** SEA mode: embed the Node binary running pkg (`process.execPath`) as the base. */
seaUseLocalNode?: boolean;
}
6 changes: 6 additions & 0 deletions test/test-95-sea-local-node/index.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
'use strict';

// Packaged into a SEA with `--sea-use-local-node`, so the embedded runtime is
// the same Node that ran pkg (rather than a downloaded one). Output is asserted
// by main.js. process.pkg confirms the enhanced SEA bootstrap is active.
console.log('local-node SEA OK:' + (process.pkg != null));
29 changes: 29 additions & 0 deletions test/test-95-sea-local-node/main.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
#!/usr/bin/env node

'use strict';

const assert = require('assert');
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 testName = 'test-95-sea-local-node';

const newcomers = utils.seaHostOutputs(testName);

const before = utils.filesBefore(newcomers);

// `--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.


utils.assertSeaOutput(testName, 'local-node SEA OK:true\n');

utils.filesAfter(before, newcomers, { tolerateWindowsEbusy: true });
6 changes: 6 additions & 0 deletions test/test-95-sea-local-node/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
{
"name": "test-95-sea-local-node",
"version": "1.0.0",
"main": "index.js",
"bin": "index.js"
}
1 change: 1 addition & 0 deletions test/test.js
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,7 @@ const npmTests = [
'test-91-sea-esm-entry',
'test-92-sea-tla',
'test-94-sea-esm-import-meta',
'test-95-sea-local-node',
];

if (testFilter) {
Expand Down
37 changes: 37 additions & 0 deletions test/unit/config-parse.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -165,6 +165,24 @@ 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',
);
});

it('bool flag --sea-use-local-node (kebab key preserved)', () => {
assert.equal(
parseInput(['--sea-use-local-node', 'a.js']).flags[
'sea-use-local-node'
],
true,
);
});
});

describe('negation', () => {
Expand Down Expand Up @@ -410,6 +428,25 @@ describe('resolveFlags — CLI > config > default', () => {
assert.equal(f.bytecode, false);
});

it('SEA base-node override — defaults, config, CLI precedence', () => {
const def = resolveFlags({}, {});
assert.equal(def.seaNodePath, undefined);
assert.equal(def.seaUseLocalNode, false);

// config keys (cfg name) resolve when CLI is absent
const cfg = resolveFlags({}, { seaNodePath: '/n', seaUseLocalNode: true });
assert.equal(cfg.seaNodePath, '/n');
assert.equal(cfg.seaUseLocalNode, true);

// CLI (kebab cli name) overrides config
const cli = resolveFlags(
{ 'sea-node-path': '/cli', 'sea-use-local-node': true },
{ seaNodePath: '/cfg' },
);
assert.equal(cli.seaNodePath, '/cli');
assert.equal(cli.seaUseLocalNode, true);
});

describe('list handling', () => {
it('CLI "" clears the configured list (empty wins)', () => {
const f = resolveFlags({ options: '' }, { options: ['expose-gc'] });
Expand Down