Skip to content

spawn fails with ENOENT on standalone tarballs (Unix) #1293

@bailey-coding

Description

@bailey-coding

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

  1. Build a standalone tarball: oclif pack tarballs
  2. Install on a Linux system that has no system-level Node.js (e.g., a minimal container)
  3. Run: mycli plugins:install some-plugin
  4. 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);

Metadata

Metadata

Assignees

No one assigned

    Labels

    Type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions