feat(cli/run): add --parallel and --sequential for running multiple scripts with workspace support (#26551)

## Summary

Adds `bun run --parallel` and `bun run --sequential` — new flags for
running multiple package.json scripts concurrently or sequentially with
Foreman-style prefixed output. Includes full `--filter`/`--workspaces`
integration for running scripts across workspace packages.

### Usage

```bash
# Run "build" and "test" concurrently from the current package.json
bun run --parallel build test

# Run "build" and "test" sequentially with prefixed output
bun run --sequential build test

# Glob-matched script names
bun run --parallel "build:*"

# Run "build" in all workspace packages concurrently
bun run --parallel --filter '*' build

# Run "build" in all workspace packages sequentially
bun run --sequential --workspaces build

# Glob-matched scripts across all packages
bun run --parallel --filter '*' "build:*"

# Multiple scripts across all packages
bun run --parallel --filter '*' build lint test

# Continue running even if one package fails
bun run --parallel --no-exit-on-error --filter '*' test

# Skip packages missing the script
bun run --parallel --workspaces --if-present build
```

## How it works

### Output format

Each script's stdout/stderr is prefixed with a colored, padded label:

```
build | compiling...
test  | running suite...
lint  | checking files...
```

### Label format

- **Without `--filter`/`--workspaces`**: labels are just the script name
→ `build | output`
- **With `--filter`/`--workspaces`**: labels are `package:script` →
`pkg-a:build | output`
- **Fallback**: if a package.json has no `name` field, the relative path
from the workspace root is used (e.g., `packages/my-pkg:build`)

### Execution model

- **`--parallel`**: all scripts start immediately, output is interleaved
with prefixes
- **`--sequential`**: scripts run one at a time in order, each waiting
for the previous to finish
- **Pre/post scripts** (`prebuild`/`postbuild`) are grouped with their
main script and run in dependency order within each group
- By default, a failure kills all remaining scripts.
`--no-exit-on-error` lets all scripts finish.

### Workspace integration

The workspace branch in `multi_run.zig` uses a two-pass approach for
deterministic ordering:

1. **Collect**: iterate workspace packages using
`FilterArg.PackageFilterIterator` (same infrastructure as
`filter_run.zig`), filtering with `FilterArg.FilterSet`, collecting
matched packages with their scripts, PATH, and cwd.
2. **Sort**: sort matched packages by name (tiebreak by directory path)
for deterministic ordering — filesystem iteration order from the glob
walker is nondeterministic.
3. **Build configs**: for each sorted package, expand script names
(including globs like `build:*`) against that package's scripts map,
creating `ScriptConfig` entries with `pkg:script` labels and per-package
cwd/PATH.

### Behavioral consistency with `filter_run.zig`

| Behavior | `filter_run.zig` | `multi_run.zig` (this PR) |
|----------|-------------------|---------------------------|
| `--workspaces` skips root package | Yes | Yes |
| `--workspaces` errors on missing script | Yes | Yes |
| `--if-present` silently skips missing | Yes | Yes |
| `--filter` without `--workspaces` includes root | Yes (if matches) |
Yes (if matches) |
| Pre/post script chains | Per-package | Per-package |
| Per-package cwd | Yes | Yes |
| Per-package PATH (`node_modules/.bin`) | Yes | Yes |

### Key implementation details

- Each workspace package script runs in its own package directory with
its own `node_modules/.bin` PATH
- `dirpath` from the glob walker is duped to avoid use-after-free when
the iterator's arena is freed between patterns
- `addScriptConfigs` takes an optional `label_prefix` parameter — `null`
for single-package mode, package name for workspace mode
- `MultiRunProcessHandle` is registered in the `ProcessExitHandler`
tagged pointer union in `process.zig`

## Files changed

| File | Change |
|------|--------|
| `src/cli/multi_run.zig` | New file: process management, output
routing, workspace integration, dependency ordering |
| `src/cli.zig` | Dispatch to `MultiRun.run()` for
`--parallel`/`--sequential`, new context fields |
| `src/cli/Arguments.zig` | Parse `--parallel`, `--sequential`,
`--no-exit-on-error` flags |
| `src/bun.js/api/bun/process.zig` | Register `MultiRunProcessHandle` in
`ProcessExitHandler` tagged pointer union |
| `test/cli/run/multi-run.test.ts` | 118 tests (102 core + 16 workspace
integration) |
| `docs/pm/filter.mdx` | Document `--parallel`/`--sequential` +
`--filter`/`--workspaces` combination |
| `docs/snippets/cli/run.mdx` | Add `--parallel`, `--sequential`,
`--no-exit-on-error` parameter docs |

## Test plan

All 118 tests pass with debug build (`bun bd test
test/cli/run/multi-run.test.ts`). The 16 new workspace tests all fail
with system bun (`USE_SYSTEM_BUN=1`), confirming they test new
functionality.

### Workspace integration tests (16 tests)

1. `--parallel --filter='*'` runs script in all packages
2. `--parallel --filter='pkg-a'` runs only in matching package
3. `--parallel --workspaces` matches all workspace packages
4. `--parallel --filter='*'` with glob expands per-package scripts
5. `--sequential --filter='*'` runs in sequence (deterministic order)
6. Workspace + failure aborts other scripts
7. Workspace + `--no-exit-on-error` lets all finish
8. `--workspaces` skips root package
9. Each workspace script runs in its own package directory (cwd
verification)
10. Multiple script names across workspaces (`build` + `test`)
11. Pre/post scripts work per workspace package
12. `--filter` skips packages without the script (no error)
13. `--workspaces` errors when a package is missing the script
14. `--workspaces --if-present` skips missing scripts silently
15. Labels are padded correctly across workspace packages
16. Package without `name` field uses relative path as label

---------

Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
Co-authored-by: Dylan Conway <dylan.conway567@gmail.com>
This commit is contained in:
Jarred Sumner
2026-01-29 20:20:39 -08:00
committed by GitHub
parent adc1a6b05c
commit bb4d5b9af5
7 changed files with 2928 additions and 0 deletions

View File

@@ -97,6 +97,31 @@ Filters respect your [workspace configuration](/pm/workspaces): If you have a `p
bun run --filter foo myscript bun run --filter foo myscript
``` ```
### Parallel and sequential mode
Combine `--filter` or `--workspaces` with `--parallel` or `--sequential` to run scripts across workspace packages with Foreman-style prefixed output:
```bash terminal icon="terminal"
# Run "build" in all matching packages concurrently
bun run --parallel --filter '*' build
# Run "build" in all workspace packages sequentially
bun run --sequential --workspaces build
# Run glob-matched scripts across all packages
bun run --parallel --filter '*' "build:*"
# Continue running even if one package's script fails
bun run --parallel --no-exit-on-error --filter '*' test
# Run multiple scripts across all packages
bun run --parallel --filter '*' build lint
```
Each line of output is prefixed with the package and script name (e.g. `pkg-a:build | ...`). Without `--filter`/`--workspaces`, the prefix is just the script name (e.g. `build | ...`). When a package's `package.json` has no `name` field, the relative path from the workspace root is used instead.
Use `--if-present` with `--workspaces` to skip packages that don't have the requested script instead of erroring.
### Dependency Order ### Dependency Order
Bun will respect package dependency order when running scripts. Say you have a package `foo` that depends on another package `bar` in your workspace, and both packages have a `build` script. When you run `bun --filter '*' build`, you will notice that `foo` will only start running once `bar` is done. Bun will respect package dependency order when running scripts. Say you have a package `foo` that depends on another package `bar` in your workspace, and both packages have a `build` script. When you run `bun --filter '*' build`, you will notice that `foo` will only start running once `bar` is done.

View File

@@ -40,6 +40,18 @@ bun run <file or script>
Run a script in all workspace packages (from the <code>workspaces</code> field in <code>package.json</code>) Run a script in all workspace packages (from the <code>workspaces</code> field in <code>package.json</code>)
</ParamField> </ParamField>
<ParamField path="--parallel" type="boolean">
Run multiple scripts or workspace scripts concurrently with prefixed output
</ParamField>
<ParamField path="--sequential" type="boolean">
Run multiple scripts or workspace scripts one after another with prefixed output
</ParamField>
<ParamField path="--no-exit-on-error" type="boolean">
When using <code>--parallel</code> or <code>--sequential</code>, continue running other scripts when one fails
</ParamField>
### Runtime &amp; Process Control ### Runtime &amp; Process Control
<ParamField path="--bun" type="boolean"> <ParamField path="--bun" type="boolean">

View File

@@ -84,6 +84,7 @@ pub const ProcessExitHandler = struct {
LifecycleScriptSubprocess, LifecycleScriptSubprocess,
ShellSubprocess, ShellSubprocess,
ProcessHandle, ProcessHandle,
MultiRunProcessHandle,
SecurityScanSubprocess, SecurityScanSubprocess,
SyncProcess, SyncProcess,
}, },
@@ -111,6 +112,10 @@ pub const ProcessExitHandler = struct {
const subprocess = this.ptr.as(ProcessHandle); const subprocess = this.ptr.as(ProcessHandle);
subprocess.onProcessExit(process, status, rusage); subprocess.onProcessExit(process, status, rusage);
}, },
@field(TaggedPointer.Tag, @typeName(MultiRunProcessHandle)) => {
const subprocess = this.ptr.as(MultiRunProcessHandle);
subprocess.onProcessExit(process, status, rusage);
},
@field(TaggedPointer.Tag, @typeName(ShellSubprocess)) => { @field(TaggedPointer.Tag, @typeName(ShellSubprocess)) => {
const subprocess = this.ptr.as(ShellSubprocess); const subprocess = this.ptr.as(ShellSubprocess);
subprocess.onProcessExit(process, status, rusage); subprocess.onProcessExit(process, status, rusage);
@@ -2251,6 +2256,7 @@ pub const sync = struct {
}; };
const std = @import("std"); const std = @import("std");
const MultiRunProcessHandle = @import("../../../cli/multi_run.zig").ProcessHandle;
const ProcessHandle = @import("../../../cli/filter_run.zig").ProcessHandle; const ProcessHandle = @import("../../../cli/filter_run.zig").ProcessHandle;
const bun = @import("bun"); const bun = @import("bun");

View File

@@ -425,6 +425,9 @@ pub const Command = struct {
filters: []const []const u8 = &.{}, filters: []const []const u8 = &.{},
workspaces: bool = false, workspaces: bool = false,
if_present: bool = false, if_present: bool = false,
parallel: bool = false,
sequential: bool = false,
no_exit_on_error: bool = false,
preloads: []const string = &.{}, preloads: []const string = &.{},
has_loaded_global_config: bool = false, has_loaded_global_config: bool = false,
@@ -888,6 +891,13 @@ pub const Command = struct {
const ctx = try Command.init(allocator, log, .RunCommand); const ctx = try Command.init(allocator, log, .RunCommand);
ctx.args.target = .bun; ctx.args.target = .bun;
if (ctx.parallel or ctx.sequential) {
MultiRun.run(ctx) catch |err| {
Output.prettyErrorln("<r><red>error<r>: {s}", .{@errorName(err)});
Global.exit(1);
};
}
if (ctx.filters.len > 0 or ctx.workspaces) { if (ctx.filters.len > 0 or ctx.workspaces) {
FilterRun.runScriptsWithFilter(ctx) catch |err| { FilterRun.runScriptsWithFilter(ctx) catch |err| {
Output.prettyErrorln("<r><red>error<r>: {s}", .{@errorName(err)}); Output.prettyErrorln("<r><red>error<r>: {s}", .{@errorName(err)});
@@ -927,6 +937,13 @@ pub const Command = struct {
}; };
ctx.args.target = .bun; ctx.args.target = .bun;
if (ctx.parallel or ctx.sequential) {
MultiRun.run(ctx) catch |err| {
Output.prettyErrorln("<r><red>error<r>: {s}", .{@errorName(err)});
Global.exit(1);
};
}
if (ctx.filters.len > 0 or ctx.workspaces) { if (ctx.filters.len > 0 or ctx.workspaces) {
FilterRun.runScriptsWithFilter(ctx) catch |err| { FilterRun.runScriptsWithFilter(ctx) catch |err| {
Output.prettyErrorln("<r><red>error<r>: {s}", .{@errorName(err)}); Output.prettyErrorln("<r><red>error<r>: {s}", .{@errorName(err)});
@@ -1762,6 +1779,7 @@ const string = []const u8;
const AddCompletions = @import("./cli/add_completions.zig"); const AddCompletions = @import("./cli/add_completions.zig");
const FilterRun = @import("./cli/filter_run.zig"); const FilterRun = @import("./cli/filter_run.zig");
const MultiRun = @import("./cli/multi_run.zig");
const PmViewCommand = @import("./cli/pm_view_command.zig"); const PmViewCommand = @import("./cli/pm_view_command.zig");
const fs = @import("./fs.zig"); const fs = @import("./fs.zig");
const options = @import("./options.zig"); const options = @import("./options.zig");

View File

@@ -130,6 +130,9 @@ pub const auto_or_run_params = [_]ParamType{
clap.parseParam("-b, --bun Force a script or package to use Bun's runtime instead of Node.js (via symlinking node)") catch unreachable, clap.parseParam("-b, --bun Force a script or package to use Bun's runtime instead of Node.js (via symlinking node)") catch unreachable,
clap.parseParam("--shell <STR> Control the shell used for package.json scripts. Supports either 'bun' or 'system'") catch unreachable, clap.parseParam("--shell <STR> Control the shell used for package.json scripts. Supports either 'bun' or 'system'") catch unreachable,
clap.parseParam("--workspaces Run a script in all workspace packages (from the \"workspaces\" field in package.json)") catch unreachable, clap.parseParam("--workspaces Run a script in all workspace packages (from the \"workspaces\" field in package.json)") catch unreachable,
clap.parseParam("--parallel Run multiple scripts concurrently with Foreman-style output") catch unreachable,
clap.parseParam("--sequential Run multiple scripts sequentially with Foreman-style output") catch unreachable,
clap.parseParam("--no-exit-on-error Continue running other scripts when one fails (with --parallel/--sequential)") catch unreachable,
}; };
pub const auto_only_params = [_]ParamType{ pub const auto_only_params = [_]ParamType{
@@ -453,6 +456,9 @@ pub fn parse(allocator: std.mem.Allocator, ctx: Command.Context, comptime cmd: C
ctx.filters = args.options("--filter"); ctx.filters = args.options("--filter");
ctx.workspaces = args.flag("--workspaces"); ctx.workspaces = args.flag("--workspaces");
ctx.if_present = args.flag("--if-present"); ctx.if_present = args.flag("--if-present");
ctx.parallel = args.flag("--parallel");
ctx.sequential = args.flag("--sequential");
ctx.no_exit_on_error = args.flag("--no-exit-on-error");
if (args.option("--elide-lines")) |elide_lines| { if (args.option("--elide-lines")) |elide_lines| {
if (elide_lines.len > 0) { if (elide_lines.len > 0) {

838
src/cli/multi_run.zig Normal file
View File

@@ -0,0 +1,838 @@
const ScriptConfig = struct {
label: []const u8,
command: [:0]const u8,
cwd: []const u8,
PATH: []const u8,
};
/// Wraps a BufferedReader and tracks whether it represents stdout or stderr,
/// so output can be routed to the correct parent stream.
const PipeReader = struct {
const This = @This();
reader: bun.io.BufferedReader = bun.io.BufferedReader.init(This),
handle: *ProcessHandle = undefined, // set in ProcessHandle.start()
is_stderr: bool,
line_buffer: std.array_list.Managed(u8) = std.array_list.Managed(u8).init(bun.default_allocator),
pub fn onReadChunk(this: *This, chunk: []const u8, hasMore: bun.io.ReadState) bool {
_ = hasMore;
this.handle.state.readChunk(this, chunk) catch {};
return true;
}
pub fn onReaderDone(this: *This) void {
_ = this;
}
pub fn onReaderError(this: *This, err: bun.sys.Error) void {
_ = this;
_ = err;
}
pub fn eventLoop(this: *This) *bun.jsc.MiniEventLoop {
return this.handle.state.event_loop;
}
pub fn loop(this: *This) *bun.Async.Loop {
if (comptime bun.Environment.isWindows) {
return this.handle.state.event_loop.loop.uv_loop;
} else {
return this.handle.state.event_loop.loop;
}
}
};
pub const ProcessHandle = struct {
const This = @This();
config: *ScriptConfig,
state: *State,
color_idx: usize,
stdout_reader: PipeReader = .{ .is_stderr = false },
stderr_reader: PipeReader = .{ .is_stderr = true },
process: ?struct {
ptr: *bun.spawn.Process,
status: bun.spawn.Status = .running,
} = null,
options: bun.spawn.SpawnOptions,
start_time: ?std.time.Instant = null,
end_time: ?std.time.Instant = null,
remaining_dependencies: usize = 0,
/// Dependents within the same script group (pre->main->post chain).
/// These are NOT started if this handle fails, even with --no-exit-on-error.
group_dependents: std.array_list.Managed(*This) = std.array_list.Managed(*This).init(bun.default_allocator),
/// Dependents across sequential groups (group N -> group N+1).
/// These ARE started even if this handle fails when --no-exit-on-error is set.
next_dependents: std.array_list.Managed(*This) = std.array_list.Managed(*This).init(bun.default_allocator),
fn start(this: *This) !void {
this.state.remaining_scripts += 1;
var argv = [_:null]?[*:0]const u8{
this.state.shell_bin,
if (Environment.isPosix) "-c" else "exec",
this.config.command,
null,
};
this.start_time = std.time.Instant.now() catch null;
var spawned: bun.spawn.process.SpawnProcessResult = brk: {
var arena = std.heap.ArenaAllocator.init(bun.default_allocator);
defer arena.deinit();
const original_path = this.state.env.map.get("PATH") orelse "";
bun.handleOom(this.state.env.map.put("PATH", this.config.PATH));
defer bun.handleOom(this.state.env.map.put("PATH", original_path));
const envp = try this.state.env.map.createNullDelimitedEnvMap(arena.allocator());
break :brk try (try bun.spawn.spawnProcess(&this.options, argv[0..], envp)).unwrap();
};
var process = spawned.toProcess(this.state.event_loop, false);
this.stdout_reader.handle = this;
this.stderr_reader.handle = this;
this.stdout_reader.reader.setParent(&this.stdout_reader);
this.stderr_reader.reader.setParent(&this.stderr_reader);
if (Environment.isWindows) {
this.stdout_reader.reader.source = .{ .pipe = this.options.stdout.buffer };
this.stderr_reader.reader.source = .{ .pipe = this.options.stderr.buffer };
}
if (Environment.isPosix) {
if (spawned.stdout) |stdout_fd| {
_ = bun.sys.setNonblocking(stdout_fd);
try this.stdout_reader.reader.start(stdout_fd, true).unwrap();
}
if (spawned.stderr) |stderr_fd| {
_ = bun.sys.setNonblocking(stderr_fd);
try this.stderr_reader.reader.start(stderr_fd, true).unwrap();
}
} else {
try this.stdout_reader.reader.startWithCurrentPipe().unwrap();
try this.stderr_reader.reader.startWithCurrentPipe().unwrap();
}
this.process = .{ .ptr = process };
process.setExitHandler(this);
switch (process.watchOrReap()) {
.result => {},
.err => |err| {
if (!process.hasExited())
process.onExit(.{ .err = err }, &std.mem.zeroes(bun.spawn.Rusage));
},
}
}
pub fn onProcessExit(this: *This, proc: *bun.spawn.Process, status: bun.spawn.Status, _: *const bun.spawn.Rusage) void {
this.process.?.status = status;
this.end_time = std.time.Instant.now() catch null;
_ = proc;
this.state.processExit(this) catch {};
}
pub fn eventLoop(this: *This) *bun.jsc.MiniEventLoop {
return this.state.event_loop;
}
pub fn loop(this: *This) *bun.Async.Loop {
if (comptime bun.Environment.isWindows) {
return this.state.event_loop.loop.uv_loop;
} else {
return this.state.event_loop.loop;
}
}
};
const colors = [_][]const u8{
"\x1b[36m", // cyan
"\x1b[33m", // yellow
"\x1b[35m", // magenta
"\x1b[32m", // green
"\x1b[34m", // blue
"\x1b[31m", // red
};
const reset = "\x1b[0m";
const State = struct {
const This = @This();
handles: []ProcessHandle,
event_loop: *bun.jsc.MiniEventLoop,
remaining_scripts: usize = 0,
max_label_len: usize,
shell_bin: [:0]const u8,
aborted: bool = false,
no_exit_on_error: bool,
env: *bun.DotEnv.Loader,
use_colors: bool,
pub fn isDone(this: *This) bool {
return this.remaining_scripts == 0;
}
fn readChunk(this: *This, pipe: *PipeReader, chunk: []const u8) (std.Io.Writer.Error || bun.OOM)!void {
try pipe.line_buffer.appendSlice(chunk);
// Route to correct parent stream: child stdout -> parent stdout, child stderr -> parent stderr
const writer = if (pipe.is_stderr) Output.errorWriter() else Output.writer();
// Process complete lines
while (std.mem.indexOfScalar(u8, pipe.line_buffer.items, '\n')) |newline_pos| {
const line = pipe.line_buffer.items[0 .. newline_pos + 1];
try this.writeLineWithPrefix(pipe.handle, line, writer);
// Remove processed line from buffer
const remaining = pipe.line_buffer.items[newline_pos + 1 ..];
std.mem.copyForwards(u8, pipe.line_buffer.items[0..remaining.len], remaining);
pipe.line_buffer.items.len = remaining.len;
}
}
fn writeLineWithPrefix(this: *This, handle: *ProcessHandle, line: []const u8, writer: *std.Io.Writer) std.Io.Writer.Error!void {
try this.writePrefix(handle, writer);
try writer.writeAll(line);
}
fn writePrefix(this: *This, handle: *ProcessHandle, writer: *std.Io.Writer) std.Io.Writer.Error!void {
if (this.use_colors) {
try writer.writeAll(colors[handle.color_idx % colors.len]);
}
try writer.writeAll(handle.config.label);
const padding = this.max_label_len -| handle.config.label.len;
for (0..padding) |_| {
try writer.writeByte(' ');
}
if (this.use_colors) {
try writer.writeAll(reset);
}
try writer.writeAll(" | ");
}
fn flushPipeBuffer(this: *This, handle: *ProcessHandle, pipe: *PipeReader) std.Io.Writer.Error!void {
if (pipe.line_buffer.items.len > 0) {
const line = pipe.line_buffer.items;
const needs_newline = line.len > 0 and line[line.len - 1] != '\n';
const writer = if (pipe.is_stderr) Output.errorWriter() else Output.writer();
try this.writeLineWithPrefix(handle, line, writer);
if (needs_newline) {
writer.writeAll("\n") catch {};
}
pipe.line_buffer.clearRetainingCapacity();
}
}
fn processExit(this: *This, handle: *ProcessHandle) std.Io.Writer.Error!void {
this.remaining_scripts -= 1;
// Flush remaining buffers (stdout first, then stderr)
try this.flushPipeBuffer(handle, &handle.stdout_reader);
try this.flushPipeBuffer(handle, &handle.stderr_reader);
// Print exit status to stderr (status messages always go to stderr)
const writer = Output.errorWriter();
try this.writePrefix(handle, writer);
switch (handle.process.?.status) {
.exited => |exited| {
if (exited.code != 0) {
try writer.print("Exited with code {d}\n", .{exited.code});
} else {
if (handle.start_time != null and handle.end_time != null) {
const duration = handle.end_time.?.since(handle.start_time.?);
const ms = @as(f64, @floatFromInt(duration)) / 1_000_000.0;
if (ms > 1000.0) {
try writer.print("Done in {d:.2}s\n", .{ms / 1000.0});
} else {
try writer.print("Done in {d:.0}ms\n", .{ms});
}
} else {
try writer.writeAll("Done\n");
}
}
},
.signaled => |signal| {
try writer.print("Signaled: {s}\n", .{@tagName(signal)});
},
else => {
try writer.writeAll("Error\n");
},
}
// Check if we should abort on error
const failed = switch (handle.process.?.status) {
.exited => |exited| exited.code != 0,
.signaled => true,
else => true,
};
if (failed and !this.no_exit_on_error) {
this.abort();
return;
}
if (failed) {
// Pre->main->post chain is broken -- skip group dependents.
this.skipDependents(handle.group_dependents.items);
// But cascade to next-group dependents (sequential --no-exit-on-error).
if (!this.aborted) {
this.startDependents(handle.next_dependents.items);
}
return;
}
// Success: cascade to all dependents
if (!this.aborted) {
this.startDependents(handle.group_dependents.items);
this.startDependents(handle.next_dependents.items);
}
}
fn startDependents(_: *This, dependents: []*ProcessHandle) void {
for (dependents) |dependent| {
dependent.remaining_dependencies -= 1;
if (dependent.remaining_dependencies == 0) {
dependent.start() catch {
Output.prettyErrorln("<r><red>error<r>: Failed to start process", .{});
Global.exit(1);
};
}
}
}
/// Skip group dependents that will never start because their predecessor
/// failed. Recursively skip their group dependents too.
fn skipDependents(this: *This, dependents: []*ProcessHandle) void {
for (dependents) |dependent| {
dependent.remaining_dependencies -= 1;
if (dependent.remaining_dependencies == 0) {
this.skipDependents(dependent.group_dependents.items);
// Still cascade next_dependents so sequential chains continue
if (!this.aborted) {
this.startDependents(dependent.next_dependents.items);
}
}
}
}
pub fn abort(this: *This) void {
this.aborted = true;
for (this.handles) |*handle| {
if (handle.process) |*proc| {
if (proc.status == .running) {
_ = proc.ptr.kill(std.posix.SIG.INT);
}
}
}
}
pub fn finalize(this: *This) u8 {
for (this.handles) |handle| {
if (handle.process) |proc| {
switch (proc.status) {
.exited => |exited| {
if (exited.code != 0) return exited.code;
},
.signaled => |signal| return signal.toExitCode() orelse 1,
else => return 1,
}
}
}
return 0;
}
};
const AbortHandler = struct {
var should_abort = false;
fn posixSignalHandler(sig: i32, info: *const std.posix.siginfo_t, _: ?*const anyopaque) callconv(.c) void {
_ = sig;
_ = info;
should_abort = true;
}
fn windowsCtrlHandler(dwCtrlType: std.os.windows.DWORD) callconv(.winapi) std.os.windows.BOOL {
if (dwCtrlType == std.os.windows.CTRL_C_EVENT) {
should_abort = true;
return std.os.windows.TRUE;
}
return std.os.windows.FALSE;
}
pub fn install() void {
if (Environment.isPosix) {
const action = std.posix.Sigaction{
.handler = .{ .sigaction = AbortHandler.posixSignalHandler },
.mask = std.posix.sigemptyset(),
.flags = std.posix.SA.SIGINFO | std.posix.SA.RESTART | std.posix.SA.RESETHAND,
};
std.posix.sigaction(std.posix.SIG.INT, &action, null);
} else {
const res = bun.c.SetConsoleCtrlHandler(windowsCtrlHandler, std.os.windows.TRUE);
if (res == 0) {
if (Environment.isDebug) {
Output.warn("Failed to set abort handler\n", .{});
}
}
}
}
pub fn uninstall() void {
if (Environment.isWindows) {
_ = bun.c.SetConsoleCtrlHandler(null, std.os.windows.FALSE);
}
}
};
/// Simple glob matching: `*` matches any sequence of characters.
fn matchesGlob(pattern: []const u8, name: []const u8) bool {
var pi: usize = 0;
var ni: usize = 0;
var star_pi: usize = 0;
var star_ni: usize = 0;
var have_star = false;
while (ni < name.len or pi < pattern.len) {
if (pi < pattern.len and pattern[pi] == '*') {
have_star = true;
star_pi = pi;
star_ni = ni;
pi += 1;
} else if (pi < pattern.len and ni < name.len and pattern[pi] == name[ni]) {
pi += 1;
ni += 1;
} else if (have_star) {
pi = star_pi + 1;
star_ni += 1;
ni = star_ni;
if (ni > name.len) return false;
} else {
return false;
}
}
return true;
}
/// Add configs for a single script name (with pre/post handling).
/// When `label_prefix` is non-null, labels become "{prefix}:{name}" (for workspace runs).
fn addScriptConfigs(
configs: *std.array_list.Managed(ScriptConfig),
group_infos: *std.array_list.Managed(GroupInfo),
raw_name: []const u8,
scripts_map: ?*const bun.StringArrayHashMap([]const u8),
allocator: std.mem.Allocator,
cwd: []const u8,
PATH: []const u8,
label_prefix: ?[]const u8,
) !void {
const group_start = configs.items.len;
const label = if (label_prefix) |prefix|
try std.fmt.allocPrint(allocator, "{s}:{s}", .{ prefix, raw_name })
else
raw_name;
const script_content = if (scripts_map) |sm| sm.get(raw_name) else null;
if (script_content) |content| {
// It's a package.json script - check for pre/post
const pre_name = try std.fmt.allocPrint(allocator, "pre{s}", .{raw_name});
const post_name = try std.fmt.allocPrint(allocator, "post{s}", .{raw_name});
const pre_content = if (scripts_map) |sm| sm.get(pre_name) else null;
const post_content = if (scripts_map) |sm| sm.get(post_name) else null;
if (pre_content) |pc| {
var cmd_buf = try std.array_list.Managed(u8).initCapacity(allocator, pc.len + 1);
try RunCommand.replacePackageManagerRun(&cmd_buf, pc);
try cmd_buf.append(0);
try configs.append(.{
.label = label,
.command = cmd_buf.items[0 .. cmd_buf.items.len - 1 :0],
.cwd = cwd,
.PATH = PATH,
});
}
// Main script
{
var cmd_buf = try std.array_list.Managed(u8).initCapacity(allocator, content.len + 1);
try RunCommand.replacePackageManagerRun(&cmd_buf, content);
try cmd_buf.append(0);
try configs.append(.{
.label = label,
.command = cmd_buf.items[0 .. cmd_buf.items.len - 1 :0],
.cwd = cwd,
.PATH = PATH,
});
}
if (post_content) |pc| {
var cmd_buf = try std.array_list.Managed(u8).initCapacity(allocator, pc.len + 1);
try RunCommand.replacePackageManagerRun(&cmd_buf, pc);
try cmd_buf.append(0);
try configs.append(.{
.label = label,
.command = cmd_buf.items[0 .. cmd_buf.items.len - 1 :0],
.cwd = cwd,
.PATH = PATH,
});
}
} else {
// Not a package.json script - run as a raw command
// If it looks like a file path, prefix with bun executable
const is_file = raw_name.len > 0 and (raw_name[0] == '.' or raw_name[0] == '/' or hasRunnableExtension(raw_name));
const command_z = if (is_file) brk: {
const bun_path = bun.selfExePath() catch "bun";
const cmd_str = try std.fmt.allocPrint(allocator, "{s} {s}" ++ "\x00", .{ bun_path, raw_name });
break :brk cmd_str[0 .. cmd_str.len - 1 :0];
} else try allocator.dupeZ(u8, raw_name);
try configs.append(.{
.label = label,
.command = command_z,
.cwd = cwd,
.PATH = PATH,
});
}
try group_infos.append(.{
.start = group_start,
.count = configs.items.len - group_start,
});
}
const GroupInfo = struct { start: usize, count: usize };
pub fn run(ctx: Command.Context) !noreturn {
// Validate flags
if (ctx.parallel and ctx.sequential) {
Output.prettyErrorln("<r><red>error<r>: --parallel and --sequential cannot be used together", .{});
Global.exit(1);
}
// Collect script names from positionals + passthrough
// For RunCommand: positionals[0] is "run", skip it. For AutoCommand: no "run" prefix.
var script_names = std.array_list.Managed([]const u8).init(ctx.allocator);
var positionals = ctx.positionals;
if (positionals.len > 0 and (strings.eqlComptime(positionals[0], "run") or strings.eqlComptime(positionals[0], "r"))) {
positionals = positionals[1..];
}
for (positionals) |pos| {
if (pos.len > 0) {
try script_names.append(pos);
}
}
for (ctx.passthrough) |pt| {
if (pt.len > 0) {
try script_names.append(pt);
}
}
if (script_names.items.len == 0) {
Output.prettyErrorln("<r><red>error<r>: --parallel/--sequential requires at least one script name", .{});
Global.exit(1);
}
// Set up the transpiler/environment
const fsinstance = try bun.fs.FileSystem.init(null);
var this_transpiler: transpiler.Transpiler = undefined;
_ = try RunCommand.configureEnvForRun(ctx, &this_transpiler, null, true, false);
const cwd = fsinstance.top_level_dir;
const event_loop = bun.jsc.MiniEventLoop.initGlobal(this_transpiler.env, null);
const shell_bin: [:0]const u8 = if (Environment.isPosix)
RunCommand.findShell(this_transpiler.env.get("PATH") orelse "", cwd) orelse return error.MissingShell
else
bun.selfExePath() catch return error.MissingShell;
// Build ScriptConfigs and ProcessHandles
// Each script name can produce up to 3 handles (pre, main, post)
var configs = std.array_list.Managed(ScriptConfig).init(ctx.allocator);
var group_infos = std.array_list.Managed(GroupInfo).init(ctx.allocator);
if (ctx.filters.len > 0 or ctx.workspaces) {
// Workspace-aware mode: iterate over matching workspace packages
var filters_to_use = ctx.filters;
if (ctx.workspaces) {
filters_to_use = &.{"*"};
}
var filter_instance = try FilterArg.FilterSet.init(ctx.allocator, filters_to_use, cwd);
var patterns = std.array_list.Managed([]u8).init(ctx.allocator);
var root_buf: bun.PathBuffer = undefined;
const resolve_root = try FilterArg.getCandidatePackagePatterns(ctx.allocator, ctx.log, &patterns, cwd, &root_buf);
var package_json_iter = try FilterArg.PackageFilterIterator.init(ctx.allocator, patterns.items, resolve_root);
defer package_json_iter.deinit();
// Phase 1: Collect matching packages (filesystem order is nondeterministic)
const MatchedPackage = struct {
name: []const u8,
dirpath: []const u8,
scripts: *const bun.StringArrayHashMap([]const u8),
PATH: []const u8,
};
var matched_packages = std.array_list.Managed(MatchedPackage).init(ctx.allocator);
while (try package_json_iter.next()) |package_json_path| {
const dirpath = try ctx.allocator.dupe(u8, std.fs.path.dirname(package_json_path) orelse Global.crash());
const path = bun.strings.withoutTrailingSlash(dirpath);
// When using --workspaces, skip the root package to prevent recursion
if (ctx.workspaces and strings.eql(path, resolve_root)) {
continue;
}
const pkgjson = bun.PackageJSON.parse(&this_transpiler.resolver, dirpath, .invalid, null, .include_scripts, .main) orelse {
continue;
};
if (!filter_instance.matches(path, pkgjson.name))
continue;
const pkg_scripts = pkgjson.scripts orelse continue;
const pkg_PATH = try RunCommand.configurePathForRunWithPackageJsonDir(ctx, dirpath, &this_transpiler, null, dirpath, ctx.debug.run_in_bun);
const pkg_name = if (pkgjson.name.len > 0)
pkgjson.name
else
// Fallback: use relative path from workspace root
try ctx.allocator.dupe(u8, bun.path.relativePlatform(resolve_root, path, .posix, false));
try matched_packages.append(.{
.name = pkg_name,
.dirpath = dirpath,
.scripts = pkg_scripts,
.PATH = pkg_PATH,
});
}
// Phase 2: Sort by package name, then by path as tiebreaker for deterministic ordering
std.mem.sort(MatchedPackage, matched_packages.items, {}, struct {
fn lessThan(_: void, a: MatchedPackage, b: MatchedPackage) bool {
const name_order = std.mem.order(u8, a.name, b.name);
if (name_order != .eq) return name_order == .lt;
return std.mem.order(u8, a.dirpath, b.dirpath) == .lt;
}
}.lessThan);
// Phase 3: Build configs from sorted packages
for (matched_packages.items) |pkg| {
for (script_names.items) |raw_name| {
if (std.mem.indexOfScalar(u8, raw_name, '*') != null) {
// Glob: expand against this package's scripts
var matches = std.array_list.Managed([]const u8).init(ctx.allocator);
for (pkg.scripts.keys()) |key| {
if (matchesGlob(raw_name, key)) {
try matches.append(key);
}
}
std.mem.sort([]const u8, matches.items, {}, struct {
fn lessThan(_: void, a: []const u8, b: []const u8) bool {
return std.mem.order(u8, a, b) == .lt;
}
}.lessThan);
for (matches.items) |matched_name| {
try addScriptConfigs(&configs, &group_infos, matched_name, pkg.scripts, ctx.allocator, pkg.dirpath, pkg.PATH, pkg.name);
}
} else {
if (pkg.scripts.get(raw_name) != null) {
try addScriptConfigs(&configs, &group_infos, raw_name, pkg.scripts, ctx.allocator, pkg.dirpath, pkg.PATH, pkg.name);
} else if (ctx.workspaces and !ctx.if_present) {
Output.prettyErrorln("<r><red>error<r>: Missing \"{s}\" script in package \"{s}\"", .{ raw_name, pkg.name });
Global.exit(1);
}
}
}
}
if (configs.items.len == 0) {
if (ctx.if_present) {
Global.exit(0);
}
if (ctx.workspaces) {
Output.prettyErrorln("<r><red>error<r>: No workspace packages have matching scripts", .{});
} else {
Output.prettyErrorln("<r><red>error<r>: No packages matched the filter", .{});
}
Global.exit(1);
}
} else {
// Single-package mode: use the root package.json
const PATH = try RunCommand.configurePathForRunWithPackageJsonDir(ctx, "", &this_transpiler, null, cwd, ctx.debug.run_in_bun);
// Load package.json scripts
const root_dir_info = this_transpiler.resolver.readDirInfo(cwd) catch {
Output.prettyErrorln("<r><red>error<r>: Failed to read directory", .{});
Global.exit(1);
} orelse {
Output.prettyErrorln("<r><red>error<r>: Failed to read directory", .{});
Global.exit(1);
};
const package_json = root_dir_info.enclosing_package_json;
const scripts_map: ?*const bun.StringArrayHashMap([]const u8) = if (package_json) |pkg| pkg.scripts else null;
for (script_names.items) |raw_name| {
// Check if this is a glob pattern
if (std.mem.indexOfScalar(u8, raw_name, '*') != null) {
if (scripts_map) |sm| {
// Collect matching script names
var matches = std.array_list.Managed([]const u8).init(ctx.allocator);
for (sm.keys()) |key| {
if (matchesGlob(raw_name, key)) {
try matches.append(key);
}
}
// Sort alphabetically
std.mem.sort([]const u8, matches.items, {}, struct {
fn lessThan(_: void, a: []const u8, b: []const u8) bool {
return std.mem.order(u8, a, b) == .lt;
}
}.lessThan);
if (matches.items.len == 0) {
Output.prettyErrorln("<r><red>error<r>: No scripts match pattern \"{s}\"", .{raw_name});
Global.exit(1);
}
for (matches.items) |matched_name| {
try addScriptConfigs(&configs, &group_infos, matched_name, scripts_map, ctx.allocator, cwd, PATH, null);
}
} else {
Output.prettyErrorln("<r><red>error<r>: Cannot use glob pattern \"{s}\" without package.json scripts", .{raw_name});
Global.exit(1);
}
} else {
try addScriptConfigs(&configs, &group_infos, raw_name, scripts_map, ctx.allocator, cwd, PATH, null);
}
}
}
if (configs.items.len == 0) {
Output.prettyErrorln("<r><red>error<r>: No scripts to run", .{});
Global.exit(1);
}
// Compute max label width
var max_label_len: usize = 0;
for (configs.items) |*config| {
if (config.label.len > max_label_len) {
max_label_len = config.label.len;
}
}
const use_colors = Output.enable_ansi_colors_stderr;
var state = State{
.handles = try ctx.allocator.alloc(ProcessHandle, configs.items.len),
.event_loop = event_loop,
.max_label_len = max_label_len,
.shell_bin = shell_bin,
.no_exit_on_error = ctx.no_exit_on_error,
.env = this_transpiler.env,
.use_colors = use_colors,
};
// Initialize handles
for (configs.items, 0..) |*config, i| {
// Find which group this belongs to, for color assignment
var color_idx: usize = 0;
for (group_infos.items, 0..) |group, gi| {
if (i >= group.start and i < group.start + group.count) {
color_idx = gi;
break;
}
}
state.handles[i] = ProcessHandle{
.state = &state,
.config = config,
.color_idx = color_idx,
.options = .{
.stdin = .ignore,
.stdout = if (Environment.isPosix) .buffer else .{ .buffer = try bun.default_allocator.create(bun.windows.libuv.Pipe) },
.stderr = if (Environment.isPosix) .buffer else .{ .buffer = try bun.default_allocator.create(bun.windows.libuv.Pipe) },
.cwd = config.cwd,
.windows = if (Environment.isWindows) .{ .loop = bun.jsc.EventLoopHandle.init(event_loop) },
.stream = true,
},
};
}
// Set up pre->main->post chaining within each group
for (group_infos.items) |group| {
if (group.count > 1) {
var j: usize = group.start;
while (j < group.start + group.count - 1) : (j += 1) {
try state.handles[j].group_dependents.append(&state.handles[j + 1]);
state.handles[j + 1].remaining_dependencies += 1;
}
}
}
// For sequential mode, chain groups together
if (ctx.sequential) {
var gi: usize = 0;
while (gi < group_infos.items.len - 1) : (gi += 1) {
const current_group = group_infos.items[gi];
const next_group = group_infos.items[gi + 1];
// Last handle of current group -> first handle of next group
const last_in_current = current_group.start + current_group.count - 1;
const first_in_next = next_group.start;
try state.handles[last_in_current].next_dependents.append(&state.handles[first_in_next]);
state.handles[first_in_next].remaining_dependencies += 1;
}
}
// Start handles with no dependencies
for (state.handles) |*handle| {
if (handle.remaining_dependencies == 0) {
handle.start() catch {
Output.prettyErrorln("<r><red>error<r>: Failed to start process", .{});
Global.exit(1);
};
}
}
AbortHandler.install();
while (!state.isDone()) {
if (AbortHandler.should_abort and !state.aborted) {
AbortHandler.uninstall();
state.abort();
}
event_loop.tickOnce(&state);
}
const status = state.finalize();
Global.exit(status);
}
fn hasRunnableExtension(name: []const u8) bool {
const ext = std.fs.path.extension(name);
const loader = bun.options.defaultLoaders.get(ext) orelse return false;
return loader.canBeRunByBun();
}
const FilterArg = @import("./filter_arg.zig");
const std = @import("std");
const RunCommand = @import("./run_command.zig").RunCommand;
const bun = @import("bun");
const Environment = bun.Environment;
const Global = bun.Global;
const Output = bun.Output;
const strings = bun.strings;
const transpiler = bun.transpiler;
const CLI = bun.cli;
const Command = CLI.Command;

File diff suppressed because it is too large Load Diff