diff --git a/CMakeLists.txt b/CMakeLists.txt index b64e7b4c48..d179dbf0d6 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -2,7 +2,7 @@ cmake_minimum_required(VERSION 3.22) cmake_policy(SET CMP0091 NEW) cmake_policy(SET CMP0067 NEW) -set(Bun_VERSION "1.0.31") +set(Bun_VERSION "1.0.32") set(WEBKIT_TAG 089023cc9078b3aa173869fd6685f3e7bed2a994) if(APPLE AND DEFINED ENV{CI}) diff --git a/docs/runtime/bunfig.md b/docs/runtime/bunfig.md index 8d01ce7744..d2fc618197 100644 --- a/docs/runtime/bunfig.md +++ b/docs/runtime/bunfig.md @@ -426,4 +426,56 @@ editor = "code" # - "nvim", "neovim" # - "vim","vi" # - "emacs" -``` --> +``` +--> + +## `bun run` + +The `bun run` command can be configured under the `[run]` section. These apply to the `bun run` command and the `bun` command when running a file or executable or script. + +### `run.shell`: `"system" | "bun"` + +The shell to use when running package.json scripts via `bun run` or `bun`. On Windows, this defaults to `"bun"` and on other platforms it defaults to `"system"`. + +Always use the system shell instead of Bun's shell (default behavior unless Windows): + +```toml + +[run] +shell = "system" +``` + +Always use Bun's shell instead of the system shell: + +```toml +[run] +shell = "bun" +``` + +### `run.bun`: `boolean` + +When `true`, this adds a `node` symlink to `$PATH` which points to the `bun` binary for all scripts or executables invoked by `bun run` or `bun`. + +By default, this is enabled if `node` is not already in your `$PATH`. + +```toml +[run] +bun = true +``` + +You can test this by running: + +```sh +$ bun --bun which node # /path/to/bun +$ bun which node # /path/to/node +``` + +This option is equivalent to prefixing all `bun run` commands with `--bun`: + +```sh +bun --bun run dev +bun --bun dev +bun run --bun dev +``` + +If set to `false`, this will disable the `node` symlink. diff --git a/packages/bun-types/bun.d.ts b/packages/bun-types/bun.d.ts index 5e32060423..418c7f3b6c 100644 --- a/packages/bun-types/bun.d.ts +++ b/packages/bun-types/bun.d.ts @@ -4483,7 +4483,7 @@ declare module "bun" { */ cmd: string[]; - onExit: never; + onExit?: never; }, ): SpawnOptions.OptionsToSyncSubprocess; diff --git a/src/bun_js.zig b/src/bun_js.zig index a9b973a5a7..690eea456a 100644 --- a/src/bun_js.zig +++ b/src/bun_js.zig @@ -123,7 +123,7 @@ pub const Run = struct { vm.global.vm().holdAPILock(&run, callback); } - fn bootBunShell(ctx: *const Command.Context, entry_path: []const u8) !void { + fn bootBunShell(ctx: *const Command.Context, entry_path: []const u8) !bun.shell.ExitCode { @setCold(true); // this is a hack: make dummy bundler so we can use its `.runEnvLoader()` function to populate environment variables probably should split out the functionality @@ -136,8 +136,7 @@ pub const Run = struct { try bundle.runEnvLoader(); const mini = JSC.MiniEventLoop.initGlobal(bundle.env); mini.top_level_dir = ctx.args.absolute_working_dir orelse ""; - try bun.shell.Interpreter.initAndRunFromFile(mini, entry_path); - return; + return try bun.shell.Interpreter.initAndRunFromFile(mini, entry_path); } pub fn boot(ctx_: Command.Context, entry_path: string) !void { @@ -146,8 +145,8 @@ pub const Run = struct { bun.JSC.initialize(); if (strings.endsWithComptime(entry_path, ".bun.sh")) { - try bootBunShell(&ctx, entry_path); - Global.exit(0); + const exit_code = try bootBunShell(&ctx, entry_path); + Global.exitWide(exit_code); return; } diff --git a/src/bunfig.zig b/src/bunfig.zig index 3f68777df0..b5004da3c1 100644 --- a/src/bunfig.zig +++ b/src/bunfig.zig @@ -506,6 +506,38 @@ pub const Bunfig = struct { } } } + + if (json.get("run")) |run_expr| { + if (run_expr.get("silent")) |silent| { + if (silent.asBool()) |value| { + this.ctx.debug.silent = value; + } else { + try this.addError(silent.loc, "Expected boolean"); + } + } + + if (run_expr.get("shell")) |shell| { + if (shell.asString(allocator)) |value| { + if (strings.eqlComptime(value, "bun")) { + this.ctx.debug.use_system_shell = false; + } else if (strings.eqlComptime(value, "system")) { + this.ctx.debug.use_system_shell = true; + } else { + try this.addError(shell.loc, "Invalid shell, only 'bun' and 'system' are supported"); + } + } else { + try this.addError(shell.loc, "Expected string"); + } + } + + if (run_expr.get("bun")) |bun_flag| { + if (bun_flag.asBool()) |value| { + this.ctx.debug.run_in_bun = value; + } else { + try this.addError(bun_flag.loc, "Expected boolean"); + } + } + } } if (json.get("bundle")) |_bun| { diff --git a/src/cli.zig b/src/cli.zig index 4edbd5dd2f..51af9f68d8 100644 --- a/src/cli.zig +++ b/src/cli.zig @@ -182,23 +182,22 @@ pub const Arguments = struct { clap.parseParam("--conditions ... Pass custom conditions to resolve") catch unreachable, }; + 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("--shell Control the shell used for package.json scripts. Supports either 'bun' or 'system'") catch unreachable, + }; + const auto_only_params = [_]ParamType{ // clap.parseParam("--all") 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("--silent Don't print the script command") 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; const auto_params = auto_only_params ++ runtime_params_ ++ transpiler_params_ ++ base_params_; const run_only_params = [_]ParamType{ clap.parseParam("--silent Don't print the script command") 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, - } ++ if (Environment.isWindows) [_]ParamType{ - clap.parseParam("--system-shell Use cmd.exe to interpret package.json scripts") catch unreachable, - } else .{ - clap.parseParam("--bun-shell Use Bun Shell to interpret package.json scripts") catch unreachable, - }; + } ++ auto_or_run_params; pub const run_params = run_only_params ++ runtime_params_ ++ transpiler_params_ ++ base_params_; const bunx_commands = [_]ParamType{ @@ -663,7 +662,8 @@ pub const Arguments = struct { else => invalidTarget(&diag, _target), }; - ctx.debug.run_in_bun = opts.target.? == .bun; + if (opts.target.? == .bun) + ctx.debug.run_in_bun = opts.target.? == .bun; } if (args.flag("--watch")) { @@ -784,7 +784,10 @@ pub const Arguments = struct { const react_fast_refresh = true; if (cmd == .AutoCommand or cmd == .RunCommand) { - ctx.debug.silent = args.flag("--silent"); + // "run.silent" in bunfig.toml + if (args.flag("--silent")) { + ctx.debug.silent = true; + } if (opts.define) |define| { if (define.keys.len > 0) @@ -793,7 +796,10 @@ pub const Arguments = struct { } if (cmd == .RunCommand or cmd == .AutoCommand or cmd == .BunxCommand) { - ctx.debug.run_in_bun = args.flag("--bun") or ctx.debug.run_in_bun; + // "run.bun" in bunfig.toml + if (args.flag("--bun")) { + ctx.debug.run_in_bun = true; + } } opts.resolve = Api.ResolveMode.lazy; @@ -858,11 +864,17 @@ pub const Arguments = struct { if (output_file != null) ctx.debug.output_file = output_file.?; - if (cmd == .RunCommand) { - ctx.debug.use_system_shell = if (Environment.isWindows) - args.flag("--system-shell") - else - !args.flag("--bun-shell"); + if (cmd == .RunCommand or cmd == .AutoCommand) { + if (args.option("--shell")) |shell| { + if (strings.eqlComptime(shell, "bun")) { + ctx.debug.use_system_shell = false; + } else if (strings.eqlComptime(shell, "system")) { + ctx.debug.use_system_shell = true; + } else { + Output.errGeneric("Expected --shell to be one of 'bun' or 'system'. Received: \"{s}\"", .{shell}); + Global.exit(1); + } + } } return opts; @@ -1062,7 +1074,7 @@ pub const Command = struct { run_in_bun: bool = false, loaded_bunfig: bool = false, /// Disables using bun.shell.Interpreter for `bun run`, instead spawning cmd.exe - use_system_shell: bool = false, + use_system_shell: bool = !bun.Environment.isWindows, // technical debt macros: MacroOptions = MacroOptions.unspecified, diff --git a/src/cli/run_command.zig b/src/cli/run_command.zig index 0d33219976..9c95876e80 100644 --- a/src/cli/run_command.zig +++ b/src/cli/run_command.zig @@ -265,7 +265,7 @@ pub const RunCommand = struct { const log = Output.scoped(.RUN, false); - pub fn runPackageScriptForeground( + fn runPackageScriptForeground( allocator: std.mem.Allocator, original_script: string, name: string, @@ -316,15 +316,23 @@ pub const RunCommand = struct { } const mini = bun.JSC.MiniEventLoop.initGlobal(env); - bun.shell.Interpreter.initAndRunFromSource(mini, name, combined_script) catch |err| { + const code = bun.shell.Interpreter.initAndRunFromSource(mini, name, combined_script) catch |err| { if (!silent) { Output.prettyErrorln("error: Failed to run script {s} due to error {s}", .{ name, @errorName(err) }); } - Output.flush(); Global.exit(1); }; + if (code > 0) { + if (code != 2 and !silent) { + Output.prettyErrorln("error: script \"{s}\" exited with code {d}", .{ name, code }); + Output.flush(); + } + + Global.exitWide(code); + } + return true; } @@ -1155,12 +1163,11 @@ pub const RunCommand = struct { ; Output.pretty(intro_text ++ "\n\n", .{}); - Output.flush(); + Output.pretty("Flags:", .{}); - Output.flush(); + clap.simpleHelp(&Arguments.run_params); Output.pretty("\n\n" ++ examples_text, .{}); - Output.flush(); if (package_json) |pkg| { if (pkg.scripts) |scripts| { @@ -1184,16 +1191,15 @@ pub const RunCommand = struct { // Output.prettyln("\n{d} scripts", .{scripts.count()}); Output.prettyln("\n", .{}); - Output.flush(); } else { Output.prettyln("\nNo \"scripts\" found in package.json.\n", .{}); - Output.flush(); } } else { Output.prettyln("\nNo \"scripts\" found in package.json.\n", .{}); - Output.flush(); } } + + Output.flush(); } pub fn exec( diff --git a/src/shell/interpreter.zig b/src/shell/interpreter.zig index f6c43e509f..f8a9b0dcf6 100644 --- a/src/shell/interpreter.zig +++ b/src/shell/interpreter.zig @@ -73,7 +73,7 @@ pub fn assert(cond: bool, comptime msg: []const u8) void { } } -const ExitCode = if (bun.Environment.isWindows) u16 else u16; +pub const ExitCode = u16; pub const StateKind = enum(u8) { script, @@ -612,6 +612,7 @@ pub const Interpreter = struct { started: std.atomic.Value(bool) = std.atomic.Value(bool).init(false), done: ?*bool = null, + exit_code: ?*ExitCode = null, const InterpreterChildPtr = StatePtrUnion(.{ Script, @@ -1156,7 +1157,7 @@ pub const Interpreter = struct { return .{ .result = interpreter }; } - pub fn initAndRunFromFile(mini: *JSC.MiniEventLoop, path: []const u8) !void { + pub fn initAndRunFromFile(mini: *JSC.MiniEventLoop, path: []const u8) !bun.shell.ExitCode { var arena = bun.ArenaAllocator.init(bun.default_allocator); const src = src: { var file = try std.fs.cwd().openFile(path, .{}); @@ -1196,10 +1197,12 @@ pub const Interpreter = struct { var interp = switch (ThisInterpreter.init(.{ .mini = mini }, bun.default_allocator, &arena, script_heap, jsobjs)) { .err => |*e| { throwShellErr(e, .{ .mini = mini }); - return; + return 1; }, .result => |i| i, }; + var exit_code: ExitCode = 1; + const IsDone = struct { done: bool = false, @@ -1210,11 +1213,13 @@ pub const Interpreter = struct { }; var is_done: IsDone = .{}; interp.done = &is_done.done; + interp.exit_code = &exit_code; try interp.run(); mini.tick(&is_done, @as(fn (*anyopaque) bool, IsDone.isDone)); + return exit_code; } - pub fn initAndRunFromSource(mini: *JSC.MiniEventLoop, path_for_errors: []const u8, src: []const u8) !void { + pub fn initAndRunFromSource(mini: *JSC.MiniEventLoop, path_for_errors: []const u8, src: []const u8) !ExitCode { var arena = bun.ArenaAllocator.init(bun.default_allocator); defer arena.deinit(); @@ -1242,7 +1247,7 @@ pub const Interpreter = struct { var interp = switch (ThisInterpreter.init(.{ .mini = mini }, bun.default_allocator, &arena, script_heap, jsobjs)) { .err => |*e| { throwShellErr(e, .{ .mini = mini }); - return; + return 1; }, .result => |i| i, }; @@ -1255,10 +1260,13 @@ pub const Interpreter = struct { } }; var is_done: IsDone = .{}; + var exit_code: ExitCode = 1; interp.done = &is_done.done; + interp.exit_code = &exit_code; try interp.run(); mini.tick(&is_done, @as(fn (*anyopaque) bool, IsDone.isDone)); interp.deinitEverything(); + return exit_code; } pub fn run(this: *ThisInterpreter) !void { @@ -1305,11 +1313,13 @@ pub const Interpreter = struct { fn finish(this: *ThisInterpreter, exit_code: ExitCode) void { log("finish", .{}); defer decrPendingActivityFlag(&this.has_pending_activity); + if (this.event_loop == .js) { defer this.deinitAfterJSRun(); _ = this.resolve.call(&.{JSValue.jsNumberFromU16(exit_code)}); } else { this.done.?.* = true; + this.exit_code.?.* = exit_code; } } diff --git a/src/shell/shell.zig b/src/shell/shell.zig index d19cb9ba32..e6e4dd3412 100644 --- a/src/shell/shell.zig +++ b/src/shell/shell.zig @@ -3192,3 +3192,4 @@ pub fn needsEscapeUtf8AsciiLatin1Slow(str: []const u8) bool { } return false; } +pub const ExitCode = eval.ExitCode; diff --git a/test/cli/install/bun-run-bunfig.test.ts b/test/cli/install/bun-run-bunfig.test.ts new file mode 100644 index 0000000000..0b5ad6b2ef --- /dev/null +++ b/test/cli/install/bun-run-bunfig.test.ts @@ -0,0 +1,140 @@ +import { describe, expect, test } from "bun:test"; +import { realpathSync, chmodSync } from "fs"; +import { bunEnv, bunExe, isWindows, tempDirWithFiles, toTOMLString } from "harness"; +import { join } from "path"; + +describe.each(["bun run", "bun"])(`%s`, cmd => { + const runCmd = cmd === "bun" ? ["-c=bunfig.toml", "run"] : ["-c=bunfig.toml"]; + const node = Bun.which("node")!; + const execPath = process.execPath; + + describe.each(["--bun", "without --bun"])("%s", cmd2 => { + test("which node", async () => { + const bun = cmd2 === "--bun"; + const bunFlag = bun ? ["--bun"] : []; + const bunfig = toTOMLString({ + run: { + bun, + }, + }); + const which = "which"; + + const cwd = tempDirWithFiles("run.where.node." + cmd2, { + "bunfig.toml": bunfig, + "package.json": JSON.stringify( + { + scripts: { + "where-node": `${which} node`, + }, + }, + null, + 2, + ), + }); + + const result = Bun.spawnSync({ + cmd: [bunExe(), "--silent", ...bunFlag, ...runCmd, "where-node"], + env: bunEnv, + stderr: "inherit", + stdout: "pipe", + stdin: "ignore", + cwd, + }); + const nodeBin = result.stdout.toString().trim(); + + if (bun) { + if (isWindows) { + expect(realpathSync(nodeBin)).toContain("\\bun-node-"); + } else { + expect(realpathSync(nodeBin)).toBe(realpathSync(execPath)); + } + } else { + expect(realpathSync(nodeBin)).toBe(realpathSync(node)); + } + expect(result.success).toBeTrue(); + }); + }); + + describe.each(["bun", "system", "default"])(`run.shell = "%s"`, shellStr => { + const shell = shellStr === "default" ? (isWindows ? "bun" : "system") : shellStr; + const command_not_found = + isWindows && shell === "system" ? "is not recognized as an internal or external command" : "command not found"; + test.each(["true", "false"])('run.silent = "%s"', silentStr => { + const silent = silentStr === "true"; + const bunfig = toTOMLString({ + run: { + shell: shellStr === "default" ? undefined : shell, + silent, + }, + }); + + const cwd = tempDirWithFiles(Bun.hash(bunfig).toString(36), { + "bunfig.toml": bunfig, + "package.json": JSON.stringify( + { + scripts: { + startScript: "echo 1", + }, + }, + null, + 2, + ), + }); + + const result = Bun.spawnSync({ + cmd: [bunExe(), ...runCmd, "startScript"], + env: bunEnv, + stderr: "pipe", + stdout: "ignore", + stdin: "ignore", + cwd, + }); + + if (silent) { + expect(result.stderr.toString().trim()).toBe(""); + } else { + expect(result.stderr.toString().trim()).toContain("$ echo 1"); + } + expect(result.success).toBeTrue(); + }); + test("command not found", async () => { + const bunfig = toTOMLString({ + run: { + shell, + }, + }); + + const cwd = tempDirWithFiles("run.shell.system-" + Bun.hash(bunfig).toString(32), { + "bunfig.toml": bunfig, + "package.json": JSON.stringify( + { + scripts: { + start: "this-should-start-with-bun-in-the-error-message", + }, + }, + null, + 2, + ), + }); + + const result = Bun.spawnSync({ + cmd: [bunExe(), "--silent", ...runCmd, "start"], + env: bunEnv, + stderr: "pipe", + stdout: "inherit", + stdin: "ignore", + cwd, + }); + + const err = result.stderr.toString().trim(); + if (shell === "bun") { + expect(err).toStartWith("bun: "); + } else { + expect(err).not.toStartWith("bun: "); + } + expect(err).toContain(command_not_found); + expect(err).toContain("this-should-start-with-bun-in-the-error-message"); + expect(result.success).toBeFalse(); + }); + }); +}); diff --git a/test/harness.ts b/test/harness.ts index 97dfe5cec8..f04957bbde 100644 --- a/test/harness.ts +++ b/test/harness.ts @@ -524,3 +524,36 @@ export function fillRepeating(dstBuffer: NodeJS.TypedArray, start: number, end: sLen <<= 1; // double length for next segment } } + +function makeFlatPropertyMap(opts: object) { + // return all properties of opts as paths for nested objects with dot notation + // like { a: { b: 1 } } => { "a.b": 1 } + // combining names of nested objects with dot notation + // infinitely deep + const ret: any = {}; + function recurse(obj: object, path = "") { + for (const [key, value] of Object.entries(obj)) { + if (value === undefined) continue; + + if (value && typeof value === "object") { + recurse(value, path ? `${path}.${key}` : key); + } else { + ret[path ? `${path}.${key}` : key] = value; + } + } + } + + recurse(opts); + return ret; +} + +export function toTOMLString(opts: object) { + // return a TOML string of the given options + const props = makeFlatPropertyMap(opts); + let ret = ""; + for (const [key, value] of Object.entries(props)) { + if (value === undefined) continue; + ret += `${key} = ${JSON.stringify(value)}` + "\n"; + } + return ret; +}