Repository: denoland/deno
Pull request: feat(unstable): add `Deno.spawn()`, `Deno.spawnAndWait{Sync}()`

## Summary

- Add `Deno.spawn(cmd, options?)` / `Deno.spawn(cmd, args, options?)` as shorthand for `new Deno.Command(cmd, options).spawn()`
- Add `Deno.spawnAndWait(cmd, options?)` / `Deno.spawnAndWait(cmd, args, options?)` as shorthand for `new Deno.Command(cmd, options).output()`
- Add `Deno.spawnAndWaitSync(cmd, options?)` / `Deno.spawnAndWaitSync(cmd, args, options?)` as shorthand for `new Deno.Command(cmd, options).outputSync()`
- All three support a 3-argument overload where args are passed separately, and throw if `options.args` is also provided
- All APIs are marked as unstable for now

Changed files:
filename: cli/tsc/dts/lib.deno.ns.d.ts
patch: @@ -3971,6 +3971,168 @@ declare namespace Deno {
     readonly stderr: Uint8Array<ArrayBuffer>;
   }
 
+  /** Spawns a new subprocess, returning a {@linkcode Deno.ChildProcess} handle.
+   *
+   * @experimental **UNSTABLE**: New API, yet to be vetted.
+   *
+   * This is a shorthand for `new Deno.Command(command, options).spawn()`.
+   *
+   * By default, `stdin`, `stdout`, and `stderr` are set to `"inherit"`.
+   *
+   * @example Spawn a subprocess
+   *
+   * ```ts
+   * const child = Deno.spawn(Deno.execPath(), {
+   *   args: ["eval", "console.log('hello')"],
+   *   stdout: "piped",
+   * });
+   * const output = await child.stdout.text();
+   * console.log(output); // "hello\n"
+   * const status = await child.status;
+   * ```
+   *
+   * @tags allow-run
+   * @category Subprocess
+   */
+  export function spawn(
+    command: string | URL,
+    options?: CommandOptions,
+  ): ChildProcess;
+  /** Spawns a new subprocess with the given arguments, returning a
+   * {@linkcode Deno.ChildProcess} handle.
+   *
+   * @experimental **UNSTABLE**: New API, yet to be vetted.
+   *
+   * This is a shorthand for `new Deno.Command(command, { ...options, args }).spawn()`.
+   *
+   * By default, `stdin`, `stdout`, and `stderr` are set to `"inherit"`.
+   *
+   * @example Spawn a subprocess with args
+   *
+   * ```ts
+   * const child = Deno.spawn(Deno.execPath(), ["eval", "console.log('hello')"], {
+   *   stdout: "piped",
+   * });
+   * const output = await child.stdout.text();
+   * console.log(output); // "hello\n"
+   * const status = await child.status;
+   * ```
+   *
+   * @tags allow-run
+   * @category Subprocess
+   */
+  export function spawn(
+    command: string | URL,
+    args: string[],
+    options?: Omit<CommandOptions, "args">,
+  ): ChildProcess;
+
+  /** Spawns a subprocess, waits for it to finish, and returns the output.
+   *
+   * @experimental **UNSTABLE**: New API, yet to be vetted.
+   *
+   * This is a shorthand for `new Deno.Command(command, options).output()`.
+   *
+   * Will throw an error if `stdin: "piped"` is set.
+   *
+   * @example Spawn and wait for output
+   *
+   * ```ts
+   * const { code, stdout, stderr } = await Deno.spawnAndWait(Deno.execPath(), {
+   *   args: ["eval", "console.log('hello')"],
+   * });
+   * console.log(new TextDecoder().decode(stdout)); // "hello\n"
+   * ```
+   *
+   * @tags allow-run
+   * @category Subprocess
+   */
+  export function spawnAndWait(
+    command: string | URL,
+    options?: CommandOptions,
+  ): Promise<CommandOutput>;
+  /** Spawns a subprocess with the given arguments, waits for it to finish,
+   * and returns the output.
+   *
+   * @experimental **UNSTABLE**: New API, yet to be vetted.
+   *
+   * This is a shorthand for `new Deno.Command(command, { ...options, args }).output()`.
+   *
+   * Will throw an error if `stdin: "piped"` is set.
+   *
+   * @example Spawn and wait with args
+   *
+   * ```ts
+   * const { code, stdout } = await Deno.spawnAndWait(
+   *   Deno.execPath(),
+   *   ["eval", "console.log('hello')"],
+   * );
+   * console.log(new TextDecoder().decode(stdout)); // "hello\n"
+   * ```
+   *
+   * @tags allow-run
+   * @category Subprocess
+   */
+  export function spawnAndWait(
+    command: string | URL,
+    args: string[],
+    options?: Omit<CommandOptions, "args">,
+  ): Promise<CommandOutput>;
+
+  /** Synchronously spawns a subprocess, waits for it to finish, and returns
+   * the output.
+   *
+   * @experimental **UNSTABLE**: New API, yet to be vetted.
+   *
+   * This is a shorthand for `new Deno.Command(command, options).outputSync()`.
+   *
+   * Will throw an error if `stdin: "piped"` is set.
+   *
+   * @example Spawn and wait synchronously
+   *
+   * ```ts
+   * const { code, stdout } = Deno.spawnAndWaitSync(Deno.execPath(), {
+   *   args: ["eval", "console.log('hello')"],
+   * });
+   * console.log(new TextDecoder().decode(stdout)); // "hello\n"
+   * ```
+   *
+   * @tags allow-run
+   * @category Subprocess
+   */
+  export function spawnAndWaitSync(
+    command: string | URL,
+    options?: CommandOptions,
+  ): CommandOutput;
+  /** Synchronously spawns a subprocess with the given arguments, waits for it
+   * to finish, and returns the output.
+   *
+   * @experimental **UNSTABLE**: New API, yet to be vetted.
+   *
+   * This is a shorthand for
+   * `new Deno.Command(command, { ...options, args }).outputSync()`.
+   *
+   * Will throw an error if `stdin: "piped"` is set.
+   *
+   * @example Spawn and wait synchronously with args
+   *
+   * ```ts
+   * const { code, stdout } = Deno.spawnAndWaitSync(
+   *   Deno.execPath(),
+   *   ["eval", "console.log('hello')"],
+   * );
+   * console.log(new TextDecoder().decode(stdout)); // "hello\n"
+   * ```
+   *
+   * @tags allow-run
+   * @category Subprocess
+   */
+  export function spawnAndWaitSync(
+    command: string | URL,
+    args: string[],
+    options?: Omit<CommandOptions, "args">,
+  ): CommandOutput;
+
   /** Option which can be specified when performing {@linkcode Deno.inspect}.
    *
    * @category I/O */

filename: ext/process/40_process.js
patch: @@ -11,6 +11,7 @@ import {
   op_spawn_wait,
 } from "ext:core/ops";
 const {
+  ArrayIsArray,
   ArrayPrototypeMap,
   ArrayPrototypeSlice,
   TypeError,
@@ -435,7 +436,7 @@ class ReadableStreamWithCollectors extends ReadableStream {
   }
 }
 
-function spawn(command, options) {
+function spawnInner(command, options) {
   if (options?.stdin === "piped") {
     throw new TypeError(
       "Piped stdin is not supported for this function, use 'Deno.Command().spawn()' instead",
@@ -449,7 +450,7 @@ function spawn(command, options) {
     .output();
 }
 
-function spawnSync(command, {
+function spawnSyncInner(command, {
   args = [],
   cwd = undefined,
   clearEnv = false,
@@ -519,7 +520,7 @@ class Command {
         "Piped stdin is not supported for this function, use 'Deno.Command.spawn()' instead",
       );
     }
-    return spawn(this.#command, this.#options);
+    return spawnInner(this.#command, this.#options);
   }
 
   outputSync() {
@@ -528,7 +529,7 @@ class Command {
         "Piped stdin is not supported for this function, use 'Deno.Command.spawn()' instead",
       );
     }
-    return spawnSync(this.#command, this.#options);
+    return spawnSyncInner(this.#command, this.#options);
   }
 
   spawn() {
@@ -543,4 +544,54 @@ class Command {
   }
 }
 
-export { ChildProcess, Command, kill, kInputOption, Process, run };
+function spawn(command, argsOrOptions, maybeOptions) {
+  if (ArrayIsArray(argsOrOptions)) {
+    const options = maybeOptions ?? {};
+    if (options.args !== undefined) {
+      throw new TypeError(
+        "Passing 'args' in options is not allowed when args are passed as a separate argument",
+      );
+    }
+    return new Command(command, { ...options, args: argsOrOptions }).spawn();
+  }
+  return new Command(command, argsOrOptions).spawn();
+}
+
+function spawnAndWait(command, argsOrOptions, maybeOptions) {
+  if (ArrayIsArray(argsOrOptions)) {
+    const options = maybeOptions ?? {};
+    if (options.args !== undefined) {
+      throw new TypeError(
+        "Passing 'args' in options is not allowed when args are passed as a separate argument",
+      );
+    }
+    return new Command(command, { ...options, args: argsOrOptions }).output();
+  }
+  return new Command(command, argsOrOptions).output();
+}
+
+function spawnAndWaitSync(command, argsOrOptions, maybeOptions) {
+  if (ArrayIsArray(argsOrOptions)) {
+    const options = maybeOptions ?? {};
+    if (options.args !== undefined) {
+      throw new TypeError(
+        "Passing 'args' in options is not allowed when args are passed as a separate argument",
+      );
+    }
+    return new Command(command, { ...options, args: argsOrOptions })
+      .outputSync();
+  }
+  return new Command(command, argsOrOptions).outputSync();
+}
+
+export {
+  ChildProcess,
+  Command,
+  kill,
+  kInputOption,
+  Process,
+  run,
+  spawn,
+  spawnAndWait,
+  spawnAndWaitSync,
+};

filename: runtime/js/90_deno_ns.js
patch: @@ -159,6 +159,9 @@ const denoNs = {
   uid: os.uid,
   Command: process.Command,
   ChildProcess: process.ChildProcess,
+  spawn: process.spawn,
+  spawnAndWait: process.spawnAndWait,
+  spawnAndWaitSync: process.spawnAndWaitSync,
   dlopen: ffi.dlopen,
   UnsafeCallback: ffi.UnsafeCallback,
   UnsafePointer: ffi.UnsafePointer,

filename: tests/unit/command_test.ts
patch: @@ -1248,3 +1248,254 @@ Deno.test({ permissions: { run: true } }, async function collectText() {
 
   await process.status;
 });
+
+Deno.test(
+  { permissions: { run: true } },
+  async function denoSpawnBasic() {
+    const child = Deno.spawn(Deno.execPath(), {
+      args: ["eval", "console.log('hello from spawn')"],
+      stdout: "piped",
+      stderr: "null",
+    });
+
+    assert(child instanceof Deno.ChildProcess);
+    assert(child.pid > 0);
+
+    const output = await child.stdout.text();
+    assertEquals(output, "hello from spawn\n");
+
+    const status = await child.status;
+    assertEquals(status.success, true);
+    assertEquals(status.code, 0);
+  },
+);
+
+Deno.test(
+  { permissions: { run: true } },
+  async function denoSpawnDefaultsToInherit() {
+    // Deno.spawn should default stdio to "inherit" like Command.spawn()
+    const child = Deno.spawn(Deno.execPath(), {
+      args: ["eval", "Deno.exit(42)"],
+    });
+
+    const status = await child.status;
+    assertEquals(status.success, false);
+    assertEquals(status.code, 42);
+  },
+);
+
+Deno.test(
+  { permissions: { run: true } },
+  async function denoSpawnWithPipedStdin() {
+    const child = Deno.spawn(Deno.execPath(), {
+      args: [
+        "eval",
+        "const buf = new Uint8Array(5); await Deno.stdin.read(buf); console.log(new TextDecoder().decode(buf))",
+      ],
+      stdin: "piped",
+      stdout: "piped",
+      stderr: "null",
+    });
+
+    const writer = child.stdin.getWriter();
+    await writer.write(new TextEncoder().encode("hello"));
+    await writer.close();
+
+    const output = await child.stdout.text();
+    assertEquals(output, "hello\n");
+
+    const status = await child.status;
+    assertEquals(status.success, true);
+  },
+);
+
+Deno.test(
+  { permissions: { run: true } },
+  async function denoSpawnWithArgsArray() {
+    const child = Deno.spawn(
+      Deno.execPath(),
+      ["eval", "console.log('args overload')"],
+      { stdout: "piped", stderr: "null" },
+    );
+
+    const output = await child.stdout.text();
+    assertEquals(output, "args overload\n");
+
+    const status = await child.status;
+    assertEquals(status.success, true);
+    assertEquals(status.code, 0);
+  },
+);
+
+Deno.test(
+  { permissions: { run: true } },
+  function denoSpawnWithArgsArrayAndArgsOptionThrows() {
+    assertThrows(
+      () => {
+        Deno.spawn(
+          Deno.execPath(),
+          ["eval", "console.log('hello')"],
+          // @ts-expect-error - args not allowed when using args array overload
+          { args: ["eval", "console.log('hello')"], stdout: "null" },
+        );
+      },
+      TypeError,
+      "Passing 'args' in options is not allowed when args are passed as a separate argument",
+    );
+  },
+);
+
+Deno.test(
+  { permissions: { run: true } },
+  async function denoSpawnAndWaitBasic() {
+    const { code, stdout, stderr } = await Deno.spawnAndWait(
+      Deno.execPath(),
+      {
+        args: ["eval", "console.log('waited')"],
+      },
+    );
+
+    assertEquals(code, 0);
+    assertEquals(new TextDecoder().decode(stdout), "waited\n");
+    assertEquals(new TextDecoder().decode(stderr), "");
+  },
+);
+
+Deno.test(
+  { permissions: { run: true } },
+  async function denoSpawnAndWaitWithArgsArray() {
+    const { code, stdout } = await Deno.spawnAndWait(
+      Deno.execPath(),
+      ["eval", "console.log('args overload waited')"],
+    );
+
+    assertEquals(code, 0);
+    assertEquals(new TextDecoder().decode(stdout), "args overload waited\n");
+  },
+);
+
+Deno.test(
+  { permissions: { run: true } },
+  async function denoSpawnAndWaitExitCode() {
+    const { code, success } = await Deno.spawnAndWait(
+      Deno.execPath(),
+      ["eval", "Deno.exit(42)"],
+    );
+
+    assertEquals(success, false);
+    assertEquals(code, 42);
+  },
+);
+
+Deno.test(
+  { permissions: { run: true } },
+  function denoSpawnAndWaitPipedStdinThrows() {
+    assertThrows(
+      () => {
+        Deno.spawnAndWait(Deno.execPath(), {
+          args: ["eval", "console.log('hello')"],
+          stdin: "piped",
+        });
+      },
+      TypeError,
+      "Piped stdin is not supported for this function",
+    );
+  },
+);
+
+Deno.test(
+  { permissions: { run: true } },
+  function denoSpawnAndWaitWithArgsArrayAndArgsOptionThrows() {
+    assertThrows(
+      () => {
+        Deno.spawnAndWait(
+          Deno.execPath(),
+          ["eval", "console.log('hello')"],
+          // @ts-expect-error - args not allowed when using args array overload
+          { args: ["eval", "console.log('hello')"] },
+        );
+      },
+      TypeError,
+      "Passing 'args' in options is not allowed when args are passed as a separate argument",
+    );
+  },
+);
+
+Deno.test(
+  { permissions: { run: true } },
+  function denoSpawnAndWaitSyncBasic() {
+    const { code, stdout, stderr } = Deno.spawnAndWaitSync(
+      Deno.execPath(),
+      {
+        args: ["eval", "console.log('sync waited')"],
+      },
+    );
+
+    assertEquals(code, 0);
+    assertEquals(new TextDecoder().decode(stdout), "sync waited\n");
+    assertEquals(new TextDecoder().decode(stderr), "");
+  },
+);
+
+Deno.test(
+  { permissions: { run: true } },
+  function denoSpawnAndWaitSyncWithArgsArray() {
+    const { code, stdout } = Deno.spawnAndWaitSync(
+      Deno.execPath(),
+      ["eval", "console.log('sync args overload')"],
+    );
+
+    assertEquals(code, 0);
+    assertEquals(
+      new TextDecoder().decode(stdout),
+      "sync args overload\n",
+    );
+  },
+);
+
+Deno.test(
+  { permissions: { run: true } },
+  function denoSpawnAndWaitSyncExitCode() {
+    const { code, success } = Deno.spawnAndWaitSync(
+      Deno.execPath(),
+      ["eval", "Deno.exit(42)"],
+    );
+
+    assertEquals(success, false);
+    assertEquals(code, 42);
+  },
+);
+
+Deno.test(
+  { permissions: { run: true } },
+  function denoSpawnAndWaitSyncPipedStdinThrows() {
+    assertThrows(
+      () => {
+        Deno.spawnAndWaitSync(Deno.execPath(), {
+          args: ["eval", "console.log('hello')"],
+          stdin: "piped",
+        });
+      },
+      TypeError,
+      "Piped stdin is not supported for this function",
+    );
+  },
+);
+
+Deno.test(
+  { permissions: { run: true } },
+  function denoSpawnAndWaitSyncWithArgsArrayAndArgsOptionThrows() {
+    assertThrows(
+      () => {
+        Deno.spawnAndWaitSync(
+          Deno.execPath(),
+          ["eval", "console.log('hello')"],
+          // @ts-expect-error - args not allowed when using args array overload
+          { args: ["eval", "console.log('hello')"] },
+        );
+      },
+      TypeError,
+      "Passing 'args' in options is not allowed when args are passed as a separate argument",
+    );
+  },
+);