@codebolt/shell
v0.1.0
Published
Pure-JS virtual filesystem, full POSIX command set, and complete bash interpreter that runs in Cloudflare Workers, browsers, Node, Bun — anywhere V8 runs. No native bindings, no subprocess spawning, no WASM.
Readme
codeboltshell
A pure-JS virtual filesystem, POSIX command set, and full bash interpreter that runs in Cloudflare Workers, browsers, Node, Bun — anywhere V8 runs. No native bindings, no subprocess spawning, no WASM. Compose backends, search, edit, run git, run bash scripts, all behind one
Vmobject.
import { Vm } from "@codebolt/shell";
const vm = new Vm();
// Direct FileSystem
await vm.writeFile("/hello.txt", "world");
// POSIX commands (vendored from just-bash, no dep on it)
await vm.cmd.grep(["-rn", "TODO", "/src"]);
await vm.cmd.find(["/", "-name", "*.txt"]);
// Full bash interpreter — pipes, control flow, expansions, the lot
const r = await vm.bash.exec(`
for f in /src/*.txt; do
grep TODO "$f"
done | wc -l
`);
console.log(r.stdout); // "3\n"
// Git — backed by isomorphic-git over the same FileSystem
await vm.git.init();
await vm.git.add({ filepath: "hello.txt" });
await vm.git.commit({
message: "first",
author: { name: "you", email: "[email protected]" }
});Why it exists
Most "virtual filesystem" libraries fall into one of two camps:
- Userspace OS sandboxes (agent-os, secure-exec, WebContainers) — try to recreate POSIX so the agent can run real binaries. Heavy. Don't run on Workers.
- Edge-only typed APIs (
@cloudflare/shellWorkspace) — a curatedstate.*surface that runs on Workers but is tied to Cloudflare's bindings.
codeboltshell is the third option: a small, in-process library that
exposes shell-shaped tools (fs, search, edit, git) over a virtual
filesystem with pluggable backends, runs anywhere V8 runs, and has no
external dependencies on a specific cloud.
The Vm is a thin facade — every method is implemented against a
FileSystem interface that has multiple backends (in-memory, SQL,
overlay, mountable, chunked) you can mix freely. The same agent code
runs against an in-memory FS in tests, a Durable-Object-SQL FS in
production, an overlay FS for per-agent isolation — without any code
changes above the FS layer.
Install
npm install @codebolt/shellimport { Vm } from "@codebolt/shell";Four runtime dependencies, all pure JS and Worker-safe:
diff— forvm.diffand the vendoredvm.cmd.diffcommandisomorphic-git— forvm.gitsprintf-js— forvm.cmd.printf(vendored from just-bash)minimatch— forvm.cmd.ls's glob ignore patterns
No native bindings, no WASM.
The Vm
One object, every capability hangs off it:
import { Vm } from "@codebolt/shell";
const vm = new Vm({
// Optional: provide your own FileSystem. Defaults to a fresh InMemoryFs.
fs: someFileSystem,
// Optional: seed the default InMemoryFs with files (only used when fs is omitted).
files: { "/seed.txt": "hello" }
});Construction is synchronous. Per-operation methods are async so the
same code works with backends that have real I/O (D1, R2, S3) without
a contract change. Spinning up a fresh Vm per request, per session,
or per agent task is cheap — the constructor allocates a Map.
Filesystem
Reading
await vm.readFile(path); // string | null
await vm.readFileBytes(path); // Uint8Array | null
await vm.readFileStream(path); // ReadableStream<Uint8Array> | null
await vm.exists(path); // boolean
await vm.stat(path); // { type, size, mtime } | null
await vm.lstat(path); // same as stat unless backend has symlinks
await vm.readdir(dir); // [{ name, type }, ...]readFile returns null for missing files (not an exception). Reads
that should fail loudly are explicit at the call site.
Writing
await vm.writeFile(path, "text"); // string
await vm.writeFileBytes(path, new Uint8Array([0xff, 0xfe])); // raw bytes
await vm.writeFileStream(path, readableStream); // streaming
await vm.mkdir(path, { recursive: true });
await vm.rm(path, { recursive: true, force: true });All write operations auto-create parent directories. There's no "missing parent" failure mode.
Manipulating
await vm.cp(src, dest, { recursive: true });
await vm.mv(src, dest);
await vm.glob("/src/**/*.ts"); // string[]glob supports *, ?, ** (across segments), and the standard
/**/ and trailing /** semantics from minimatch.
Streaming
readFileStream and writeFileStream use the Web Streams API. They're
real streaming on ChunkedVFS (which backs SqlFs) — chunks are read
from / written to the BlockStore one at a time, never holding the
whole file in memory. On InMemoryFs they wrap the underlying buffer
in a single-chunk stream (correct, but no memory savings since the data
is already in RAM).
// Pipe a request body straight into the FS
await vm.writeFileStream("/uploads/file.bin", request.body);
// Stream a file straight back as the response
const stream = await vm.readFileStream("/big-export.csv");
return new Response(stream, { headers: { "content-type": "text/csv" } });
// Pipe through any TransformStream
const compressed = (await vm.readFileStream("/source.bin"))!
.pipeThrough(new CompressionStream("gzip"));
await vm.writeFileStream("/source.bin.gz", compressed);Backends
The Vm's fs field is a FileSystem. Five backends ship in the box;
they all implement the same interface and compose freely.
InMemoryFs
The simplest. Map<path, Uint8Array> plus a Set<path> for
directories. Cheap to construct, ephemeral, no I/O.
import { InMemoryFs } from "@codebolt/shell";
const fs = new InMemoryFs({
"/seed.txt": "hello", // optional seed files
"/src/index.ts": "..."
});
const vm = new Vm({ fs });Use for: tests, scratch sandboxes, the upper layer of an OverlayFs,
the default in new Vm().
OverlayFs — copy-on-write
Wraps a lower (read-only) and an upper (writable) FileSystem.
Reads check upper first, fall through to lower. Writes always go
to upper. Deletes are tombstoned in an in-memory Set so the lower's
copy doesn't reappear.
import { OverlayFs, InMemoryFs } from "@codebolt/shell";
// One persistent project, two agents working on it independently.
const project = persistentFs; // shared, read-mostly
const vmA = new Vm({
fs: new OverlayFs({ lower: project, upper: new InMemoryFs() })
});
const vmB = new Vm({
fs: new OverlayFs({ lower: project, upper: new InMemoryFs() })
});
// vmA and vmB can both edit the same files; their writes never collide;
// the underlying `project` sees neither edit.Use for: per-agent isolation over a shared base, "preview an edit" sandboxes, throw-away test runs against a real codebase.
MountableFs — multi-mount routing
Routes paths to multiple backends by longest-prefix match. Each
backend sees its own world starting at /.
import { MountableFs } from "@codebolt/shell";
const vm = new Vm({
fs: new MountableFs({
"/project": persistentFs, // D1 + R2
"/tmp": new InMemoryFs(), // ephemeral
"/refs": referenceLibraryFs // shared read-only
})
});
await vm.writeFile("/tmp/scratch.txt", "...");
// → routes to the InMemoryFs as writeFile("/scratch.txt")Mount points show up as synthetic directory entries in readdir("/").
Nested mounts are not supported (you can't mount /project/sub
separately from /project); the one exception is /, which can be a
catch-all base under which other mounts override.
ChunkedVFS — metadata + blocks split
Composes a MetadataStore (directory tree + inodes) with an optional
BlockStore (blob bytes). Tiered storage: small files live inline in
metadata; large files are split into chunkSize blocks in the BlockStore.
import {
ChunkedVFS,
InMemoryMetadataStore,
InMemoryBlockStore
} from "@codebolt/shell";
const vm = new Vm({
fs: new ChunkedVFS({
metadata: new InMemoryMetadataStore(),
blocks: new InMemoryBlockStore(),
inlineThreshold: 1_500_000, // ≤ this → inline (default 1.5 MB)
chunkSize: 4 * 1024 * 1024 // > inlineThreshold → split into N × 4 MB blocks
})
});The two interfaces are independent. Swap metadata in (SQL → in-memory) without touching block storage; swap blocks in (R2 → S3 → in-memory) without touching metadata. This is the agent-os / JuiceFS pattern.
SqlFs — convenience wrapper
import { SqlFs } from "@codebolt/shell";
const vm = new Vm({
fs: new SqlFs({
backend: someSqlBackend, // your D1 / DO SQL adapter
blocks: someBlockStore, // optional
inlineThreshold: 1_500_000,
chunkSize: 4 * 1024 * 1024,
tableName: "myproject_files" // optional namespace
})
});Equivalent to:
new ChunkedVFS({
metadata: new SqlMetadataStore({ backend: someSqlBackend, tableName: "myproject_files" }),
blocks: someBlockStore
})Persistent storage
codeboltshell ships no Cloudflare-specific code. Persistence
plugs in via two adapter functions you write in your own consumer code.
SQL backend
The SqlBackend interface is three methods:
interface SqlBackend {
exec(sql: string, params?: SqlParam[]): Promise<void>;
get<T>(sql: string, params?: SqlParam[]): Promise<T | null>;
all<T>(sql: string, params?: SqlParam[]): Promise<T[]>;
}
type SqlParam = string | number | null | Uint8Array;A D1 adapter is ~15 lines:
import type { SqlBackend, SqlParam } from "@codebolt/shell";
export function d1ToSqlBackend(db: D1Database): SqlBackend {
return {
async exec(sql, params = []) { await db.prepare(sql).bind(...params).run(); },
async get(sql, params = []) { return db.prepare(sql).bind(...params).first() as never; },
async all(sql, params = []) {
const r = await db.prepare(sql).bind(...params).all();
return (r.results ?? []) as never;
}
};
}A Durable Object SQL adapter is the same shape against
ctx.storage.sql.exec(...). A better-sqlite3 adapter is the same
shape against the prepared-statement API.
Block store
Three methods:
interface BlockStore {
get(key: string): Promise<Uint8Array | null>;
put(key: string, data: Uint8Array): Promise<void>;
delete(key: string): Promise<void>;
}An R2 adapter is ~15 lines:
import type { BlockStore } from "@codebolt/shell";
export function r2ToBlockStore(bucket: R2Bucket): BlockStore {
return {
async get(key) {
const obj = await bucket.get(key);
return obj ? new Uint8Array(await obj.arrayBuffer()) : null;
},
async put(key, data) { await bucket.put(key, data); },
async delete(key) { await bucket.delete(key); }
};
}S3 (via aws4fetch), Backblaze B2, MinIO — all the same shape.
Putting it together
import { Vm, SqlFs } from "@codebolt/shell";
export default {
async fetch(req: Request, env: Env): Promise<Response> {
const vm = new Vm({
fs: new SqlFs({
backend: d1ToSqlBackend(env.DB),
blocks: r2ToBlockStore(env.BLOCKS)
})
});
await vm.writeFile("/hello.txt", "persistent");
return Response.json({ content: await vm.readFile("/hello.txt") });
}
};Search and edit
// Search one file
const matches = await vm.searchText("/src/index.ts", "TODO");
// → [{ line: 14, column: 6, text: " // TODO: fix me", match: "TODO" }, ...]
// Search across many files
const results = await vm.searchFiles("/src/**/*.ts", "TODO");
// → [{ path, matches: [...] }, ...]
// Regex
const usages = await vm.searchFiles("/src/**/*.ts", "useState\\((.*)\\)", {
regex: true,
caseInsensitive: true,
maxMatchesPerFile: 100
});
// Replace in one file (dry-run preview)
const preview = await vm.replaceInFile("/api/client.ts", "fetch(", "fetchWithRetry(", {
dryRun: true
});
console.log(`Would replace ${preview.replaced} occurrences`);
console.log(preview.newContent);
// Replace across many files
const result = await vm.replaceInFiles("/src/**/*.ts", "old_url", "new_url");
// → { totalReplaced: 7, perFile: [{ path, replaced }, ...] }Diff
// Diff two files in the FS (unified diff string)
const patch = await vm.diff("/a.txt", "/b.txt");
// Diff a file's current content against a candidate edit
const preview = await vm.diffContent("/src/index.ts", "export const v = 'NEW';\n");Both return standard diff -u formatted strings via the diff package.
Git
vm.git is a lazy Git instance backed by isomorphic-git over the
same FileSystem.
const vm = new Vm();
const dir = "/repo";
await vm.git.init({ dir });
await vm.writeFile("/repo/README.md", "# hello");
await vm.git.add({ dir, filepath: "README.md" });
const oid = await vm.git.commit({
dir,
message: "first",
author: { name: "you", email: "[email protected]" }
});
const log = await vm.git.log({ dir });
const status = await vm.git.statusMatrix({ dir });
const branches = await vm.git.listBranches({ dir });The full surface: init, clone, status, statusMatrix, add,
remove, commit, log, listBranches, branch, checkout,
fetch, pull, push, listRemotes, addRemote, resolveRef,
readCommit. Each method takes an optional dir (defaults to the
Git instance's defaultDir, which is /).
Auth
// GitHub PAT
await vm.git.clone({
url: "https://github.com/org/repo.git",
dir: "/repo",
auth: { token: "ghp_..." }
});
// Username + password
await vm.git.push({
dir: "/repo",
auth: { username: "you", password: "..." }
});Cloning over HTTPS
vm.git.clone works against any CORS-enabled git endpoint. GitHub
itself isn't CORS-enabled — you need a CORS proxy like
cors.isomorphic-git.org or your own. Same constraint as
isomorphic-git itself.
Shell commands and bash
codeboltshell ships a complete POSIX shell layer vendored from
just-bash 2.14.0
(Apache-2.0). No runtime dependency on just-bash itself — the source
lives at src/bash/, attribution at src/bash/NOTICE.md. We
vendored to keep full control: every command runs over our
FileSystem, and consumers can patch any vendored file in place
without negotiating with an upstream.
Three command surfaces, ordered roughly by power and weight:
vm.exec — lightweight zero-dep runner
Tiny single-line command runner with a quote-aware tokenizer. No pipes, no expansions, no control flow. Use it when you have a single line to run and don't need the full bash machinery.
await vm.exec.run("mkdir -p /a/b");
await vm.exec.run("cd /a/b");
const r = await vm.exec.run("pwd"); // r.stdout === "/a/b\n"Builtins: echo, pwd, cd, ls, cat, mkdir, rm, cp,
mv, touch, grep, help. Register your own with
vm.exec.define(defineCommand("name", async (args, ctx) => ...)).
vm.cmd.<name>(args) — vendored POSIX commands
~57 just-bash commands, each callable as a typed method. Returns a
real {stdout, stderr, exitCode}. Argv is passed exactly as you'd
write it on a real shell command line.
await vm.cmd.grep(["-rn", "TODO", "/src"]);
await vm.cmd.find(["/src", "-name", "*.ts"]);
await vm.cmd.sed(["s/foo/bar/g", "/file.txt"]);
await vm.cmd.awk(["-F,", "$3==\"eng\"{print $1}", "/data.csv"]);
await vm.cmd.wc(["-l", "/file.txt"]);
await vm.cmd.tr(["a-z", "A-Z"], { stdin: "hello\n" });
await vm.cmd.sha256sum([], { stdin: "hello" });Full list (categorised):
- Search / text:
grep,sed,awk,cat,head,tail,wc,sort,uniq,cut,paste,comm,join,nl,tac,rev,tr,expand,unexpand,fold,od,strings,split,diff,column - Filesystem:
ls,cp,mv,rm,mkdir,rmdir,touch,ln,stat,basename,dirname,readlink,find,du,chmod,tree - Misc:
echo,printf,pwd,env,printenv,date,seq,tee,expr,true,false,sleep,which,whoami,clear,base64,md5sum,sha1sum,sha256sum,xargs,time,timeout
xargs, timeout, and find -exec work because the vm.cmd
constructor wires a sub-execution callback into every command's
context — they dispatch back into the same registry.
Skipped: tar / gzip (need Node child_process / zlib),
file (needs file-type), python3 / js-exec / sqlite3 (WASM
bundles).
vm.bash.exec(script) — full bash interpreter
The big one. Parser + interpreter + expansion + redirections + control
flow + functions + arrays + [[ ]] + here-docs + set -e + the
whole bash surface, all vendored, all running over vm.fs.
await vm.bash.exec("echo hello world");
await vm.bash.exec('FOO=hi; echo "${FOO:-default}"');
await vm.bash.exec("cat /work/data.txt | grep TODO | wc -l");
await vm.bash.exec("for f in /work/*.txt; do grep TODO $f; done | wc -l");
await vm.bash.exec(`
if [ -f /work/data.txt ]; then
echo found
else
echo missing
fi
`);
await vm.bash.exec("greet() { echo hello $1; }; greet world");vm.bash holds mutable shell state across calls — env vars, exported
set, functions, last exit code, cwd. Successive calls behave like one
shell session:
await vm.bash.exec("export FOO=persistent; cd /work");
await vm.bash.exec("echo $FOO"); // → "persistent\n"
await vm.bash.exec("pwd"); // → "/work\n"Every command in vm.cmd is automatically available inside scripts.
The interpreter looks them up via the same registry.
Transform plugins
Hand vm.bash.exec an AST transform plugin (or array, or pipeline)
to inspect or rewrite the script between parse and execute. Plugin
metadata comes back on result.transformMetadata.
import {
BashTransformPipeline,
CommandCollectorPlugin,
TeePlugin
} from "@codebolt/shell";
// Single plugin
const collector = new CommandCollectorPlugin();
const r = await vm.bash.exec("cat data.txt | grep foo", { transform: collector });
console.log(r.transformMetadata); // { commands: ["cat", "grep"] }
// Pipeline form (chains plugins, merges metadata)
const pipeline = new BashTransformPipeline()
.use(new TeePlugin({ outputDir: "/tmp/tee", targetCommandPattern: /grep/ }))
.use(new CommandCollectorPlugin());
const r2 = await vm.bash.exec(script, { transform: pipeline });
// r2.transformMetadata: { teeFiles: [...], commands: [...] }Bring your own plugin by implementing TransformPlugin:
import type { TransformPlugin, ScriptNode } from "@codebolt/shell";
const myPlugin: TransformPlugin<{ stmtCount: number }> = {
name: "my-plugin",
transform({ ast }) {
return {
ast, // unchanged or rewritten
metadata: { stmtCount: ast.statements.length }
};
}
};Standalone parse + serialize
The parser and serializer are exported on their own — no Vm
required — for tools that just want to manipulate bash scripts as
data:
import { parse, serialize } from "@codebolt/shell";
const ast = parse("for i in 1 2 3; do echo $i; done");
const back = serialize(ast);⚠ Round-trip is "lossy but semantically equivalent" today. Known quirks:
[ -f x ]round-trips with an escaped closing bracket, and${a}_${b}loses the braces around${a}. Both are upstream just-bash issues we inherited; tracked for a future pass.
API surface
Everything exported from the package:
// Core
export { Vm, type VmConfig };
// FileSystem implementations
export { InMemoryFs };
export { OverlayFs };
export { MountableFs };
export { ChunkedVFS, type ChunkedVFSConfig };
export { SqlFs, type SqlFsConfig };
// MetadataStore (directory tree side of ChunkedVFS)
export { InMemoryMetadataStore };
export { SqlMetadataStore, type SqlMetadataStoreConfig };
export type { Inode, MetadataStore };
// BlockStore (blob side of ChunkedVFS)
export { InMemoryBlockStore };
export type { BlockStore };
// SQL backend interface (adapters in consumer code)
export type { SqlBackend, SqlParam, SqlRow };
// FileSystem interface
export type { DirEntry, FileStat, FileSystem };
export { FsError };
// Tools
export { searchText, searchFiles };
export { replaceInFile, replaceInFiles };
export { diff, diffContent };
export type {
SearchOptions, SearchMatch, FileSearchResult,
ReplaceOptions, ReplaceResult, MultiFileReplaceResult
};
// Git
export { Git, type GitAuth, type GitAuthor };
export { createGitFs };
// Lightweight exec runner
export { Exec, defineCommand, type ExecOptions, type RunOptions };
export { tokenize };
export type { Command, CommandContext, ExecResult };
// Vendored bash layer (just-bash 2.14.0, Apache-2.0; src/bash/NOTICE.md)
export { Bash, type BashOptions, type BashExecOptions };
export { Cmd };
export { parse, serialize };
export { BashTransformPipeline };
export { CommandCollectorPlugin, type CommandCollectorMetadata };
export { TeePlugin, type TeePluginOptions, type TeePluginMetadata, type TeeFileInfo };
// Plus the full set of bash AST node types (ScriptNode, StatementNode,
// PipelineNode, SimpleCommandNode, IfNode, ForNode, ...)Architecture
┌─ Vm ─────────────────────────────────────────────────────────┐
│ │
│ vm.fs ── one FileSystem ── any backend │
│ │
│ ┌─ FileSystem (interface) ──────────────────────────────┐ │
│ │ read(File|FileBytes|FileStream) / write(...) │ │
│ │ exists / stat / lstat / readdir / glob │ │
│ │ mkdir / rm / cp / mv / symlink / readlink │ │
│ └────────────────────────────────────────────────────────┘ │
│ ▲ ▲ ▲ ▲ ▲ │
│ │ │ │ │ │ │
│ InMemoryFs OverlayFs MountableFs ChunkedVFS SqlFs │
│ (Map) (lower+ (longest- (metadata (=Chunked │
│ upper+ prefix + blocks) VFS over │
│ tombstone) routing) SqlMeta) │
│ ▲ │
│ ┌─────────────┴─────────────┐ │
│ │ │ │
│ MetadataStore BlockStore │
│ (directory tree, (blob bytes) │
│ inodes, chunk maps) │
│ ▲ ▲ │
│ │ │ │
│ ┌─────────────┴─────┐ ┌────────────┴┐ │
│ InMemoryMetadataStore InMemoryBlockStore │
│ SqlMetadataStore R2BlockStore │
│ (your adapter) │
│ │
│ vm.git ── Git ── createGitFs(this.fs) ── isomorphic-git │
│ vm.searchText / replaceInFile / diff ── helpers over fs │
│ │
│ vm.exec ── Exec ── tokenize + builtins ── small + zero-dep │
│ │
│ vm.cmd ── Cmd ── ~57 vendored just-bash POSIX commands │
│ grep / sed / awk / find / ls / cat / cp / mv / ... │
│ │ │
│ └─ adaptFs(this.fs) ─ IFileSystem shim │
│ │
│ vm.bash ── Bash ── parser + interpreter (vendored) │
│ pipes, redir, expansion, control flow, fns │
│ • shares vm.cmd's registry as the command lookup │
│ • optional transform pipeline (TeePlugin, │
│ CommandCollectorPlugin, custom plugins) │
└───────────────────────────────────────────────────────────────┘The shape that matters: one FileSystem interface, multiple
implementations, all composable, all running over the same Vm. The
two-axis split (metadata + blocks) inside ChunkedVFS lets the
persistent backend mix and match storage independently — D1 metadata
with R2 blocks, in-memory metadata with R2 blocks for tests, etc.
Status and limits
codeboltshell is v0.0.x — the API is stable enough to build
against but not 1.0. Deliberate gaps:
- No symlinks on most backends.
InMemoryFs,ChunkedVFS, andSqlFsthrowFsError("ENOTSUP")onsymlink/readlink. The interface is there for backends that want them. - No transactions across SQL + blocks. A streaming write that
fails mid-flight can leave orphan blocks in the BlockStore. A GC
pass against
bucket.list() − SELECT block_keys FROM ...is the fix; not yet shipped. InMemoryFsis not chunked. It storesMap<path, Uint8Array>and its streams are single-chunk. Chunking only kicks in when you pairChunkedVFSwith a realBlockStore.vm.git.cloneover HTTPS needs a CORS proxy for any git server that isn't CORS-enabled (i.e., GitHub). Same limit as upstreamisomorphic-git.isomorphic-gitis the heaviest dependency (~250 KiB raw, less after tree-shaking). If bundle size becomes a problem, the git surface is a candidate to split into a sibling package.- No real path normalization.
..,.mid-path, and//aren't collapsed by theFileSystemlayer (only by the git fs adapter, where it's needed). If you pass weird paths, you get weird results. writeFileStreamis consumer-driven. It takes aReadableStreamand reads from it. A truly progressivevm.openWriteStream(path) → WritableStreamAPI is future work.- Vendored bash layer is ~58K lines under
src/bash/. It tree-shakes per-file in modern bundlers (esbuild, wrangler) — consumers who only usevm.fsandvm.gitwon't pay for the bash code. - Bash serializer round-trip is lossy in two known cases (escaped
]in[ ... ]tests; dropped braces in${a}_${b}). Inherited from upstream just-bash; tracked. - Skipped vendored commands:
tar,gzip(Node-only deps),file(file-type dep),python3/js-exec/sqlite3(WASM bundles).
License
@codebolt/shell is licensed under the Apache License 2.0. See
LICENSE for the full text and NOTICE.md at the package root for
the third-party attribution summary.
The vendored bash layer under src/bash/ is derived from
just-bash 2.14.0,
copyright Vercel-Labs and contributors, also under Apache-2.0. The
upstream attribution is preserved at src/bash/NOTICE.md and the
license text at src/bash/LICENSE.apache-2.0.
