Skip to content

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

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

    Fields

    No fields configured for Bug.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions