Compare commits

...

7 Commits

Author SHA1 Message Date
Jarred Sumner
6da932dcf9 Merge branch 'main' into claude/implement-workspace-flag 2025-07-14 07:46:58 -07:00
jarred-sumner-bot
35325055fe bun run prettier 2025-07-14 13:15:11 +00:00
jarred-sumner-bot
07af79cbbd bun run zig-format 2025-07-14 13:13:48 +00:00
Claude Bot
e28b8661c2 Enhance --workspace flag to support both file paths and package names
- Add path detection logic to distinguish between file paths and package names
- Support packages without name field in package.json when using file paths
- Add comprehensive tests for file paths, relative paths, and mixed usage
- Ensure backward compatibility with existing package name matching
- All 46 tests now pass, confirming robust functionality

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-07-14 13:10:47 +00:00
jarred-sumner-bot
005c133fc4 bun run prettier 2025-07-14 12:53:55 +00:00
jarred-sumner-bot
b3f91460b9 bun run zig-format 2025-07-14 12:52:31 +00:00
Claude Bot
3c886c7ec8 Implement --workspace flag for bun run command
This adds npm-compatible --workspace functionality to bun run, allowing
users to run scripts in specific workspace packages.

Key features:
- Accepts --workspace/-w flag with package names
- Supports multiple --workspace flags
- Preserves workspace definition order (unlike --filter which uses dependency order)
- Can be combined with --filter flags
- Includes comprehensive test coverage

Example usage:
  bun run --workspace foo test
  bun run -w foo -w bar test
  bun run --workspace foo --filter 'ba*' test

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-07-14 12:50:03 +00:00
5 changed files with 546 additions and 27 deletions

View File

