Repository: denoland/deno
Pull request: fix(compile): skip Node-to-Deno arg translation in standalone binaries

## Summary

- Skip Node.js-to-Deno CLI argument translation in `child_process.spawn` and
  `child_process.fork` when running inside a standalone (compiled) binary
- Fixes an issue where compiled npm CLI tools that relaunch themselves (e.g.,
  `@google/gemini-cli`) would get Deno flags injected into `process.argv`

## Problem

When a `deno compile`d binary spawns itself via `child_process.spawn(process.execPath, args)`,
Deno's `child_process` implementation detects `file === Deno.execPath()` and translates
Node.js-style args (like `--max-old-space-size=4096`) into Deno CLI args
(`run -A --unstable-node-globals --unstable-bare-node-builtins --unstable-detect-cjs --v8-flags=...`).

The compiled binary doesn't parse these as Deno subcommands -- it puts ALL of them into
`Deno.args` / `process.argv`. The app's argument parser (e.g., yargs) then rejects them:

```
Unknown arguments: A, unstable-node-globals, unstableNodeGlobals,
unstable-bare-node-builtins, unstableBareNodeBuiltins, unstable-detect-cjs, unstableDetectCjs
```

## Fix

Check `Deno.build.standalone` before applying the Node-to-Deno arg translation in two places:
- `buildCommand()` in `ext/node/polyfills/internal/child_process.ts` (for `spawn`)
- `fork()` in `ext/node/polyfills/child_process.ts`

Standalone binaries already have permissions, unstable config, and V8 flags baked into their
metadata, so the translation is unnecessary and harmful.

## Test plan

- [x] New spec test: `tests/specs/compile/standalone_spawn_self` -- compiles a binary that
  spawns itself and verifies no Deno flags are injected into the child's `process.argv`
- [x] Verified fix with `@google/gemini-cli` end-to-end
- [x] Existing `child_process` and `node_cli_parser` tests pass


