Add --stream flag for workspace script execution

Implements a --stream flag for `bun run` that streams logs immediately without
buffering or eliding when running scripts across workspace packages. This is
similar to pnpm's --stream flag and addresses user feedback about log display
in workspace environments.

Changes:
- Add --stream flag to CLI parameters for both run and auto commands
- Implement streaming output in filter_run.zig that bypasses buffering
- Add validation to prevent using --stream with --elide-lines
- Add comprehensive test coverage for the new flag
- Update documentation in filter.md and run.md

The flag provides immediate, unbuffered output which is useful for:
- Real-time monitoring of long-running processes
- CI/CD environments where immediate feedback is important
- Debugging scripts that may hang or have timing issues

Fixes the issue where workspace script output was being buffered and elided
by default, making it difficult to see logs in real-time.

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

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Claude Bot
2025-09-07 08:06:37 +00:00
parent 2daf7ed02e
commit 690ca26414
6 changed files with 277 additions and 2 deletions

View File

@@ -71,6 +71,33 @@ bun --filter '*' dev
Both commands will be run in parallel, and you will see a nice terminal UI showing their respective outputs:
![Terminal Output](https://github.com/oven-sh/bun/assets/48869301/2a103e42-9921-4c33-948f-a1ad6e6bac71)
### Output options
By default, when running scripts with `--filter` in a terminal, Bun displays a formatted UI that elides long output to keep the display manageable. You can control this behavior with these flags:
#### `--elide-lines <number>`
Controls how many lines of output are shown for each package (default: 10). Set to 0 to show all output:
```bash
# Show only 5 lines of output per package
bun --filter '*' --elide-lines 5 test
# Show all output (no eliding)
bun --filter '*' --elide-lines 0 test
```
#### `--stream`
Stream logs immediately without buffering or eliding. This is useful when you want to see real-time output from all packages, similar to pnpm's `--stream` flag:
```bash
# Stream all output immediately
bun --filter '*' --stream dev
```
Note: `--stream` and `--elide-lines` cannot be used together. When `--stream` is enabled, output is written directly to stdout without package name prefixes or formatting.
### Running scripts in workspaces
Filters respect your [workspace configuration](https://bun.com/docs/install/workspaces): If you have a `package.json` file that specifies which packages are part of the workspace,

View File

@@ -164,6 +164,10 @@ bun run --filter 'ba*' <script>
will execute `<script>` in both `bar` and `baz`, but not in `foo`.
When using `--filter`, you can control the output display with:
- `--elide-lines <number>`: Limit the number of output lines shown per package (default: 10, use 0 for no limit)
- `--stream`: Stream output immediately without buffering or formatting (similar to pnpm's `--stream`)
Find more details in the docs page for [filter](https://bun.com/docs/cli/filter#running-scripts-with-filter).
## `bun run -` to pipe code from stdin

View File

@@ -419,6 +419,7 @@ pub const Command = struct {
env_behavior: api.DotEnvBehavior = .disable,
env_prefix: []const u8 = "",
elide_lines: ?usize = null,
stream_logs: bool = false,
// Compile options
compile: bool = false,
compile_target: Cli.CompileTarget = .{},

View File

@@ -123,6 +123,7 @@ pub const auto_only_params = [_]ParamType{
// clap.parseParam("--all") catch unreachable,
clap.parseParam("--silent Don't print the script command") catch unreachable,
clap.parseParam("--elide-lines <NUMBER> Number of lines of script output shown when using --filter (default: 10). Set to 0 to show all lines.") catch unreachable,
clap.parseParam("--stream Stream logs immediately without buffering or eliding. Similar to pnpm's --stream flag") catch unreachable,
clap.parseParam("-v, --version Print version and exit") catch unreachable,
clap.parseParam("--revision Print version with revision and exit") catch unreachable,
} ++ auto_or_run_params;
@@ -131,6 +132,7 @@ pub const auto_params = auto_only_params ++ runtime_params_ ++ transpiler_params
pub const run_only_params = [_]ParamType{
clap.parseParam("--silent Don't print the script command") catch unreachable,
clap.parseParam("--elide-lines <NUMBER> Number of lines of script output shown when using --filter (default: 10). Set to 0 to show all lines.") catch unreachable,
clap.parseParam("--stream Stream logs immediately without buffering or eliding. Similar to pnpm's --stream flag") catch unreachable,
} ++ auto_or_run_params;
pub const run_params = run_only_params ++ runtime_params_ ++ transpiler_params_ ++ base_params_;
@@ -399,6 +401,8 @@ pub fn parse(allocator: std.mem.Allocator, ctx: Command.Context, comptime cmd: C
};
}
}
ctx.bundler_options.stream_logs = args.flag("--stream");
}
if (cmd == .TestCommand) {
@@ -1140,6 +1144,8 @@ pub fn parse(allocator: std.mem.Allocator, ctx: Command.Context, comptime cmd: C
};
}
}
ctx.bundler_options.stream_logs = args.flag("--stream");
if (opts.define) |define| {
if (define.keys.len > 0)

View File

@@ -146,6 +146,7 @@ const State = struct {
draw_buf: std.ArrayList(u8) = std.ArrayList(u8).init(bun.default_allocator),
last_lines_written: usize = 0,
pretty_output: bool,
stream_logs: bool,
shell_bin: [:0]const u8,
aborted: bool = false,
env: *bun.DotEnv.Loader,
@@ -160,7 +161,11 @@ const State = struct {
};
fn readChunk(this: *This, handle: *ProcessHandle, chunk: []const u8) !void {
if (this.pretty_output) {
if (this.stream_logs) {
// In stream mode, output immediately without buffering
const stdout = std.io.getStdOut();
stdout.writeAll(chunk) catch {};
} else if (this.pretty_output) {
bun.handleOom(handle.buffer.appendSlice(chunk));
this.redraw(false) catch {};
} else {
@@ -202,7 +207,10 @@ const State = struct {
}
}
}
if (this.pretty_output) {
if (this.stream_logs) {
// In stream mode, don't do any special exit handling for output
// The output has already been streamed
} else if (this.pretty_output) {
this.redraw(false) catch {};
} else {
this.draw_buf.clearRetainingCapacity();
@@ -542,10 +550,22 @@ pub fn runScriptsWithFilter(ctx: Command.Context) !noreturn {
.handles = try ctx.allocator.alloc(ProcessHandle, scripts.items.len),
.event_loop = event_loop,
.pretty_output = if (Environment.isWindows) windowsIsTerminal() and Output.enable_ansi_colors_stdout else Output.enable_ansi_colors_stdout,
.stream_logs = ctx.bundler_options.stream_logs,
.shell_bin = shell_bin,
.env = this_transpiler.env,
};
// Check if both --stream and --elide-lines are used
if (state.stream_logs and ctx.bundler_options.elide_lines != null) {
Output.prettyErrorln("<r><red>error<r>: --stream and --elide-lines cannot be used together", .{});
Global.exit(1);
}
// When --stream is set, disable pretty output
if (state.stream_logs) {
state.pretty_output = false;
}
// Check if elide-lines is used in a non-terminal environment
if (ctx.bundler_options.elide_lines != null and !state.pretty_output) {
Output.prettyErrorln("<r><red>error<r>: --elide-lines is only supported in terminal environments", .{});

View File

@@ -506,4 +506,221 @@ describe("bun", () => {
win32ExpectedError: /--elide-lines is only supported in terminal environments/,
});
});
describe("--stream flag", () => {
test("streams output immediately without buffering", () => {
const dir = tempDirWithFiles("testworkspace-stream", {
packages: {
pkg1: {
"index.js": `console.log('pkg1-line1'); console.log('pkg1-line2'); console.log('pkg1-line3');`,
"package.json": JSON.stringify({
name: "pkg1",
scripts: {
test: `${bunExe()} run index.js`,
},
}),
},
pkg2: {
"index.js": `console.log('pkg2-line1'); console.log('pkg2-line2'); console.log('pkg2-line3');`,
"package.json": JSON.stringify({
name: "pkg2",
scripts: {
test: `${bunExe()} run index.js`,
},
}),
},
},
"package.json": JSON.stringify({
name: "ws",
workspaces: ["packages/*"],
}),
});
const { exitCode, stdout, stderr } = spawnSync({
cwd: dir,
cmd: [bunExe(), "run", "--filter", "*", "--stream", "test"],
env: bunEnv,
stdout: "pipe",
stderr: "pipe",
});
const output = stdout.toString();
// In stream mode, output should not have package name prefixes
expect(output).toMatch(/pkg1-line1/);
expect(output).toMatch(/pkg1-line2/);
expect(output).toMatch(/pkg1-line3/);
expect(output).toMatch(/pkg2-line1/);
expect(output).toMatch(/pkg2-line2/);
expect(output).toMatch(/pkg2-line3/);
// Should not have the package name prefixes that appear in non-stream mode
expect(output).not.toMatch(/pkg1 test:/);
expect(output).not.toMatch(/pkg2 test:/);
expect(exitCode).toBe(0);
});
test("--stream and --elide-lines cannot be used together", () => {
const dir = tempDirWithFiles("testworkspace-stream-conflict", {
packages: {
pkg1: {
"package.json": JSON.stringify({
name: "pkg1",
scripts: {
test: "echo test",
},
}),
},
},
"package.json": JSON.stringify({
name: "ws",
workspaces: ["packages/*"],
}),
});
const { exitCode, stderr } = spawnSync({
cwd: dir,
cmd: [bunExe(), "run", "--filter", "*", "--stream", "--elide-lines", "5", "test"],
env: bunEnv,
stdout: "pipe",
stderr: "pipe",
});
expect(stderr.toString()).toMatch(/--stream and --elide-lines cannot be used together/);
expect(exitCode).not.toBe(0);
});
test("--stream works with dependency ordering", () => {
const dir = tempDirWithFiles("testworkspace-stream-deps", {
dep0: {
"index.js": `await Bun.write('out.txt', 'dep0-complete'); console.log('dep0-output');`,
"package.json": JSON.stringify({
name: "dep0",
scripts: {
script: `${bunExe()} run index.js`,
},
}),
},
dep1: {
"index.js": `const content = await Bun.file("../dep0/out.txt").text(); console.log('dep1-output: ' + content);`,
"package.json": JSON.stringify({
name: "dep1",
dependencies: {
dep0: "*",
},
scripts: {
script: `${bunExe()} run index.js`,
},
}),
},
});
const { exitCode, stdout } = spawnSync({
cwd: dir,
cmd: [bunExe(), "run", "--filter", "*", "--stream", "script"],
env: bunEnv,
stdout: "pipe",
stderr: "pipe",
});
const output = stdout.toString();
// Verify that dep0 runs before dep1 and outputs are streamed
expect(output).toMatch(/dep0-output/);
expect(output).toMatch(/dep1-output: dep0-complete/);
expect(exitCode).toBe(0);
});
test("--stream works with pre and post scripts", () => {
const dir = tempDirWithFiles("testworkspace-stream-lifecycle", {
pkg1: {
"package.json": JSON.stringify({
name: "pkg1",
scripts: {
prescript: "echo pre-script",
script: "echo main-script",
postscript: "echo post-script",
},
}),
},
});
const { exitCode, stdout } = spawnSync({
cwd: dir,
cmd: [bunExe(), "run", "--filter", "*", "--stream", "script"],
env: bunEnv,
stdout: "pipe",
stderr: "pipe",
});
const output = stdout.toString();
// All outputs should be streamed in order
expect(output).toMatch(/pre-script[\s\S]*main-script[\s\S]*post-script/);
expect(exitCode).toBe(0);
});
test("--stream handles errors correctly", () => {
const dir = tempDirWithFiles("testworkspace-stream-error", {
pkg1: {
"package.json": JSON.stringify({
name: "pkg1",
scripts: {
test: "echo before-error && exit 42",
},
}),
},
pkg2: {
"package.json": JSON.stringify({
name: "pkg2",
scripts: {
test: "echo pkg2-runs",
},
}),
},
});
const { exitCode, stdout } = spawnSync({
cwd: dir,
cmd: [bunExe(), "run", "--filter", "*", "--stream", "test"],
env: bunEnv,
stdout: "pipe",
stderr: "pipe",
});
const output = stdout.toString();
// Both should run even if one fails
expect(output).toMatch(/before-error/);
expect(output).toMatch(/pkg2-runs/);
// Should exit with the error code from the failed script
expect(exitCode).toBe(42);
});
test("--stream works with auto command", () => {
const dir = tempDirWithFiles("testworkspace-stream-auto", {
packages: {
pkg1: {
"package.json": JSON.stringify({
name: "pkg1",
scripts: {
test: "echo auto-stream-test",
},
}),
},
},
"package.json": JSON.stringify({
name: "ws",
workspaces: ["packages/*"],
}),
});
const { exitCode, stdout } = spawnSync({
cwd: dir,
cmd: [bunExe(), "--filter", "*", "--stream", "test"],
env: bunEnv,
stdout: "pipe",
stderr: "pipe",
});
const output = stdout.toString();
expect(output).toMatch(/auto-stream-test/);
expect(exitCode).toBe(0);
});
});
});