@@ -166,6 +166,38 @@ will execute `<script>` in both `bar` and `baz`, but not in `foo`.
Find more details in the docs page for [filter](https://bun.com/docs/cli/filter#running-scripts-with-filter).
### Workspaces
Similar to npm, you can use the `--workspace` argument to run scripts in specific workspace packages.
Use `bun run --workspace <name> <script>` to execute `<script>` in the workspace package named `<name>`.
```bash
bun run --workspace foo test
```
You can specify multiple workspaces by using the flag multiple times:
```bash
bun run --workspace foo --workspace bar test
```
Or use the short flag `-w`:
```bash
bun run -w foo -w bar test
```
Unlike `--filter`, the `--workspace` flag requires exact package name matches and executes scripts in the order they appear in your `package.json` workspaces configuration, not in dependency order.
You can also combine `--workspace` with `--filter`:
```bash
bun run --workspace foo --filter 'ba*' test
```
This will run the `test` script in both the `foo` workspace (exact match) and any packages matching `ba*` (pattern match).
## `bun run -` to pipe code from stdin
`bun run -` lets you read JavaScript, TypeScript, TSX, or JSX from stdin and execute it without writing to a temporary file first.

View File

@@ -411,6 +411,7 @@ pub const Command = struct {
runtime_options: RuntimeOptions = .{},
filters: []const []const u8 = &.{},
workspaces: []const []const u8 = &.{},
preloads: []const string = &.{},
has_loaded_global_config: bool = false,
@@ -1083,7 +1084,7 @@ pub const Command = struct {
const ctx = try Command.init(allocator, log, .RunCommand);
ctx.args.target = .bun;
if (ctx.filters.len > 0) {
if (ctx.filters.len > 0 or ctx.workspaces.len > 0) {
FilterRun.runScriptsWithFilter(ctx) catch |err| {
Output.prettyErrorln("<r><red>error<r>: {s}", .{@errorName(err)});
Global.exit(1);

View File

@@ -113,6 +113,7 @@ pub const runtime_params_ = [_]ParamType{
pub const auto_or_run_params = [_]ParamType{
clap.parseParam("-F, --filter <STR>... Run a script in all workspace packages matching the pattern") catch unreachable,
clap.parseParam("-w, --workspace <STR>... Run a script in the specified workspace packages") 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,
};
@@ -380,6 +381,7 @@ pub fn parse(allocator: std.mem.Allocator, ctx: Command.Context, comptime cmd: C
if (cmd == .RunCommand or cmd == .AutoCommand) {
ctx.filters = args.options("--filter");
ctx.workspaces = args.options("--workspace");
if (args.option("--elide-lines")) |elide_lines| {
if (elide_lines.len > 0) {

View File

@@ -475,8 +475,49 @@ pub fn runScriptsWithFilter(ctx: Command.Context) !noreturn {
const pkgscripts = pkgjson.scripts orelse continue;
if (!filter_instance.matches(path, pkgjson.name))
// Check if package matches either --filter or --workspace criteria
const matches_filter = ctx.filters.len > 0 and filter_instance.matches(path, pkgjson.name);
const matches_workspace = ctx.workspaces.len > 0 and blk: {
for (ctx.workspaces) |workspace| {
// Determine if workspace pattern is a path or name
const is_path = workspace.len > 0 and workspace[0] == '.';
if (is_path) {
// For path patterns, try to match against the package directory path
// Convert workspace pattern to absolute path for comparison
var path_buf: bun.PathBuffer = undefined;
const parts = [_][]const u8{workspace};
const abs_workspace_path = bun.path.joinAbsStringBuf(fsinstance.top_level_dir, &path_buf, &parts, .loose);
// Normalize both paths for comparison
const normalized_workspace = bun.strings.withoutTrailingSlash(abs_workspace_path);
const normalized_path = bun.strings.withoutTrailingSlash(path);
if (bun.strings.eql(normalized_workspace, normalized_path)) {
break :blk true;
}
} else {
// For name patterns, match against package name
if (bun.strings.eql(workspace, pkgjson.name)) {
break :blk true;
}
}
}
break :blk false;
};
if (ctx.filters.len > 0 and ctx.workspaces.len > 0) {
// Both --filter and --workspace specified: package must match at least one
if (!matches_filter and !matches_workspace) continue;
} else if (ctx.filters.len > 0) {
// Only --filter specified
if (!matches_filter) continue;
} else if (ctx.workspaces.len > 0) {
// Only --workspace specified
if (!matches_workspace) continue;
} else {
// Neither specified (shouldn't happen, but handle gracefully)
continue;
}
const PATH = try RunCommand.configurePathForRunWithPackageJsonDir(ctx, dirpath, &this_transpiler, null, dirpath, ctx.debug.run_in_bun);
@@ -565,38 +606,44 @@ pub fn runScriptsWithFilter(ctx: Command.Context) !noreturn {
// &state.handles[i];
}
}
// compute dependencies (TODO: maybe we should do this only in a workspace?)
for (state.handles) |*handle| {
var iter = handle.config.deps.map.iterator();
while (iter.next()) |entry| {
var sfa = std.heap.stackFallback(256, ctx.allocator);
const alloc = sfa.get();
const buf = try alloc.alloc(u8, entry.key_ptr.len());
defer alloc.free(buf);
const name = entry.key_ptr.slice(buf);
// is it a workspace dependency?
if (map.get(name)) |pkgs| {
for (pkgs.items) |dep| {
try dep.dependents.append(handle);
handle.remaining_dependencies += 1;
// compute dependencies
// Skip dependency order computation for --workspace to preserve workspace definition order
const is_workspace_mode = ctx.workspaces.len > 0 and ctx.filters.len == 0;
if (!is_workspace_mode) {
for (state.handles) |*handle| {
var iter = handle.config.deps.map.iterator();
while (iter.next()) |entry| {
var sfa = std.heap.stackFallback(256, ctx.allocator);
const alloc = sfa.get();
const buf = try alloc.alloc(u8, entry.key_ptr.len());
defer alloc.free(buf);
const name = entry.key_ptr.slice(buf);
// is it a workspace dependency?
if (map.get(name)) |pkgs| {
for (pkgs.items) |dep| {
try dep.dependents.append(handle);
handle.remaining_dependencies += 1;
}
}
}
}
}
// check if there is a dependency cycle
// check if there is a dependency cycle (skip in workspace mode)
var has_cycle = false;
for (state.handles) |*handle| {
if (hasCycle(handle)) {
has_cycle = true;
break;
}
}
// if there is, we ignore dependency order completely
if (has_cycle) {
if (!is_workspace_mode) {
for (state.handles) |*handle| {
handle.dependents.clearRetainingCapacity();
handle.remaining_dependencies = 0;
if (hasCycle(handle)) {
has_cycle = true;
break;
}
}
// if there is, we ignore dependency order completely
if (has_cycle) {
for (state.handles) |*handle| {
handle.dependents.clearRetainingCapacity();
handle.remaining_dependencies = 0;
}
}
}
@@ -609,6 +656,17 @@ pub fn runScriptsWithFilter(ctx: Command.Context) !noreturn {
}
}
// In workspace mode, serialize execution to preserve workspace definition order
if (is_workspace_mode) {
for (0..state.handles.len - 1) |i| {
// Only create dependencies between different packages, not within the same package
if (!bun.strings.eql(state.handles[i].config.package_name, state.handles[i + 1].config.package_name)) {
try state.handles[i].dependents.append(&state.handles[i + 1]);
state.handles[i + 1].remaining_dependencies += 1;
}
}
}
// start inital scripts
for (state.handles) |*handle| {
if (handle.remaining_dependencies == 0) {

View File

@@ -0,0 +1,426 @@
import { spawnSync } from "bun";
import { describe, expect, test } from "bun:test";
import { bunEnv, bunExe, tempDirWithFiles } from "harness";
import { join } from "path";
const cwd_root = tempDirWithFiles("testworkspace", {
packages: {
pkga: {
"index.js": "console.log('pkga');",
"package.json": JSON.stringify({
name: "pkga",
scripts: {
present: "echo scripta",
test: "echo testa",
},
}),
},
pkgb: {
"index.js": "console.log('pkgb');",
"package.json": JSON.stringify({
name: "pkgb",
scripts: {
present: "echo scriptb",
test: "echo testb",
},
}),
},
pkgc: {
"index.js": "console.log('pkgc');",
"package.json": JSON.stringify({
name: "pkgc",
scripts: {
present: "echo scriptc",
test: "echo testc",
},
}),
},
scoped: {
"index.js": "console.log('scoped');",
"package.json": JSON.stringify({
name: "@scoped/scoped",
scripts: {
present: "echo scriptd",
test: "echo testd",
},
}),
},
},
"package.json": JSON.stringify({
name: "ws",
scripts: {
present: "echo rootscript",
},
workspaces: ["packages/pkga", "packages/pkgb", "packages/pkgc", "packages/scoped"],
}),
});
const cwd_packages = join(cwd_root, "packages");
const cwd_a = join(cwd_packages, "pkga");
const cwd_b = join(cwd_packages, "pkgb");
const cwd_c = join(cwd_packages, "pkgc");
const cwd_d = join(cwd_packages, "scoped");
function runWithWorkspaceSuccess({
cwd,
workspace,
target_pattern,
antipattern,
command = ["present"],
env = {},
}: {
cwd: string;
workspace: string | string[];
target_pattern: RegExp | RegExp[];
antipattern?: RegExp | RegExp[];
command?: string[];
env?: Record<string, string | undefined>;
}) {
const cmd = [bunExe(), "run"];
if (Array.isArray(workspace)) {
for (const w of workspace) {
cmd.push("--workspace", w);
}
} else {
cmd.push("-w", workspace);
}
for (const c of command) {
cmd.push(c);
}
const { exitCode, stdout, stderr } = spawnSync({
cwd,
cmd,
env: { ...bunEnv, ...env },
stdout: "pipe",
stderr: "pipe",
});
const stdoutval = stdout.toString();
for (const r of Array.isArray(target_pattern) ? target_pattern : [target_pattern]) {
expect(stdoutval).toMatch(r);
}
if (antipattern !== undefined) {
for (const r of Array.isArray(antipattern) ? antipattern : [antipattern]) {
expect(stdoutval).not.toMatch(r);
}
}
expect(stderr.toString()).toBeEmpty();
expect(exitCode).toBe(0);
}
function runWithWorkspaceFailure(cwd: string, workspace: string, scriptname: string, result: RegExp) {
const { exitCode, stdout, stderr } = spawnSync({
cwd: cwd,
cmd: [bunExe(), "run", "--workspace", workspace, scriptname],
env: bunEnv,
stdout: "pipe",
stderr: "pipe",
});
expect(stdout.toString()).toBeEmpty();
expect(stderr.toString()).toMatch(result);
expect(exitCode).not.toBe(0);
}
describe("bun run --workspace", () => {
const dirs = [cwd_root, cwd_packages, cwd_a, cwd_b, cwd_c, cwd_d];
const packages = [
{
name: "pkga",
output: /scripta/,
},
{
name: "pkgb",
output: /scriptb/,
},
{
name: "pkgc",
output: /scriptc/,
},
{
name: "@scoped/scoped",
output: /scriptd/,
},
];
const names = packages.map(p => p.name);
for (const d of dirs) {
for (const { name, output } of packages) {
test(`resolve ${name} from ${d}`, () => {
runWithWorkspaceSuccess({ cwd: d, workspace: name, target_pattern: output });
});
}
}
for (const d of dirs) {
test(`resolve all workspaces from ${d}`, () => {
runWithWorkspaceSuccess({
cwd: d,
workspace: names,
target_pattern: [/scripta/, /scriptb/, /scriptc/, /scriptd/],
});
});
}
test("workspace ordering follows package.json order", () => {
// Test that workspaces are executed in the order they appear in package.json
const { exitCode, stdout, stderr } = spawnSync({
cwd: cwd_root,
cmd: [bunExe(), "run", "--workspace", "pkga", "--workspace", "pkgb", "--workspace", "pkgc", "present"],
env: bunEnv,
stdout: "pipe",
stderr: "pipe",
});
expect(exitCode).toBe(0);
expect(stderr.toString()).toBeEmpty();
const output = stdout.toString();
// Check that the order matches workspace definition order
const indexA = output.indexOf("scripta");
const indexB = output.indexOf("scriptb");
const indexC = output.indexOf("scriptc");
expect(indexA).toBeLessThan(indexB);
expect(indexB).toBeLessThan(indexC);
});
test("workspace filtering is exact match", () => {
runWithWorkspaceSuccess({
cwd: cwd_root,
workspace: "pkga",
target_pattern: /scripta/,
antipattern: [/scriptb/, /scriptc/, /scriptd/],
});
});
test("non-existent workspace fails", () => {
runWithWorkspaceFailure(cwd_root, "nonexistent", "present", /No packages matched the filter/);
});
test("multiple workspaces", () => {
runWithWorkspaceSuccess({
cwd: cwd_root,
workspace: ["pkga", "pkgc"],
target_pattern: [/scripta/, /scriptc/],
antipattern: [/scriptb/, /scriptd/],
});
});
test("scoped packages work", () => {
runWithWorkspaceSuccess({
cwd: cwd_root,
workspace: "@scoped/scoped",
target_pattern: /scriptd/,
antipattern: [/scripta/, /scriptb/, /scriptc/],
});
});
test("different script names work", () => {
runWithWorkspaceSuccess({
cwd: cwd_root,
workspace: "pkga",
target_pattern: /testa/,
command: ["test"],
});
});
test("workspace with missing script fails gracefully", () => {
runWithWorkspaceFailure(cwd_root, "pkga", "nonexistent", /No packages matched the filter/);
});
test("combine --workspace with --filter", () => {
// Both --workspace and --filter should work together
const { exitCode, stdout, stderr } = spawnSync({
cwd: cwd_root,
cmd: [bunExe(), "run", "--workspace", "pkga", "--filter", "pkgb", "present"],
env: bunEnv,
stdout: "pipe",
stderr: "pipe",
});
expect(exitCode).toBe(0);
expect(stderr.toString()).toBeEmpty();
const output = stdout.toString();
// Should run both pkga (via --workspace) and pkgb (via --filter)
expect(output).toMatch(/scripta/);
expect(output).toMatch(/scriptb/);
});
test("short flag -w works", () => {
runWithWorkspaceSuccess({
cwd: cwd_root,
workspace: "pkga",
target_pattern: /scripta/,
antipattern: [/scriptb/, /scriptc/, /scriptd/],
});
});
test("multiple -w flags work", () => {
const { exitCode, stdout, stderr } = spawnSync({
cwd: cwd_root,
cmd: [bunExe(), "run", "-w", "pkga", "-w", "pkgb", "present"],
env: bunEnv,
stdout: "pipe",
stderr: "pipe",
});
expect(exitCode).toBe(0);
expect(stderr.toString()).toBeEmpty();
const output = stdout.toString();
expect(output).toMatch(/scripta/);
expect(output).toMatch(/scriptb/);
});
test("workspace supports file paths", () => {
runWithWorkspaceSuccess({
cwd: cwd_root,
workspace: "./packages/pkga",
target_pattern: /scripta/,
antipattern: [/scriptb/, /scriptc/, /scriptd/],
});
});
test("workspace supports relative paths", () => {
runWithWorkspaceSuccess({
cwd: cwd_packages,
workspace: "./pkgb",
target_pattern: /scriptb/,
antipattern: [/scripta/, /scriptc/, /scriptd/],
});
});
test("can mix file paths and package names", () => {
runWithWorkspaceSuccess({
cwd: cwd_root,
workspace: ["./packages/pkga", "pkgb"],
target_pattern: [/scripta/, /scriptb/],
antipattern: [/scriptc/, /scriptd/],
});
});
});
describe("workspace without package names", () => {
// Test workspace functionality with packages that don't have name field
const cwd_no_names = tempDirWithFiles("testworkspace-no-names", {
packages: {
unnamed1: {
"package.json": JSON.stringify({
// No name field
scripts: {
present: "echo unnamed1-script",
},
}),
},
unnamed2: {
"package.json": JSON.stringify({
// No name field
scripts: {
present: "echo unnamed2-script",
},
}),
},
},
"package.json": JSON.stringify({
name: "ws",
workspaces: ["packages/unnamed1", "packages/unnamed2"],
}),
});
test("workspace works with packages without name field using paths", () => {
const { exitCode, stdout, stderr } = spawnSync({
cwd: cwd_no_names,
cmd: [bunExe(), "run", "--workspace", "./packages/unnamed1", "present"],
env: bunEnv,
stdout: "pipe",
stderr: "pipe",
});
expect(exitCode).toBe(0);
expect(stderr.toString()).toBeEmpty();
const output = stdout.toString();
expect(output).toMatch(/unnamed1-script/);
expect(output).not.toMatch(/unnamed2-script/);
});
test("workspace works with multiple unnamed packages using paths", () => {
const { exitCode, stdout, stderr } = spawnSync({
cwd: cwd_no_names,
cmd: [bunExe(), "run", "--workspace", "./packages/unnamed1", "--workspace", "./packages/unnamed2", "present"],
env: bunEnv,
stdout: "pipe",
stderr: "pipe",
});
expect(exitCode).toBe(0);
expect(stderr.toString()).toBeEmpty();
const output = stdout.toString();
expect(output).toMatch(/unnamed1-script/);
expect(output).toMatch(/unnamed2-script/);
});
});
describe("workspace ordering test", () => {
// Test with a different workspace order to ensure it's preserved
const cwd_reordered = tempDirWithFiles("testworkspace-reordered", {
packages: {
pkga: {
"package.json": JSON.stringify({
name: "pkga",
scripts: {
present: "echo scripta",
},
}),
},
pkgb: {
"package.json": JSON.stringify({
name: "pkgb",
scripts: {
present: "echo scriptb",
},
}),
},
pkgc: {
"package.json": JSON.stringify({
name: "pkgc",
scripts: {
present: "echo scriptc",
},
}),
},
},
"package.json": JSON.stringify({
name: "ws",
workspaces: ["packages/pkgc", "packages/pkga", "packages/pkgb"], // Different order
}),
});
test("workspace ordering follows package.json order (reordered)", () => {
const { exitCode, stdout, stderr } = spawnSync({
cwd: cwd_reordered,
cmd: [bunExe(), "run", "--workspace", "pkga", "--workspace", "pkgb", "--workspace", "pkgc", "present"],
env: bunEnv,
stdout: "pipe",
stderr: "pipe",
});
expect(exitCode).toBe(0);
expect(stderr.toString()).toBeEmpty();
const output = stdout.toString();
// Check that the order matches workspace definition order (c, a, b)
const indexA = output.indexOf("scripta");
const indexB = output.indexOf("scriptb");
const indexC = output.indexOf("scriptc");
// In this test, the workspace order is pkgc, pkga, pkgb
// So we should see c before a, and a before b
expect(indexC).toBeLessThan(indexA);
expect(indexA).toBeLessThan(indexB);
});
});