🤖 Generated with [Claude Code](https://claude.com/claude-code)

Changed files:
filename: ext/node/polyfills/child_process.ts
patch: @@ -146,37 +146,45 @@ export function fork(
   // Combine execArgv (Node CLI flags), modulePath (script), and args (script args)
   const nodeArgs = [...(execArgv || []), modulePath, ...args].map(String);
 
-  // Use the Rust parser to translate Node.js CLI args to Deno args
-  // The parser handles Deno-style args (e.g., from vitest) by passing them through unchanged
-  const result = op_node_translate_cli_args(nodeArgs, false, true);
-  const denoArgs = result.deno_args;
-  const bootstrapArgs = op_bootstrap_unstable_args();
-
-  // Insert bootstrap unstable args after "run" but before other args.
-  // Filter out any that the translator already added to avoid duplicates
-  // (e.g. --unstable-bare-node-builtins).
-  // denoArgs is like ["run", "-A", "--unstable-...", "script.js", ...]
-  // We need ["run", ...uniqueBootstrapArgs, "-A", "--unstable-...", "script.js", ...]
-  const denoArgSet = new Set(denoArgs);
-  const uniqueBootstrapArgs = bootstrapArgs.filter((a) => !denoArgSet.has(a));
-  if (
-    denoArgs.length > 0 && denoArgs[0] === "run" &&
-    uniqueBootstrapArgs.length > 0
-  ) {
-    args = [denoArgs[0], ...uniqueBootstrapArgs, ...denoArgs.slice(1)];
+  if (Deno.build.standalone) {
+    // In standalone (compiled) binaries, skip Node-to-Deno arg translation.
+    // The binary already has permissions and unstable config baked in.
+    // Translating would inject "run -A --unstable-..." which the compiled
+    // binary doesn't understand and would pass through as app args.
+    args = nodeArgs;
   } else {
-    args = [...uniqueBootstrapArgs, ...denoArgs];
-  }
-
-  // Handle NODE_OPTIONS if the parser returned any
-  if (result.node_options.length > 0) {
-    const nodeOptionsStr = result.node_options.join(" ");
-    if (options.env) {
-      options.env.NODE_OPTIONS = options.env.NODE_OPTIONS
-        ? options.env.NODE_OPTIONS + " " + nodeOptionsStr
-        : nodeOptionsStr;
+    // Use the Rust parser to translate Node.js CLI args to Deno args
+    // The parser handles Deno-style args (e.g., from vitest) by passing them through unchanged
+    const result = op_node_translate_cli_args(nodeArgs, false, true);
+    const denoArgs = result.deno_args;
+    const bootstrapArgs = op_bootstrap_unstable_args();
+
+    // Insert bootstrap unstable args after "run" but before other args.
+    // Filter out any that the translator already added to avoid duplicates
+    // (e.g. --unstable-bare-node-builtins).
+    // denoArgs is like ["run", "-A", "--unstable-...", "script.js", ...]
+    // We need ["run", ...uniqueBootstrapArgs, "-A", "--unstable-...", "script.js", ...]
+    const denoArgSet = new Set(denoArgs);
+    const uniqueBootstrapArgs = bootstrapArgs.filter((a) => !denoArgSet.has(a));
+    if (
+      denoArgs.length > 0 && denoArgs[0] === "run" &&
+      uniqueBootstrapArgs.length > 0
+    ) {
+      args = [denoArgs[0], ...uniqueBootstrapArgs, ...denoArgs.slice(1)];
     } else {
-      options.env = { ...process.env, NODE_OPTIONS: nodeOptionsStr };
+      args = [...uniqueBootstrapArgs, ...denoArgs];
+    }
+
+    // Handle NODE_OPTIONS if the parser returned any
+    if (result.node_options.length > 0) {
+      const nodeOptionsStr = result.node_options.join(" ");
+      if (options.env) {
+        options.env.NODE_OPTIONS = options.env.NODE_OPTIONS
+          ? options.env.NODE_OPTIONS + " " + nodeOptionsStr
+          : nodeOptionsStr;
+      } else {
+        options.env = { ...process.env, NODE_OPTIONS: nodeOptionsStr };
+      }
     }
   }
 

filename: ext/node/polyfills/internal/child_process.ts
patch: @@ -1470,7 +1470,7 @@ function buildCommand(
   env: Record<string, string | number | boolean>,
 ): [string, string[], boolean] {
   let includeNpmProcessState = false;
-  if (file === Deno.execPath()) {
+  if (file === Deno.execPath() && !Deno.build.standalone) {
     // Ensure all args are strings (Node allows numbers in args array)
     args = args.map((arg) => String(arg));
 

filename: tests/specs/compile/standalone_spawn_self/__test__.jsonc
patch: @@ -0,0 +1,18 @@
+{
+  "tempDir": true,
+  "tests": {
+    "spawn_self": {
+      "steps": [
+        {
+          "args": "compile -A --unstable-node-globals --unstable-bare-node-builtins --unstable-detect-cjs --output main main.ts",
+          "output": "[WILDCARD]"
+        },
+        {
+          "commandName": "./main",
+          "args": [],
+          "output": "main.out"
+        }
+      ]
+    }
+  }
+}

filename: tests/specs/compile/standalone_spawn_self/main.out
patch: @@ -0,0 +1 @@
+ok

filename: tests/specs/compile/standalone_spawn_self/main.ts
patch: @@ -0,0 +1,48 @@
+import { spawn } from "node:child_process";
+import process from "node:process";
+
+if (process.env.SPAWNED_CHILD === "1") {
+  // Child process: verify no Deno flags were injected into argv.
+  // Before the fix, child_process.spawn would translate Node args
+  // into Deno args (run -A --unstable-node-globals etc.) when the
+  // spawn target was a compiled binary.
+  const appArgs = process.argv.slice(2);
+  const denoFlags = appArgs.filter(
+    (a) =>
+      a === "run" ||
+      a === "-A" ||
+      a.startsWith("--unstable-") ||
+      a.startsWith("--v8-flags="),
+  );
+  if (denoFlags.length > 0) {
+    console.log("FAIL: unexpected Deno flags in argv: " + denoFlags.join(", "));
+    process.exit(1);
+  }
+  console.log("ok");
+} else {
+  // Parent process: re-spawn self with the script path
+  // (mimics the pattern used by Node CLIs that relaunch themselves)
+  const script = process.argv[1];
+  const child = spawn(process.execPath, [script], {
+    stdio: ["pipe", "pipe", "pipe"],
+    env: { ...process.env, SPAWNED_CHILD: "1" },
+  });
+
+  let stdout = "";
+  child.stdout!.on("data", (data) => {
+    stdout += data.toString();
+  });
+
+  let stderr = "";
+  child.stderr!.on("data", (data) => {
+    stderr += data.toString();
+  });
+
+  child.on("close", (code) => {
+    if (stderr) {
+      process.stderr.write(stderr);
+    }
+    process.stdout.write(stdout);
+    process.exit(code ?? 1);
+  });
+}