diff --git a/docs/bundler/loaders.md b/docs/bundler/loaders.md index a05783b204..514d3b608e 100644 --- a/docs/bundler/loaders.md +++ b/docs/bundler/loaders.md @@ -178,7 +178,7 @@ In the bundler, `.node` files are handled using the [`file`](#file) loader. In the runtime and bundler, SQLite databases can be directly imported. This will load the database using [`bun:sqlite`](/docs/api/sqlite.md). ```ts -import db from "./my.db" with {type: "sqlite"}; +import db from "./my.db" with { type: "sqlite" }; ``` This is only supported when the `target` is `bun`. @@ -189,21 +189,21 @@ You can change this behavior with the `"embed"` attribute: ```ts // embed the database into the bundle -import db from "./my.db" with {type: "sqlite", embed: "true"}; +import db from "./my.db" with { type: "sqlite", embed: "true" }; ``` When using a [standalone executable](/docs/bundler/executables), the database is embedded into the single-file executable. Otherwise, the database to embed is copied into the `outdir` with a hashed filename. -### `bunshell` loader +### `sh` loader -**Bun Shell loader**. Default for `.bun.sh` files +**Bun Shell loader**. Default for `.sh` files -This loader is used to parse [Bun Shell](/docs/runtime/shell) scripts. It's only supported when starting bun itself, so it's not available in the bundler or in the runtime. +This loader is used to parse [Bun Shell](/docs/runtime/shell) scripts. It's only supported when starting Bun itself, so it's not available in the bundler or in the runtime. ```sh -$ bun run ./script.bun.sh +$ bun run ./script.sh ``` ### `file` diff --git a/docs/runtime/shell.md b/docs/runtime/shell.md index 54881c5820..42977585d0 100644 --- a/docs/runtime/shell.md +++ b/docs/runtime/shell.md @@ -1,9 +1,5 @@ Bun Shell makes shell scripting with JavaScript & TypeScript fun. It's a cross-platform bash-like shell with seamless JavaScript interop. -{% callout type="note" %} -**Alpha-quality software**: Bun Shell is an unstable API still under development. If you have feature requests or run into bugs, please open an issue. There may be breaking changes in the future. -{% /callout %} - Quickstart: ```js @@ -23,6 +19,8 @@ await $`cat < ${response} | wc -c`; // 1256 - **Template literals**: Template literals are used to execute shell commands. This allows for easy interpolation of variables and expressions. - **Safety**: Bun Shell escapes all strings by default, preventing shell injection attacks. - **JavaScript interop**: Use `Response`, `ArrayBuffer`, `Blob`, `Bun.file(path)` and other JavaScript objects as stdin, stdout, and stderr. +- **Shell scripting**: Bun Shell can be used to run shell scripts (`.bun.sh` files). +- **Custom interpreter**: Bun Shell is written in Zig, along with it's lexer, parser, and interpreter. Bun Shell is a small programming language. ## Getting started @@ -53,16 +51,63 @@ const welcome = await $`echo "Hello World!"`.text(); console.log(welcome); // Hello World!\n ``` -To get stdout, stderr, and the exit code, use await or `.run`: +By default, `await`ing will return stdout and stderr as `Buffer`s. ```js import { $ } from "bun"; -const { stdout, stderr, exitCode } = await $`echo "Hello World!"`.quiet(); +const { stdout, stderr } = await $`echo "Hello World!"`.quiet(); console.log(stdout); // Buffer(6) [ 72, 101, 108, 108, 111, 32 ] console.log(stderr); // Buffer(0) [] -console.log(exitCode); // 0 +``` + +## Error handling + +By default, non-zero exit codes will throw an error. This `ShellError` contains information about the command run. + +```js +import { $ } from "bun"; + +try { + const output = await $`something-that-may-fail`.text(); + console.log(output); +} catch (err) { + console.log(`Failed with code ${err.exitCode}`); + console.log(output.stdout.toString()); + console.log(output.stderr.toString()); +} +``` + +Throwing can be disabled with `.nothrow()`. The result's `exitCode` will need to be checked manually. + +```js +import { $ } from "bun"; + +const { stdout, stderr, exitCode } = await $`something-that-may-fail` + .nothrow() + .quiet(); + +if (exitCode !== 0) { + console.log(`Non-zero exit code ${exitCode}`); +} + +console.log(stdout); +console.log(stderr); +``` + +The default handling of non-zero exit codes can be configured by calling `.nothrow()` or `.throws(boolean)` on the `$` function itself. + +```js +// shell promises will not throw, meaning you will have to +// check for `exitCode` manually on every shell command. +$.nothrow(); // equivilent to $.throws(false) + +// default behavior, non-zero exit codes will throw an error +$.throws(true); + +// alias for $.nothrow() +$.throws(false); ``` ## Redirection @@ -89,9 +134,8 @@ To redirect stdout to a JavaScript object, use the `>` operator: import { $ } from "bun"; const buffer = Buffer.alloc(100); -const result = await $`echo "Hello World!" > ${buffer}`; +await $`echo "Hello World!" > ${buffer}`; -console.log(result.exitCode); // 0 console.log(buffer.toString()); // Hello World!\n ``` @@ -404,24 +448,28 @@ await $`echo ${{ raw: '$(foo) `bar` "baz"' }}`; For simple shell scripts, instead of `/bin/sh`, you can use Bun Shell to run shell scripts. -To do so, just run the script with `bun` on a file with the `.bun.sh` extension. +To do so, just run the script with `bun` on a file with the `.sh` extension. -```sh#script.bun.sh +```sh#script.sh echo "Hello World! pwd=$(pwd)" ``` ```sh -$ bun ./script.bun.sh +$ bun ./script.sh Hello World! pwd=/home/demo ``` Scripts with Bun Shell are cross platform, which means they work on Windows: ``` -PS C:\Users\Demo> bun .\script.bun.sh +PS C:\Users\Demo> bun .\script.sh Hello World! pwd=C:\Users\Demo ``` +## Implementation notes + +Bun Shell is a small programming language in Bun that is implemented in Zig. It includes a handwritten lexer, parser, and interpreter. Unlike bash, zsh, and other shells, Bun Shell runs operations concurrently. + ## Credits Large parts of this API were inspired by [zx](https://github.com/google/zx), [dax](https://github.com/dsherret/dax), and [bnx](https://github.com/wobsoriano/bnx). Thank you to the authors of those projects. diff --git a/packages/bun-types/bun.d.ts b/packages/bun-types/bun.d.ts index 2bb70f3971..ec6a87e036 100644 --- a/packages/bun-types/bun.d.ts +++ b/packages/bun-types/bun.d.ts @@ -276,12 +276,16 @@ declare module "bun" { blob(): Promise; /** - * Configure the shell to not throw an exception on non-zero exit codes. + * Configure the shell to not throw an exception on non-zero exit codes. Throwing can be re-enabled with `.throws(true)`. + * + * By default, the shell with throw an exception on commands which return non-zero exit codes. */ nothrow(): this; /** * Configure whether or not the shell should throw an exception on non-zero exit codes. + * + * By default, this is configured to `true`. */ throws(shouldThrow: boolean): this; } diff --git a/src/StandaloneModuleGraph.zig b/src/StandaloneModuleGraph.zig index d658f771cf..e486befda6 100644 --- a/src/StandaloneModuleGraph.zig +++ b/src/StandaloneModuleGraph.zig @@ -10,8 +10,6 @@ const Global = bun.Global; const Environment = bun.Environment; const Syscall = bun.sys; -const exe_suffix = bun.exe_suffix; - const w = std.os.windows; pub const StandaloneModuleGraph = struct { @@ -661,30 +659,32 @@ pub const StandaloneModuleGraph = struct { return try StandaloneModuleGraph.fromBytes(allocator, to_read, offsets); } - fn isBuiltInExe(argv0: []const u8) bool { + /// heuristic: `bun build --compile` won't be supported if the name is "bun", "bunx", or "node". + /// this is a cheap way to avoid the extra overhead of opening the executable, and also just makes sense. + fn isBuiltInExe(comptime T: type, argv0: []const T) bool { if (argv0.len == 0) return false; if (argv0.len == 3) { - if (bun.strings.eqlComptimeIgnoreLen(argv0, "bun" ++ exe_suffix)) { + if (bun.strings.eqlComptimeCheckLenWithType(T, argv0, bun.strings.literal(T, "bun"), false)) { return true; } } if (argv0.len == 4) { - if (bun.strings.eqlComptimeIgnoreLen(argv0, "bunx" ++ exe_suffix)) { + if (bun.strings.eqlComptimeCheckLenWithType(T, argv0, bun.strings.literal(T, "bunx"), false)) { return true; } - if (bun.strings.eqlComptimeIgnoreLen(argv0, "node" ++ exe_suffix)) { + if (bun.strings.eqlComptimeCheckLenWithType(T, argv0, bun.strings.literal(T, "node"), false)) { return true; } } if (comptime Environment.isDebug) { - if (bun.strings.eqlComptime(argv0, "bun-debug")) { + if (bun.strings.eqlComptimeCheckLenWithType(T, argv0, bun.strings.literal(T, "bun-debug"), true)) { return true; } - if (bun.strings.eqlComptime(argv0, "bun-debugx")) { + if (bun.strings.eqlComptimeCheckLenWithType(T, argv0, bun.strings.literal(T, "bun-debugx"), true)) { return true; } } @@ -693,13 +693,10 @@ pub const StandaloneModuleGraph = struct { } fn openSelf() std.fs.OpenSelfExeError!bun.FileDescriptor { - // heuristic: `bun build --compile` won't be supported if the name is "bun", "bunx", or "node". - // this is a cheap way to avoid the extra overhead - // of opening the executable and also just makes sense. if (!Environment.isWindows) { const argv = bun.argv(); if (argv.len > 0) { - if (isBuiltInExe(argv[0])) { + if (isBuiltInExe(u8, argv[0])) { return error.FileNotFound; } } @@ -739,6 +736,13 @@ pub const StandaloneModuleGraph = struct { var nt_path_buf: bun.WPathBuffer = undefined; const nt_path = bun.strings.addNTPathPrefix(&nt_path_buf, image_path); + const basename_start = std.mem.lastIndexOfScalar(u16, nt_path, '\\') orelse + return error.FileNotFound; + const basename = nt_path[basename_start + 1 .. nt_path.len - ".exe".len]; + if (isBuiltInExe(u16, basename)) { + return error.FileNotFound; + } + return bun.sys.openFileAtWindows( bun.FileDescriptor.cwd(), nt_path, diff --git a/src/bun_js.zig b/src/bun_js.zig index 301ab7d9a8..db26dd0a7c 100644 --- a/src/bun_js.zig +++ b/src/bun_js.zig @@ -147,7 +147,7 @@ pub const Run = struct { try bun.CLI.Arguments.loadConfigPath(ctx.allocator, true, "bunfig.toml", &ctx, .RunCommand); } - if (strings.endsWithComptime(entry_path, comptime if (Environment.isWindows) ".sh" else ".bun.sh")) { + if (strings.endsWithComptime(entry_path, ".sh")) { const exit_code = try bootBunShell(&ctx, entry_path); Global.exitWide(exit_code); return; diff --git a/src/cli.zig b/src/cli.zig index 4587ccf993..b6ce59bbaa 100644 --- a/src/cli.zig +++ b/src/cli.zig @@ -1033,6 +1033,7 @@ pub const HelpCommand = struct { Output.flush(); } + pub fn execWithReason(_: std.mem.Allocator, comptime reason: Reason) void { @setCold(true); printWithReason(reason, false); @@ -1040,6 +1041,7 @@ pub const HelpCommand = struct { if (reason == .invalid_command) { std.process.exit(1); } + std.process.exit(0); } }; diff --git a/src/js/builtins/shell.ts b/src/js/builtins/shell.ts index 6c06e9c517..2e8429402d 100644 --- a/src/js/builtins/shell.ts +++ b/src/js/builtins/shell.ts @@ -1,12 +1,20 @@ +import type { ShellOutput } from "bun"; + type ShellInterpreter = any; type Resolve = (value: ShellOutput) => void; export function createBunShellTemplateFunction(ShellInterpreter) { - function lazyBufferToHumanReadableString() { + function lazyBufferToHumanReadableString(this: Buffer) { return this.toString(); } + class ShellError extends Error { #output?: ShellOutput = undefined; + info; + exitCode; + stdout; + stderr; + constructor() { super(""); } @@ -30,24 +38,20 @@ export function createBunShellTemplateFunction(ShellInterpreter) { Object.assign(this, this.info); } - exitCode; - stdout; - stderr; - text(encoding) { - return this.#output.text(encoding); + return this.#output!.text(encoding); } json() { - return this.#output.json(); + return this.#output!.json(); } arrayBuffer() { - return this.#output.arrayBuffer(); + return this.#output!.arrayBuffer(); } blob() { - return this.#output.blob(); + return this.#output!.blob(); } } @@ -87,19 +91,20 @@ export function createBunShellTemplateFunction(ShellInterpreter) { #hasRun: boolean = false; #throws: boolean = true; // #immediate; + constructor(core: ShellInterpreter, throws: boolean) { // Create the error immediately so it captures the stacktrace at the point // of the shell script's invocation. Just creating the error should be // relatively cheap, the costly work is actually computing the stacktrace // (`computeErrorInfo()` in ZigGlobalObject.cpp) - let potentialError = new ShellError(); + let potentialError: ShellError | undefined = new ShellError(); let resolve, reject; super((res, rej) => { resolve = code => { const out = new ShellOutput(core.getBufferedStdout(), core.getBufferedStderr(), code); if (this.#throws && code !== 0) { - potentialError.initialize(out, code); + potentialError!.initialize(out, code); rej(potentialError); } else { // Set to undefined to hint to the GC that this is unused so it can @@ -109,7 +114,7 @@ export function createBunShellTemplateFunction(ShellInterpreter) { } }; reject = code => { - potentialError.initialize(new ShellOutput(core.getBufferedStdout(), core.getBufferedStderr(), code), code); + potentialError!.initialize(new ShellOutput(core.getBufferedStdout(), core.getBufferedStderr(), code), code); rej(potentialError); }; }); @@ -150,7 +155,7 @@ export function createBunShellTemplateFunction(ShellInterpreter) { return this; } - env(newEnv: Record): this { + env(newEnv: Record): this { this.#throwIfRunning(); if (typeof newEnv === "undefined") { newEnv = defaultEnv; @@ -250,7 +255,7 @@ export function createBunShellTemplateFunction(ShellInterpreter) { class ShellPrototype { [cwdSymbol]: string | undefined; [envSymbol]: Record | undefined; - [throwsSymbol]: boolean = false; + [throwsSymbol]: boolean = true; env(newEnv: Record) { if (typeof newEnv === "undefined" || newEnv === originalDefaultEnv) { @@ -263,6 +268,7 @@ export function createBunShellTemplateFunction(ShellInterpreter) { return this; } + cwd(newCwd: string | undefined) { if (typeof newCwd === "undefined" || typeof newCwd === "string") { if (newCwd === "." || newCwd === "" || newCwd === "./") { @@ -276,10 +282,12 @@ export function createBunShellTemplateFunction(ShellInterpreter) { return this; } + nothrow() { this[throwsSymbol] = false; return this; } + throws(doThrow: boolean | undefined) { this[throwsSymbol] = !!doThrow; return this; @@ -333,7 +341,7 @@ export function createBunShellTemplateFunction(ShellInterpreter) { BunShell[cwdSymbol] = defaultCwd; BunShell[envSymbol] = defaultEnv; - BunShell[throwsSymbol] = false; + BunShell[throwsSymbol] = true; Object.defineProperties(BunShell, { Shell: { diff --git a/src/options.zig b/src/options.zig index 3927c5aaf4..6d68afb196 100644 --- a/src/options.zig +++ b/src/options.zig @@ -712,7 +712,7 @@ pub const Loader = enum(u8) { map.set(Loader.wasm, "input.wasm"); map.set(Loader.napi, "input.node"); map.set(Loader.text, "input.txt"); - map.set(Loader.bunsh, "input.bunsh"); + map.set(Loader.bunsh, "input.sh"); break :brk map; }; @@ -757,7 +757,7 @@ pub const Loader = enum(u8) { .{ "base64", Loader.base64 }, .{ "txt", Loader.text }, .{ "text", Loader.text }, - .{ "bunsh", Loader.bunsh }, + .{ "sh", Loader.bunsh }, .{ "sqlite", Loader.sqlite }, .{ "sqlite_embedded", Loader.sqlite_embedded }, }); @@ -781,7 +781,7 @@ pub const Loader = enum(u8) { .{ "base64", Api.Loader.base64 }, .{ "txt", Api.Loader.text }, .{ "text", Api.Loader.text }, - .{ "bunsh", Api.Loader.file }, + .{ "sh", Api.Loader.file }, .{ "sqlite", Api.Loader.sqlite }, }); diff --git a/test/cli/run/run-shell.test.ts b/test/cli/run/run-shell.test.ts new file mode 100644 index 0000000000..2f24433345 --- /dev/null +++ b/test/cli/run/run-shell.test.ts @@ -0,0 +1,18 @@ +import { expect, test } from "bun:test"; +import { mkdirSync, realpathSync } from "fs"; +import { bunEnv, bunExe } from "harness"; +import { tmpdir } from "os"; +import { join } from "path"; + +test("running a shell script works", async () => { + const dir = join(realpathSync(tmpdir()), "bun-run-shell"); + mkdirSync(dir, { recursive: true }); + await Bun.write(join(dir, "something.sh"), "echo wah"); + let { stdout, stderr } = Bun.spawnSync({ + cmd: [bunExe(), join(dir, "something.sh")], + cwd: dir, + env: bunEnv, + }); + console.log(stderr.toString("utf8")); + expect(stdout.toString("utf8")).toEqual("wah\n"); +}); diff --git a/test/js/bun/shell/bunshell-default.test.ts b/test/js/bun/shell/bunshell-default.test.ts new file mode 100644 index 0000000000..be46c48976 --- /dev/null +++ b/test/js/bun/shell/bunshell-default.test.ts @@ -0,0 +1,25 @@ +import { $ } from "bun"; + +test("default throw on command failure", async () => { + try { + await $`echo hi; ls oogabooga`.quiet(); + expect.unreachable(); + } catch (e: any) { + expect(e).toBeInstanceOf(Error); + expect(e.exitCode).toBe(1); + expect(e.message).toBe("Failed with exit code 1"); + expect(e.stdout.toString("utf-8")).toBe("hi\n"); + expect(e.stderr.toString("utf-8")).toBe("ls: oogabooga: No such file or directory\n"); + } +}); + +test("ShellError has .text()", async () => { + try { + await $`ls oogabooga`.quiet(); + expect.unreachable(); + } catch (e: any) { + expect(e).toBeInstanceOf(Error); + expect(e.exitCode).toBe(1); + expect(e.stderr.toString("utf-8")).toBe("ls: oogabooga: No such file or directory\n"); + } +}); diff --git a/test/js/bun/shell/commands/mv.test.ts b/test/js/bun/shell/commands/mv.test.ts index b2d2d4f8dd..ceea68aca2 100644 --- a/test/js/bun/shell/commands/mv.test.ts +++ b/test/js/bun/shell/commands/mv.test.ts @@ -4,6 +4,8 @@ import { TestBuilder } from "../test_builder"; import { sortedShellOutput } from "../util"; import { join } from "path"; +$.nothrow(); + describe("mv", async () => { TestBuilder.command`echo foo > a; mv a b`.ensureTempDir().fileEquals("b", "foo\n").runAsTest("move file -> file"); diff --git a/test/js/bun/shell/test_builder.ts b/test/js/bun/shell/test_builder.ts index e282abc63a..6e2c195222 100644 --- a/test/js/bun/shell/test_builder.ts +++ b/test/js/bun/shell/test_builder.ts @@ -44,7 +44,7 @@ export class TestBuilder { static command(strings: TemplateStringsArray, ...expressions: any[]): TestBuilder { try { if (process.env.BUN_DEBUG_SHELL_LOG_CMD === "1") console.info("[ShellTestBuilder] Cmd", strings.join("")); - const promise = Bun.$(strings, ...expressions); + const promise = Bun.$(strings, ...expressions).nothrow(); const This = new this({ type: "ok", val: promise }); This._testName = strings.join(""); return This; diff --git a/test/js/bun/util/which.test.ts b/test/js/bun/util/which.test.ts index c5ec4e13b2..f23d09e820 100644 --- a/test/js/bun/util/which.test.ts +++ b/test/js/bun/util/which.test.ts @@ -7,6 +7,8 @@ import { tmpdir } from "node:os"; import { rmdirSync } from "js/node/fs/export-star-from"; import { isIntelMacOS, isWindows, tempDirWithFiles } from "harness"; +$.nothrow(); + { const delim = isWindows ? ";" : ":"; if (`${delim}${process.env.PATH}${delim}`.includes(`${delim}.${delim}`)) {