shell-dsl
v0.0.34
Published
A sandboxed shell-style DSL for running scriptable command pipelines in-process without host OS access
Downloads
1,602
Maintainers
Readme
shell-dsl
A sandboxed shell-style DSL for running scriptable command pipelines where all commands are explicitly registered and executed in-process, without access to the host OS.
import { createShellDSL, createVirtualFS } from "shell-dsl";
import { createFsFromVolume, Volume } from "memfs";
import { builtinCommands } from "shell-dsl/commands";
const vol = new Volume();
vol.fromJSON({ "/data.txt": "foo\nbar\nbaz\n" });
const sh = createShellDSL({
fs: createVirtualFS(createFsFromVolume(vol)),
cwd: "/",
env: { USER: "alice" },
commands: builtinCommands,
});
const count = await sh`cat /data.txt | grep foo | wc -l`.text();
console.log(count.trim()); // "1"Installation
bun add shell-dsl memfsFeatures
- Sandboxed execution — No host OS access; all commands run in-process
- Virtual filesystem — Uses memfs for complete isolation from the real filesystem
- Real filesystem — Optional sandboxed access to real files with path containment and permissions
- Explicit command registry — Only registered commands can execute
- Automatic escaping — Interpolated values are escaped by default for safety
- POSIX-inspired syntax — Pipes, redirects, control flow operators, and more
- Streaming pipelines — Commands communicate via async iteration
- Version control — Built-in VCS with commits, branches, checkout, and diffs on any virtual filesystem
- TypeScript-first — Full type definitions included
Getting Started
Create a ShellDSL instance by providing a virtual filesystem, working directory, environment variables, and a command registry:
import { createShellDSL, createVirtualFS } from "shell-dsl";
import { createFsFromVolume, Volume } from "memfs";
import { builtinCommands } from "shell-dsl/commands";
const vol = new Volume();
const sh = createShellDSL({
fs: createVirtualFS(createFsFromVolume(vol)),
cwd: "/",
env: { USER: "alice", HOME: "/home/alice" },
commands: builtinCommands,
});
const greeting = await sh`echo "Hello, $USER"`.text();
console.log(greeting); // "Hello, alice\n"Output Methods
Every shell command returns a ShellPromise that can be consumed in different formats:
// String output
await sh`echo hello`.text(); // "hello\n"
// Parsed JSON
await sh`cat config.json`.json(); // { key: "value" }
// Async line iterator
for await (const line of sh`cat data.txt`.lines()) {
console.log(line);
}
// Raw Buffer
await sh`cat binary.dat`.buffer(); // Buffer
// Blob
await sh`cat image.png`.blob(); // BlobError Handling
By default, commands with non-zero exit codes throw a ShellError:
import { ShellError } from "shell-dsl";
try {
await sh`cat /nonexistent`;
} catch (err) {
if (err instanceof ShellError) {
console.log(err.exitCode); // 1
console.log(err.stderr.toString()); // "cat: /nonexistent: ..."
console.log(err.stdout.toString()); // ""
}
}Disabling Throws
Use .nothrow() to suppress throwing for a single command:
const result = await sh`cat /nonexistent`.nothrow();
console.log(result.exitCode); // 1Use .throws(boolean) for explicit control:
const result = await sh`cat /nonexistent`.throws(false);Global Throw Setting
Disable throwing globally with sh.throws(false):
sh.throws(false);
const result = await sh`cat /nonexistent`;
console.log(result.exitCode); // 1
// Per-command override still works
await sh`cat /nonexistent`.throws(true); // This throwsPiping
Use | to connect commands. Data flows between commands via async streams:
const result = await sh`cat /data.txt | grep pattern | wc -l`.text();Each command in the pipeline receives the previous command's stdout as its stdin.
Control Flow Operators
Sequential Execution (;)
Run commands one after another, regardless of exit codes:
await sh`echo one; echo two; echo three`.text();
// "one\ntwo\nthree\n"AND Operator (&&)
Run the next command only if the previous one succeeds (exit code 0):
await sh`test -f /config.json && cat /config.json`;OR Operator (||)
Run the next command only if the previous one fails (non-zero exit code):
await sh`cat /config.json || echo "default config"`;Combined Operators
await sh`mkdir -p /out && echo "created" || echo "failed"`;Redirection
Input Redirection (<)
Read stdin from a file:
await sh`cat < /input.txt`.text();Output Redirection (>, >>)
Write stdout to a file:
// Overwrite
await sh`echo "content" > /output.txt`;
// Append
await sh`echo "more" >> /output.txt`;Stderr Redirection (2>, 2>>)
await sh`cmd 2> /errors.txt`; // stderr to file
await sh`cmd 2>> /errors.txt`; // append stderrFile Descriptor Redirects
| Redirect | Effect |
|----------|--------|
| 2>&1 | Redirect stderr to stdout |
| 1>&2 | Redirect stdout to stderr |
| &> | Redirect both stdout and stderr to file |
| &>> | Append both stdout and stderr to file |
// Capture both stdout and stderr
const result = await sh`cmd 2>&1`.text();
// Write both to file
await sh`cmd &> /all-output.txt`;Environment Variables
Variable Expansion
Variables are expanded with $VAR or ${VAR} syntax:
const sh = createShellDSL({
// ...
env: { USER: "alice", HOME: "/home/alice" },
});
await sh`echo $USER`.text(); // "alice\n"
await sh`echo "Home: $HOME"`.text(); // "Home: /home/alice\n"Quoting Semantics
| Quote | Behavior |
|-------|----------|
| "..." | Variables expanded, special chars preserved |
| '...' | Literal string, no expansion |
await sh`echo "Hello $USER"`.text(); // "Hello alice\n"
await sh`echo 'Hello $USER'`.text(); // "Hello $USER\n"Inline Assignment
Assign variables for subsequent commands:
await sh`FOO=bar && echo $FOO`.text(); // "bar\n"Assign variables for a single command (scoped):
await sh`FOO=bar echo $FOO`.text(); // "bar\n"
// FOO is not set after this commandPer-Command Environment
Override environment for a single command:
await sh`echo $CUSTOM`.env({ CUSTOM: "value" }).text();Global Environment
Set environment variables globally:
sh.env({ API_KEY: "secret" });
await sh`echo $API_KEY`.text(); // "secret\n"
sh.resetEnv(); // Restore initial environmentTTY Detection
Commands can check ctx.stdout.isTTY to vary their output format depending on whether they're writing to a terminal or a pipe/file, just like real shell commands do (e.g. ls uses columnar output on a terminal but one-per-line when piped).
Enable TTY mode via the isTTY config option (default false):
const sh = createShellDSL({
fs: createVirtualFS(createFsFromVolume(vol)),
cwd: "/",
env: {},
commands: builtinCommands,
isTTY: true,
});
// Standalone command — stdout.isTTY is true
await sh`ls /dir`.text(); // "file1.txt file2.txt subdir\n"
// Piped command — intermediate stdout.isTTY is always false
await sh`ls /dir | grep file`.text(); // "file1.txt\nfile2.txt\n"| Context | stdout.isTTY |
|---------|----------------|
| Standalone command, shell has isTTY: true | true |
| Intermediate command in pipeline | false |
| Output redirected to file (> file) | false |
| Command substitution ($(cmd)) | false |
| Shell has isTTY: false (default) | false |
Using isTTY in Custom Commands
const myls: Command = async (ctx) => {
const entries = await ctx.fs.readdir(ctx.cwd);
if (ctx.stdout.isTTY) {
await ctx.stdout.writeText(entries.join(" ") + "\n");
} else {
for (const entry of entries) {
await ctx.stdout.writeText(entry + "\n");
}
}
return 0;
};Glob Expansion
Globs are expanded by the interpreter before command execution:
await sh`ls *.txt`; // Matches: a.txt, b.txt, ...
await sh`cat src/**/*.ts`; // Recursive glob
await sh`echo file[123].txt`; // Character classes
await sh`echo {a,b,c}.txt`; // Brace expansion: a.txt b.txt c.txtCommand Substitution
Use $(command) to capture command output:
await sh`echo "Current dir: $(pwd)"`.text();Nested substitution is supported:
await sh`echo "Files: $(ls $(pwd))"`.text();Defining Custom Commands
Commands are async functions that receive a CommandContext and return an exit code (0 = success):
import type { Command } from "shell-dsl";
const hello: Command = async (ctx) => {
const name = ctx.args[0] ?? "World";
await ctx.stdout.writeText(`Hello, ${name}!\n`);
return 0;
};
const sh = createShellDSL({
// ...
commands: { ...builtinCommands, hello },
});
await sh`hello Alice`.text(); // "Hello, Alice!\n"CommandContext Interface
interface CommandContext {
args: string[]; // Command arguments
stdin: Stdin; // Input stream
stdout: Stdout; // Output stream
stderr: Stderr; // Error stream
fs: VirtualFS; // Virtual filesystem
cwd: string; // Current working directory
env: Record<string, string>; // Environment variables
}Stdin Interface
interface Stdin {
stream(): AsyncIterable<Uint8Array>; // Raw byte stream
buffer(): Promise<Buffer>; // All input as Buffer
text(): Promise<string>; // All input as string
lines(): AsyncIterable<string>; // Line-by-line iterator
}Stdout/Stderr Interface
interface Stdout {
write(chunk: Uint8Array): Promise<void>; // Write bytes
writeText(str: string): Promise<void>; // Write UTF-8 string
isTTY: boolean; // Whether output is a terminal
}Example: echo
const echo: Command = async (ctx) => {
await ctx.stdout.writeText(ctx.args.join(" ") + "\n");
return 0;
};Example: cat
Read from stdin or files:
const cat: Command = async (ctx) => {
if (ctx.args.length === 0) {
// Read from stdin
for await (const chunk of ctx.stdin.stream()) {
await ctx.stdout.write(chunk);
}
} else {
// Read from files
for (const file of ctx.args) {
const path = ctx.fs.resolve(ctx.cwd, file);
const content = await ctx.fs.readFile(path);
await ctx.stdout.write(new Uint8Array(content));
}
}
return 0;
};Example: grep
Pattern matching with stdin:
const grep: Command = async (ctx) => {
const pattern = ctx.args[0];
if (!pattern) {
await ctx.stderr.writeText("grep: missing pattern\n");
return 1;
}
const regex = new RegExp(pattern);
let found = false;
for await (const line of ctx.stdin.lines()) {
if (regex.test(line)) {
await ctx.stdout.writeText(line + "\n");
found = true;
}
}
return found ? 0 : 1;
};Example: Custom uppercase command
const upper: Command = async (ctx) => {
const text = await ctx.stdin.text();
await ctx.stdout.writeText(text.toUpperCase());
return 0;
};
// Usage
await sh`echo "hello" | upper`.text(); // "HELLO\n"Error Handling in Custom Commands
Report errors by writing to ctx.stderr and returning a non-zero exit code. The shell wraps non-zero exits in a ShellError (unless .nothrow() is used):
const divide: Command = async (ctx) => {
const a = Number(ctx.args[0]);
const b = Number(ctx.args[1]);
if (isNaN(a) || isNaN(b)) {
await ctx.stderr.writeText("divide: arguments must be numbers\n");
return 1;
}
if (b === 0) {
await ctx.stderr.writeText("divide: division by zero\n");
return 1;
}
await ctx.stdout.writeText(String(a / b) + "\n");
return 0;
};
// ShellError is thrown on non-zero exit
try {
await sh`divide 1 0`.text();
} catch (err) {
err.exitCode; // 1
err.stderr.toString(); // "divide: division by zero\n"
}
// Suppress with nothrow
const { exitCode } = await sh`divide 1 0`.nothrow();Common Patterns
Dual-mode input (stdin vs files): Many commands read from stdin when no file arguments are given, or from files otherwise. See the cat and grep examples above.
Resolving paths: Always resolve relative paths against ctx.cwd:
const path = ctx.fs.resolve(ctx.cwd, ctx.args[0]);
const content = await ctx.fs.readFile(path);Accessing environment variables:
const home = ctx.env["HOME"] ?? "/";Common Pitfalls
- Always register commands in the
commandsobject. Don't try to match command names with regex on raw input — registered commands work correctly in pipelines,&&/||chains, redirections, and subshells. - Always return an exit code. Forgetting
return 0leaves the exit code undefined. - Don't forget trailing newlines. Most shell tools expect lines terminated with
\n. UsewriteText(value + "\n")rather thanwriteText(value).
Built-in Commands
Import all built-in commands:
import { builtinCommands } from "shell-dsl/commands";Or import individually:
import { echo, cat, grep, wc, cp, mv, touch, tee, tree, find, sed, awk, cut } from "shell-dsl/commands";| Command | Description |
|---------|-------------|
| echo | Print arguments to stdout |
| cat | Concatenate files or stdin to stdout |
| grep | Linux-compatible pattern search |
| wc | Count lines, words, or characters (-l, -w, -c) |
| head | Output first lines (-n) |
| tail | Output last lines (-n) |
| sort | Sort lines (-r reverse, -n numeric) |
| uniq | Remove duplicate adjacent lines (-c count) |
| pwd | Print working directory |
| ls | List directory contents (TTY-aware: space-separated on TTY, one-per-line when piped) |
| mkdir | Create directories (-p parents) |
| rm | Remove files/directories (-r recursive, -f force) |
| cp | Copy files/directories (-r recursive, -n no-clobber) |
| mv | Move/rename files/directories (-n no-clobber) |
| touch | Create empty files or update timestamps (-c no-create) |
| tee | Duplicate stdin to stdout and files (-a append) |
| tree | Display directory structure as tree (-a all, -d dirs only, -L <n> depth, -I <pattern> ignore, --prune remove empty dirs) |
| find | Search for files (-name, -iname, -type f\|d, -maxdepth, -mindepth) |
| sed | Stream editor (s///, d, p, -n, -e) |
| awk | Pattern scanning ({print $1}, -F, NF, NR) |
| cut | Select fields/characters (-f, -d, -c, -b, -s, --complement) |
| test / [ | File and string tests (-f, -d, -e, -z, -n, =, !=) |
| true | Exit with code 0 |
| false | Exit with code 1 |
Virtual Filesystem
The VirtualFS interface wraps memfs for sandboxed file operations:
import { createVirtualFS } from "shell-dsl";
import { createFsFromVolume, Volume } from "memfs";
const vol = new Volume();
vol.fromJSON({
"/data.txt": "file content",
"/config.json": '{"key": "value"}',
});
const fs = createVirtualFS(createFsFromVolume(vol));VirtualFS Interface
interface VirtualFS {
// Reading
readFile(path: string): Promise<Buffer>;
readdir(path: string): Promise<string[]>;
stat(path: string): Promise<FileStat>;
exists(path: string): Promise<boolean>;
// Writing
writeFile(path: string, data: Buffer | string): Promise<void>;
appendFile(path: string, data: Buffer | string): Promise<void>;
mkdir(path: string, opts?: { recursive?: boolean }): Promise<void>;
// Deletion
rm(path: string, opts?: { recursive?: boolean; force?: boolean }): Promise<void>;
// Utilities
resolve(...paths: string[]): string;
dirname(path: string): string;
basename(path: string): string;
glob(pattern: string, opts?: { cwd?: string }): Promise<string[]>;
}globVirtualFS Helper
If you're implementing a custom VirtualFS, especially a composite or mounted filesystem, you can reuse globVirtualFS() instead of writing glob traversal yourself:
import { globVirtualFS, type VirtualFS } from "shell-dsl";
class CompositeFileSystem implements VirtualFS {
// ... implement readFile/readdir/stat/etc.
async glob(pattern: string, opts?: { cwd?: string }): Promise<string[]> {
return globVirtualFS(this, pattern, opts);
}
}globVirtualFS() walks the visible virtual tree using only readdir(), stat(), and resolve(), so it works correctly for filesystems that mount different host directories under one virtual namespace.
It supports the same shell-style patterns used by the interpreter:
*.txtfor segment wildcards**/*.tsfor recursive matchesfile-?.mdfor single-character matches{a,b}.jsonfor brace expansion[ab].txtfor character classes
Real Filesystem Access
For scenarios where you need to access the real filesystem with sandboxing, use FileSystem or ReadOnlyFileSystem:
import { createShellDSL, FileSystem } from "shell-dsl";
import { builtinCommands } from "shell-dsl/commands";
// Mount a directory with permission rules
const fs = new FileSystem("./project", {
".env": "excluded", // Cannot read or write
".git/**": "excluded", // Block entire directory
"config/**": "read-only", // Can read, cannot write
"src/**": "read-write", // Full access (default)
});
const sh = createShellDSL({
fs,
cwd: "/",
env: {},
commands: builtinCommands,
});
await sh`cat /src/index.ts`.text(); // Works
await sh`cat /.env`.text(); // Throws: excluded
await sh`echo "x" > /config/app.json`; // Throws: read-onlyPermission Types
| Permission | Read | Write |
|------------|------|-------|
| "read-write" | Yes | Yes |
| "read-only" | Yes | No |
| "excluded" | No | No |
Rule Specificity
When multiple rules match, the most specific wins:
- More path segments:
a/b/cbeatsa/b - Literal beats wildcard:
config/app.jsonbeatsconfig/* - Single wildcard beats double:
src/*beatssrc/**
const fs = new FileSystem("./project", {
"**": "read-only", // Default: read-only
"src/**": "read-write", // Override for src/
"src/generated/**": "excluded", // But not generated files
});ReadOnlyFileSystem
Convenience class that defaults all paths to read-only:
import { ReadOnlyFileSystem } from "shell-dsl";
const fs = new ReadOnlyFileSystem("./docs");
// All writes blocked by default
await fs.writeFile("/file.txt", "x"); // Throws: read-only
// Can still exclude or allow specific paths
const fs2 = new ReadOnlyFileSystem("./docs", {
"drafts/**": "read-write", // Allow writes here
".internal/**": "excluded", // Block completely
});Full System Access
Omit the mount path for unrestricted access, but this is the same as just passing fs from node:fs:
const fs = new FileSystem(); // Full filesystem access same as fs from node:fsWeb Filesystem
Use WebFileSystem when you already have a FileSystemDirectoryHandle in the browser, including an OPFS root from navigator.storage.getDirectory():
import { WebFileSystem } from "shell-dsl";
const root = await navigator.storage.getDirectory();
const fs = new WebFileSystem(root, {
"secrets/**": "excluded",
"docs/**": "read-only",
});For advanced use, you can inject the web adapter into FileSystem directly:
import { FileSystem, createWebUnderlyingFS } from "shell-dsl";
const root = await navigator.storage.getDirectory();
const fs = new FileSystem("/", {}, createWebUnderlyingFS(root));Version Control
VersionControlSystem adds git-like version control to any VirtualFS. It tracks changes as diffs, supports branching, and stores metadata in a .vcs directory.
Ignore and attribute rules are configured directly on the constructor:
import { VersionControlSystem, createVirtualFS } from "shell-dsl";
import { createFsFromVolume, Volume } from "memfs";
const vol = new Volume();
vol.fromJSON({
"/project/src/index.ts": 'console.log("hello")',
"/project/README.md": "# My Project",
});
const fs = createVirtualFS(createFsFromVolume(vol));
const vcs = new VersionControlSystem({
fs,
path: "/project",
ignore: ["dist", "*.log"],
attributes: [
{ pattern: "assets/*.png", diff: "binary" },
{ pattern: "secrets/**", diff: "none" },
],
});Ignore patterns apply only to untracked paths:
- Ignored untracked files are skipped by
status()and fullcommit() - Files already tracked by VCS remain tracked even if they later match an ignore rule
- Full
checkout()preserves ignored untracked files
Attribute rules are applied in declaration order, with later matches winning. Supported properties:
binary?: booleandiff?: "text" | "binary" | "none"
Committing Changes
// Commit all pending changes
const rev = await vcs.commit("initial commit");
// Selective commit with glob patterns (relative to root path)
await vcs.commit("update src only", { paths: ["/src/**"] });Checking Status
status() returns a DiffEntry[] describing uncommitted changes:
const changes = await vcs.status();
for (const entry of changes) {
console.log(entry.type, entry.path, entry.diff, entry.binary);
// "add" | "modify" | "delete", "text" | "binary" | "none", boolean
}When diff is "none", the entry still reports the path and change type, but omits content and previousContent.
Checkout
// Checkout a specific revision (errors if working tree is dirty)
await vcs.checkout(1);
// Force checkout, discarding uncommitted changes
await vcs.checkout(1, { force: true });
// Partial checkout — restore specific files without changing HEAD
await vcs.checkout(1, { paths: ["/src/index.ts", "/**/*.txt"] });Branching
// Create a branch at HEAD
await vcs.branch("feature");
// Switch to a branch
await vcs.checkout("feature");
// List all branches
const branches = await vcs.branches();
// [{ name: "main", revision: 1, current: false },
// { name: "feature", revision: 1, current: true }]History and Diffs
// Revision history
const entries = await vcs.log();
const filtered = await vcs.log({ path: "src/index.ts", limit: 10 });
// Diff between two revisions
const diff = await vcs.diff(1, 2);
for (const entry of diff) {
console.log(entry.type, entry.path, entry.diff);
}
// Current HEAD info
const head = await vcs.head();
// { branch: "main", revision: 2 }Separate VCS Storage
By default, metadata lives in {path}/.vcs. You can store it on a different filesystem:
const vcs = new VersionControlSystem({
fs: workingTreeFs,
path: "/project",
vcsPath: {
fs: metadataFs, // different VirtualFS instance
path: "/meta/.vcs", // custom location
},
});Low-Level API
For advanced use cases (custom tooling, AST inspection):
// Tokenize shell source
const tokens = sh.lex("cat foo | grep bar");
// Parse tokens into AST
const ast = sh.parse(tokens);
// Compile AST to executable program
const program = sh.compile(ast);
// Execute a compiled program
const result = await sh.run(program);Manual Escaping
sh.escape("hello world"); // "'hello world'"
sh.escape("$(rm -rf /)"); // "'$(rm -rf /)'"
sh.escape("safe"); // "safe"Raw Escape Hatch
Bypass escaping for trusted input:
await sh`echo ${{ raw: "$(date)" }}`.text();Warning: Use { raw: ... } with extreme caution when handling untrusted input.
Safety & Security
- No host access — All commands run in-process against a virtual filesystem
- Automatic escaping — Interpolated values are escaped by default
- Explicit command registry — Only registered commands can execute
- No shell spawning — Never invokes
/bin/shor similar
The { raw: ... } escape hatch exists for advanced use cases but should be used with extreme caution.
TypeScript Types
Key exported types:
import type {
Command,
CommandContext,
Stdin,
Stdout,
Stderr,
VirtualFS,
FileStat,
ExecResult,
ShellConfig,
RawValue,
Permission,
PermissionRules,
UnderlyingFS,
VCSConfig,
Revision,
DiffEntry,
LogEntry,
BranchInfo,
} from "shell-dsl";Running Tests
bun testTypecheck
bun run typecheckLicense
MIT
