diff --git a/packages/bun-types/bun.d.ts b/packages/bun-types/bun.d.ts index d8c9402e2871..e06d92939b3a 100644 --- a/packages/bun-types/bun.d.ts +++ b/packages/bun-types/bun.d.ts @@ -7031,7 +7031,12 @@ declare module "bun" { */ type OptionsObject = BaseOptions; - interface BaseOptions { + interface BaseOptions< + In extends Writable, + Out extends Readable, + Err extends Readable, + Term extends Terminal | undefined = undefined, + > { /** * The current working directory of the process * @@ -7196,7 +7201,7 @@ declare module "bun" { * ``` */ onExit?( - subprocess: Subprocess, + subprocess: Subprocess, exitCode: number | null, signalCode: number | null, /** @@ -7262,7 +7267,7 @@ declare module "bun" { /** * The {@link Subprocess} that received the message */ - subprocess: Subprocess, + subprocess: Subprocess, handle?: unknown, ): void; @@ -7373,8 +7378,21 @@ declare module "bun" { interface SpawnSyncOptions extends BaseOptions {} - interface SpawnOptions - extends BaseOptions { + /** + * Options accepted by {@link spawn}. + * + * `Term` is what {@link Subprocess.terminal} is on the process these options produce, and decides what + * the `terminal` option accepts: only `undefined` when `Term` is `undefined` (the default), + * `TerminalOptions | Terminal` when it is `Terminal`, and either when it is `Terminal | undefined`. The + * {@link Subprocess} passed to `onExit` and `ipc` has the same `Term`. {@link spawn} picks the overload, + * and with it `Term`, from whether the options it is called with carry a `terminal`. + */ + interface SpawnOptions< + In extends Writable, + Out extends Readable, + Err extends Readable, + Term extends Terminal | undefined = undefined, + > extends BaseOptions { /** * If true, the stdout and stderr pipes don't automatically start reading * data. Reading begins only when you access the `stdout` or `stderr` @@ -7397,14 +7415,23 @@ declare module "bun" { */ lazy?: boolean; + // `terminal` is typed as a union that contains `Term` itself rather than as one conditional type on + // `Term`, so that TypeScript measures this interface as covariant in `Term`. With a bare conditional + // it measures it as bivariant, and a `SpawnOptions<.., Terminal | undefined>` value would then satisfy + // the `Term = undefined` instantiation that the terminal-less `spawn` overloads take. /** * Spawn the subprocess with a pseudo-terminal (PTY) attached. * * When this option is provided: - * - `stdin`, `stdout`, and `stderr` are all connected to the terminal + * - `stdin`, `stdout`, and `stderr` are all connected to the terminal; the + * `stdin`, `stdout` and `stderr` options are ignored * - The subprocess sees itself running in a real terminal (`isTTY = true`) - * - Access the terminal via `subprocess.terminal` - * - `subprocess.stdin`, `subprocess.stdout`, `subprocess.stderr` return `null` + * - Access the terminal via `subprocess.terminal`, which is a {@link Terminal} + * - `subprocess.stdin`, `subprocess.stdout` and `subprocess.stderr` are `null` + * + * {@link spawn} types the returned {@link Subprocess} accordingly: its `terminal` is a `Terminal` + * and its `stdin`, `stdout` and `stderr` are `null`. When the option's value may be `undefined`, the + * returned process has both shapes in a union (`proc.stdout` is `ReadableStream | null`, and so on). * * Only available on POSIX systems (Linux, macOS). * @@ -7433,7 +7460,7 @@ declare module "bun" { * terminal.close(); * ``` */ - terminal?: TerminalOptions | Terminal; + terminal?: Term | (Term extends Terminal ? TerminalOptions : never); } type ReadableToIO = X extends "pipe" | undefined @@ -7533,26 +7560,57 @@ declare module "bun" { /** * A process created by {@link Bun.spawn}. * - * The 3 optional type parameters correspond to the `stdio` array from the options object. Instead of specifying them, use one of these utility types: + * The first 3 optional type parameters correspond to the `stdio` array from the options object. Instead of specifying them, use one of these utility types: * - {@link ReadableSubprocess} (any, pipe, pipe) * - {@link WritableSubprocess} (pipe, any, any) * - {@link PipedSubprocess} (pipe, pipe, pipe) * - {@link NullSubprocess} (ignore, ignore, ignore) + * + * The 4th is the type of {@link Subprocess.terminal}: `Terminal` for a process spawned with the `terminal` + * option, whose `stdin`, `stdout`, `stderr` and `readable` are then `null`; `undefined` for a process + * without one, whose stdio properties are derived from the first 3 parameters. {@link Bun.spawn} picks it + * from the options it is called with. + * + * It defaults to `undefined` when the stdio parameters are given (`Subprocess<"ignore", "pipe", "pipe">`, + * the utility types above), so those types keep describing exactly the streams they name. When the stdio + * parameters are left unspecified as well (`Subprocess`, `Subprocess`), it defaults to + * `Terminal | undefined`, so a plain `Subprocess` accepts every process {@link Bun.spawn} returns; its + * stdio properties then include `null`. `Subprocess` accepts exactly the + * processes that have a terminal. */ interface Subprocess< In extends SpawnOptions.Writable = SpawnOptions.Writable, Out extends SpawnOptions.Readable = SpawnOptions.Readable, Err extends SpawnOptions.Readable = SpawnOptions.Readable, + Term extends Terminal | undefined = [SpawnOptions.Writable, SpawnOptions.Readable, SpawnOptions.Readable] extends [ + In, + Out, + Err, + ] + ? Terminal | undefined + : undefined, > extends AsyncDisposable { - readonly stdin: SpawnOptions.WritableToIO; - readonly stdout: SpawnOptions.ReadableToIO; - readonly stderr: SpawnOptions.ReadableToIO; + /** + * Derived from the `stdin` option (see {@link SpawnOptions.WritableToIO}), or `null` when the process + * was spawned with the `terminal` option: write to {@link Subprocess.terminal} instead. + */ + readonly stdin: Term extends Terminal ? null : SpawnOptions.WritableToIO; + /** + * Derived from the `stdout` option (see {@link SpawnOptions.ReadableToIO}), or `null` when the process + * was spawned with the `terminal` option: the output arrives in the terminal's `data` callback instead. + */ + readonly stdout: Term extends Terminal ? null : SpawnOptions.ReadableToIO; + /** + * Derived from the `stderr` option (see {@link SpawnOptions.ReadableToIO}), or `null` when the process + * was spawned with the `terminal` option: the output arrives in the terminal's `data` callback instead. + */ + readonly stderr: Term extends Terminal ? null : SpawnOptions.ReadableToIO; /** - * The terminal attached to this subprocess, if spawned with the `terminal` option. - * `undefined` if no terminal was attached. + * The terminal attached to this subprocess when it was spawned with the `terminal` option + * (the same object if an existing {@link Terminal} was passed), `undefined` otherwise. * - * When a terminal is attached, `stdin`, `stdout`, and `stderr` return `null`. + * When a terminal is attached, `stdin`, `stdout`, and `stderr` are `null`. * Use `terminal.write()` and the `data` callback instead. * * @example @@ -7561,10 +7619,10 @@ declare module "bun" { * terminal: { data: (term, data) => console.log(data.toString()) }, * }); * - * proc.terminal?.write("echo hello\n"); + * proc.terminal.write("echo hello\n"); * ``` */ - readonly terminal: Terminal | undefined; + readonly terminal: Term; /** * Extra file descriptors passed to the `stdio` option. @@ -7583,7 +7641,7 @@ declare module "bun" { * * Exists for compatibility with {@link ReadableStream.pipeThrough} */ - readonly readable: SpawnOptions.ReadableToIO; + readonly readable: Term extends Terminal ? null : SpawnOptions.ReadableToIO; /** * The process ID of the child process @@ -7733,7 +7791,7 @@ declare module "bun" { */ cmd: string[]; // to support dynamically constructed commands }, - ): Subprocess; + ): Subprocess; /** * Spawn a new process @@ -7767,7 +7825,95 @@ declare module "bun" { */ cmds: string[], options?: SpawnOptions.SpawnOptions, - ): Subprocess; + ): Subprocess; + + /** + * Spawn a new process with a pseudo-terminal attached (the `terminal` option). + * + * The process's stdio goes through the terminal, so the returned {@link Subprocess} has a + * {@link Terminal} as its `terminal` and `null` as its `stdin`, `stdout` and `stderr`. + * + * ```ts + * const proc = Bun.spawn({ + * cmd: ["bash"], + * terminal: { data: (term, data) => process.stdout.write(data) }, + * }); + * proc.terminal.write("echo hello\n"); + * ``` + */ + function spawn< + const In extends SpawnOptions.Writable = "ignore", + const Out extends SpawnOptions.Readable = "pipe", + const Err extends SpawnOptions.Readable = "inherit", + >( + options: SpawnOptions.SpawnOptions & { + /** The command to run, resolved as described on the first overload. */ + cmd: string[]; + terminal: TerminalOptions | Terminal; + }, + ): Subprocess; + + /** + * Spawn a new process with a pseudo-terminal attached (the `terminal` option). + * + * The process's stdio goes through the terminal, so the returned {@link Subprocess} has a + * {@link Terminal} as its `terminal` and `null` as its `stdin`, `stdout` and `stderr`. + * + * ```ts + * const proc = Bun.spawn(["bash"], { + * terminal: { data: (term, data) => process.stdout.write(data) }, + * }); + * proc.terminal.write("echo hello\n"); + * ``` + */ + function spawn< + const In extends SpawnOptions.Writable = "ignore", + const Out extends SpawnOptions.Readable = "pipe", + const Err extends SpawnOptions.Readable = "inherit", + >( + /** The command to run, resolved as described on the first overload. */ + cmds: string[], + options: SpawnOptions.SpawnOptions & { terminal: TerminalOptions | Terminal }, + ): Subprocess; + + // The two overloads below accept everything the four above accept, so they have to stay last. The + // object form is the very last one: when a one-argument call matches no overload, TypeScript reports + // the last overload's error, which should be the object form's, and `ReturnType` is + // the last overload's return type, which this way stays equal to a plain `Subprocess`. + /** + * Spawn a new process whose `terminal` option may or may not be set (for example + * `terminal: interactive ? { ... } : undefined`). + * + * The returned {@link Subprocess} covers both cases: its `terminal` is `Terminal | undefined` and + * its `stdin`, `stdout` and `stderr` are `null` when a terminal is attached. + */ + function spawn< + const In extends SpawnOptions.Writable = "ignore", + const Out extends SpawnOptions.Readable = "pipe", + const Err extends SpawnOptions.Readable = "inherit", + >( + /** The command to run, resolved as described on the first overload. */ + cmds: string[], + options: SpawnOptions.SpawnOptions, + ): Subprocess; + + /** + * Spawn a new process whose `terminal` option may or may not be set (for example + * `terminal: interactive ? { ... } : undefined`). + * + * The returned {@link Subprocess} covers both cases: its `terminal` is `Terminal | undefined` and + * its `stdin`, `stdout` and `stderr` are `null` when a terminal is attached. + */ + function spawn< + const In extends SpawnOptions.Writable = "ignore", + const Out extends SpawnOptions.Readable = "pipe", + const Err extends SpawnOptions.Readable = "inherit", + >( + options: SpawnOptions.SpawnOptions & { + /** The command to run, resolved as described on the first overload. */ + cmd: string[]; + }, + ): Subprocess; /** * Synchronously spawn a new process diff --git a/test/integration/bun-types/bun-types.test.ts b/test/integration/bun-types/bun-types.test.ts index 05fa801f45e5..e1fca224640e 100644 --- a/test/integration/bun-types/bun-types.test.ts +++ b/test/integration/bun-types/bun-types.test.ts @@ -400,6 +400,35 @@ describe("@types/bun integration test", () => { }); }); + // Also runs on debug builds, where the in-process typeTest cases (which cover the whole + // fixture directory) are skipped: checks fixture/spawn.ts alone against the packed bun-types. + describe("Bun.spawn", () => { + test("fixture/spawn.ts type-checks", async () => { + const checkDir = join(TEMP_DIR, "spawn-fixture-check"); + const tsconfig = structuredClone(sourceTsconfig); + tsconfig.files = [join(BASE_FIXTURE_DIR, "spawn.ts")]; + tsconfig.compilerOptions.typeRoots = [join(BASE_FIXTURE_DIR, "node_modules", "@types")]; + await mkdir(checkDir, { recursive: true }); + await makeTree(checkDir, { + "tsconfig.json": JSON.stringify(tsconfig, null, 2), + }); + + await using proc = Bun.spawn({ + cmd: [bunExe(), join(BASE_FIXTURE_DIR, "node_modules", "typescript", "bin", "tsc"), "-p", "."], + env: bunEnv, + cwd: checkDir, + stdout: "pipe", + stderr: "pipe", + }); + + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + + expect(stderr.trim()).toBe(""); + expect(stdout.trim()).toBe(""); + expect(exitCode).toBe(0); + }); + }); + describe("Test Globals", () => { const code = ` const test_shouldBeAFunction: Function = test; diff --git a/test/integration/bun-types/fixture/spawn.ts b/test/integration/bun-types/fixture/spawn.ts index 2036413158a0..0680c26e5ae7 100644 --- a/test/integration/bun-types/fixture/spawn.ts +++ b/test/integration/bun-types/fixture/spawn.ts @@ -226,3 +226,186 @@ tsd.expectAssignable(); + tsd.expectType(data).is>(); + }, + }, + onExit(proc) { + tsd.expectType(proc).is>(); + proc.terminal.close(); + }, + ipc(message, proc) { + tsd.expectType(proc.stdin).is(); + }, + }); + tsd.expectType(proc).is>(); + tsd.expectType(proc.stdin).is(); + tsd.expectType(proc.stdout).is(); + tsd.expectType(proc.stderr).is(); + tsd.expectType(proc.readable).is(); + tsd.expectType(proc.terminal).is(); + tsd.expectType(proc.stdio).is<[null, null, null, ...(number | null)[]]>(); + proc.terminal.write("echo hello\n"); + // @ts-expect-error stdin is null when a terminal is attached + proc.stdin.write("hello"); + // @ts-expect-error stdout is null when a terminal is attached + proc.stdout.getReader(); +} +{ + // an existing Terminal, and the object form + const terminal = new Bun.Terminal({ data() {} }); + tsd.expectType(Bun.spawn(["bash"], { terminal })).is>(); + tsd + .expectType(Bun.spawn({ cmd: ["bash"], terminal })) + .is>(); + const proc = Bun.spawn({ + cmd: ["bash"], + stdio: ["pipe", "pipe", "pipe"], + terminal: { data(term, data) {} }, + onExit(proc) { + tsd.expectType(proc.terminal).is(); + }, + }); + tsd.expectType(proc).is>(); + tsd.expectType(proc.stdout).is(); +} +{ + // a terminal that may be absent gives the union of both shapes + const proc = Bun.spawn(["bash"], { + terminal: interactive ? { data(term, data) {} } : undefined, + onExit(proc) { + tsd.expectType(proc.terminal).is(); + }, + }); + tsd.expectType(proc).is>(); + tsd.expectType(proc.stdin).is(); + tsd.expectType(proc.stdout).is> | null>(); + tsd.expectType(proc.readable).is> | null>(); + tsd.expectType(proc.terminal).is(); + const objectForm = Bun.spawn({ cmd: ["bash"], terminal: interactive ? {} : undefined }); + tsd.expectType(objectForm).is>(); +} +{ + // without the option, terminal is undefined and the stdio types are unchanged + const proc = Bun.spawn(["cat"], { + stdin: "pipe", + onExit(proc) { + tsd.expectType(proc).is>(); + proc.stdin.write("bye"); + }, + }); + tsd.expectType(proc).is>(); + tsd.expectType(proc).is>(); + tsd.expectType(proc.stdin).is(); + tsd.expectType(proc.terminal).is(); + tsd.expectType(Bun.spawn(["cat"])).is>(); + tsd + .expectType(Bun.spawn(["cat"], { terminal: undefined })) + .is>(); + tsd.expectType(Bun.spawn({ cmd: ["cat"], stdin: "pipe" })).is>(); + tsd + .expectType( + Bun.spawnSync(["cat"], { + onExit(proc) { + tsd.expectType(proc.terminal).is(); + }, + }), + ) + .is>(); + // @ts-expect-error spawnSync does not take a terminal + Bun.spawnSync(["cat"], { terminal: {} }); +} +{ + // annotated options objects: the 4th type argument says whether they carry a terminal + const plain: Bun.SpawnOptions.SpawnOptions<"ignore", "pipe", "inherit"> = { + onExit(proc) { + tsd.expectType(proc.stdout).is>>(); + }, + }; + tsd.expectType(Bun.spawn(["cat"], plain)).is>(); + // @ts-expect-error a SpawnOptions without the 4th type argument has no terminal + const rejected: Bun.SpawnOptions.SpawnOptions<"ignore", "pipe", "inherit"> = { terminal: {} }; + const withTerminal: Bun.SpawnOptions.SpawnOptions<"ignore", "pipe", "inherit", Bun.Terminal> = { + terminal: { data(term, data) {} }, + onExit(proc) { + tsd.expectType(proc.terminal).is(); + }, + }; + const maybe: Bun.SpawnOptions.SpawnOptions<"ignore", "pipe", "inherit", Bun.Terminal | undefined> = interactive + ? { terminal: new Bun.Terminal({}) } + : {}; + tsd.expectType(Bun.spawn(["cat"], maybe)).is>(); + tsd.expectAssignable(plain); + tsd.expectAssignable(withTerminal); + // @ts-expect-error may carry a terminal + tsd.expectAssignable(maybe); + // @ts-expect-error may carry a terminal + tsd.expectAssignable(maybe); + tsd.expectType<(typeof plain)["terminal"]>().is(); + tsd.expectType<(typeof withTerminal)["terminal"]>().is(); + tsd.expectType<(typeof maybe)["terminal"]>().is(); +} +{ + // holder types + const withTerminal = Bun.spawn(["bash"], { terminal: {} }); + const without = Bun.spawn(["cat"], { stdin: "pipe" }); + const either = Bun.spawn(["cat"], { terminal: interactive ? {} : undefined }); + + // Subprocess with no type arguments (and ReturnType) holds any of them + tsd.expectAssignable(withTerminal); + tsd.expectAssignable(without); + tsd.expectAssignable(either); + tsd.expectAssignable>(withTerminal); + tsd.expectAssignable>(without); + tsd.expectType>().is(); + tsd.expectType().is(); + tsd.expectAssignable(null); + tsd.expectAssignable(null); + tsd.expectAssignable(null); + tsd.expectAssignable(null); + function cleanUp(proc: Bun.Subprocess) { + proc.terminal?.close(); + proc.kill(); + } + cleanUp(withTerminal); + cleanUp(without); + cleanUp(either); + + // Subprocess<.., .., .., Terminal> holds exactly the processes that have one + tsd.expectAssignable>(withTerminal); + tsd.expectAssignable>( + Bun.spawn(["bash"], { terminal: new Bun.Terminal({}) }), + ); + // @ts-expect-error has no terminal + tsd.expectAssignable>(without); + // @ts-expect-error may have no terminal + tsd.expectAssignable>(either); + tsd.expectType["stdout"]>().is(); + + // giving the stdio type arguments still describes exactly those streams, and excludes terminal processes + tsd.expectType().is(); + tsd.expectType().is(); + tsd.expectType().is>>(); + tsd.expectType().is(); + tsd.expectType().is(); + tsd.expectType["stdout"]>().is>>(); + tsd.expectAssignable(without); + // @ts-expect-error its stdin is null, not a FileSink + tsd.expectAssignable(withTerminal); + // @ts-expect-error its stdout is null, not a stream + tsd.expectAssignable(Bun.spawn(["bash"], { stdio: ["pipe", "pipe", "pipe"], terminal: {} })); + // @ts-expect-error may have a terminal + tsd.expectAssignable>(either); + tsd.expectAssignable>(Bun.spawn(["cat"])); +}