Describe the bug
When an oclif CLI is packaged as a standalone tarball via oclif pack tarballs and deployed
to a system without a system-wide Node.js installation, plugins:install and
plugins:update fail with ENOENT when spawning the bundled npm-cli.js.
In src/spawn.ts, the .js → prepend-node workaround is guarded by process.platform === 'win32':
// On windows, the global path to npm could be .cmd, .exe, or .js. If it's a .js file, we need to run it with node.
if (process.platform === 'win32' && modulePath.endsWith('.js')) {
args.unshift(`"${modulePath}"`);
modulePath = 'node';
}
On Unix, the .js file is spawned directly via child_process.spawn(). The OS tries to
exec it via its shebang (e.g., #!/usr/bin/node), which fails with ENOENT when that
interpreter path doesn't exist. The oclif standalone tarball launcher script correctly uses
the bundled bin/node to start the CLI process itself, but @oclif/plugin-plugins bypasses
the bundled node when spawning npm.
Note: on Unix, ENOENT from spawn() can mean "missing shebang interpreter," not just
"target file missing" — which makes this error confusing to diagnose.
This likely affects any standalone tarball distribution where the resolved npm entrypoint is
a .js file whose shebang interpreter path does not exist on the host system.
To Reproduce
- Build a standalone tarball:
oclif pack tarballs
- Install on a Linux system that has no system-level Node.js (e.g., a minimal container)
- Run:
mycli plugins:install some-plugin
- See error:
Error: spawn /opt/mycli/node_modules/npm/bin/npm-cli.js ENOENT
at ChildProcess._handle.onexit (node:internal/child_process:286:19)
at onErrorNT (node:internal/child_process:484:16)
at process.processTicksAndRejections (node:internal/process/task_queues:89:21)
Useful diagnostics:
ls -l /opt/mycli/node_modules/npm/bin/npm-cli.js
head -1 /opt/mycli/node_modules/npm/bin/npm-cli.js # shows shebang
node -p "process.execPath" # shows bundled node path
Expected behavior
plugins:install should use the current CLI's bundled Node.js runtime to invoke npm,
since the standalone tarball already ships a working Node binary.
Screenshots
N/A
Environment (please complete the following information):
- OS & version: RHEL 9 (minimal container, no system Node.js)
- Shell/terminal & version: bash 5.x
@oclif/plugin-plugins version: 5.4.59
Additional context
Proposed Fix
Remove the win32 guard and use process.execPath instead of the string 'node':
// If the module path is a .js file, we need to run it with node.
// On Windows, the global path to npm could be .cmd, .exe, or .js.
// On Unix, standalone tarballs bundle npm-cli.js with a shebang that
// may point to a Node interpreter path that doesn't exist when node is
// only available via the bundled binary — spawning the .js file directly
// fails with ENOENT.
if (modulePath.endsWith('.js')) {
const quote = process.platform === 'win32' ? `"${modulePath}"` : modulePath;
args.unshift(quote);
modulePath = process.execPath;
}
Why process.execPath?
process.execPath is the exact Node.js binary running the current process — in
standalone tarball installs, this is the bundled binary (e.g., /opt/mycli/bin/node).
- The previous code used the string
'node' which relies on node being on $PATH.
process.execPath is more reliable in all environments.
- It avoids picking the wrong Node version in multi-node environments.
- It's backwards-compatible: for npm-installed CLIs,
process.execPath is just the
system node that was used to start the process.
Suggested tests:
- Unix: verify that a
.js module path is invoked via process.execPath, not directly
- Windows: verify that a
.js path with spaces remains quoted
- Existing: verify
.cmd behavior on Windows is unchanged
Workaround:
I'm applying the following patch locally via patchedDependencies in my package.json:
❯ cat patches/@oclif__plugin-plugins@5.4.59.patch
diff --git a/lib/spawn.js b/lib/spawn.js
index 1234567..abcdefg 100644
--- a/lib/spawn.js
+++ b/lib/spawn.js
@@ -5,10 +5,15 @@ import { npmRunPathEnv } from 'npm-run-path';
const debug = makeDebug('@oclif/plugin-plugins:spawn');
export async function spawn(modulePath, args = [], { cwd, logLevel }) {
return new Promise((resolve, reject) => {
- // On windows, the global path to npm could be .cmd, .exe, or .js. If it's a .js file, we need to run it with node.
- if (process.platform === 'win32' && modulePath.endsWith('.js')) {
- args.unshift(`"${modulePath}"`);
- modulePath = 'node';
+ // If the module path is a .js file, we need to run it with node.
+ // On Windows, the global path to npm could be .cmd, .exe, or .js.
+ // On Unix, standalone tarballs bundle npm-cli.js with a #!/usr/bin/node
+ // shebang that doesn't exist when node is only available via the
+ // bundled binary—spawning the .js file directly fails with ENOENT.
+ if (modulePath.endsWith('.js')) {
+ const quote = process.platform === 'win32' ? `"${modulePath}"` : modulePath;
+ args.unshift(quote);
+ modulePath = process.execPath;
}
debug('modulePath', modulePath);
debug('args', args);
Describe the bug
When an oclif CLI is packaged as a standalone tarball via
oclif pack tarballsand deployedto a system without a system-wide Node.js installation,
plugins:installandplugins:updatefail withENOENTwhen spawning the bundlednpm-cli.js.In
src/spawn.ts, the.js→ prepend-node workaround is guarded byprocess.platform === 'win32':On Unix, the
.jsfile is spawned directly viachild_process.spawn(). The OS tries toexec it via its shebang (e.g.,
#!/usr/bin/node), which fails withENOENTwhen thatinterpreter path doesn't exist. The oclif standalone tarball launcher script correctly uses
the bundled
bin/nodeto start the CLI process itself, but@oclif/plugin-pluginsbypassesthe bundled node when spawning npm.
Note: on Unix,
ENOENTfromspawn()can mean "missing shebang interpreter," not just"target file missing" — which makes this error confusing to diagnose.
This likely affects any standalone tarball distribution where the resolved npm entrypoint is
a
.jsfile whose shebang interpreter path does not exist on the host system.To Reproduce
oclif pack tarballsmycli plugins:install some-pluginUseful diagnostics:
Expected behavior
plugins:installshould use the current CLI's bundled Node.js runtime to invoke npm,since the standalone tarball already ships a working Node binary.
Screenshots
N/A
Environment (please complete the following information):
@oclif/plugin-pluginsversion: 5.4.59Additional context
Proposed Fix
Remove the
win32guard and useprocess.execPathinstead of the string'node':Why
process.execPath?process.execPathis the exact Node.js binary running the current process — instandalone tarball installs, this is the bundled binary (e.g.,
/opt/mycli/bin/node).'node'which relies on node being on$PATH.process.execPathis more reliable in all environments.process.execPathis just thesystem node that was used to start the process.
Suggested tests:
.jsmodule path is invoked viaprocess.execPath, not directly.jspath with spaces remains quoted.cmdbehavior on Windows is unchangedWorkaround:
I'm applying the following patch locally via
patchedDependenciesin mypackage.json: