A sandboxed shell-style DSL for running scriptable command pipelines in-process without host OS access
npm install shell-dslA 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.
``ts
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 shcat /data.txt | grep foo | wc -l.text();`
console.log(count.trim()); // "1"
`bash`
bun add shell-dsl memfs
- 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
- TypeScript-first — Full type definitions included
Create a ShellDSL instance by providing a virtual filesystem, working directory, environment variables, and a command registry:
`ts
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 shecho "Hello, $USER".text();`
console.log(greeting); // "Hello, alice\n"
Every shell command returns a ShellPromise that can be consumed in different formats:
`tsecho hello
// String output
await sh.text(); // "hello\n"
// Parsed JSON
await shcat config.json.json(); // { key: "value" }
// Async line iterator
for await (const line of shcat data.txt.lines()) {
console.log(line);
}
// Raw Buffer
await shcat binary.dat.buffer(); // Buffer
// Blob
await shcat image.png.blob(); // Blob`
By default, commands with non-zero exit codes throw a ShellError:
`ts
import { ShellError } from "shell-dsl";
try {
await shcat /nonexistent;`
} catch (err) {
if (err instanceof ShellError) {
console.log(err.exitCode); // 1
console.log(err.stderr.toString()); // "cat: /nonexistent: ..."
console.log(err.stdout.toString()); // ""
}
}
Use .nothrow() to suppress throwing for a single command:
`tscat /nonexistent
const result = await sh.nothrow();`
console.log(result.exitCode); // 1
Use .throws(boolean) for explicit control:
`tscat /nonexistent
const result = await sh.throws(false);`
Disable throwing globally with sh.throws(false):
`tscat /nonexistent
sh.throws(false);
const result = await sh;
console.log(result.exitCode); // 1
// Per-command override still works
await shcat /nonexistent.throws(true); // This throws`
Use | to connect commands. Data flows between commands via async streams:
`tscat /data.txt | grep pattern | wc -l
const result = await sh.text();`
Each command in the pipeline receives the previous command's stdout as its stdin.
Run commands one after another, regardless of exit codes:
`tsecho one; echo two; echo three
await sh.text();`
// "one\ntwo\nthree\n"
Run the next command only if the previous one succeeds (exit code 0):
`tstest -f /config.json && cat /config.json
await sh;`
Run the next command only if the previous one fails (non-zero exit code):
`tscat /config.json || echo "default config"
await sh;`
`tsmkdir -p /out && echo "created" || echo "failed"
await sh;`
Read stdin from a file:
`tscat < /input.txt
await sh.text();`
Write stdout to a file:
`tsecho "content" > /output.txt
// Overwrite
await sh;
// Append
await shecho "more" >> /output.txt;`
`tscmd 2> /errors.txt
await sh; // stderr to filecmd 2>> /errors.txt
await sh; // append stderr`
| 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 |
`tscmd 2>&1
// Capture both stdout and stderr
const result = await sh.text();
// Write both to file
await shcmd &> /all-output.txt;`
Variables are expanded with $VAR or ${VAR} syntax:
`ts
const sh = createShellDSL({
// ...
env: { USER: "alice", HOME: "/home/alice" },
});
await shecho $USER.text(); // "alice\n"echo "Home: $HOME"
await sh.text(); // "Home: /home/alice\n"`
| Quote | Behavior |
|-------|----------|
| "..." | Variables expanded, special chars preserved |'...'
| | Literal string, no expansion |
`tsecho "Hello $USER"
await sh.text(); // "Hello alice\n"echo 'Hello $USER'
await sh.text(); // "Hello $USER\n"`
Assign variables for subsequent commands:
`tsFOO=bar && echo $FOO
await sh.text(); // "bar\n"`
Assign variables for a single command (scoped):
`tsFOO=bar echo $FOO
await sh.text(); // "bar\n"`
// FOO is not set after this command
Override environment for a single command:
`tsecho $CUSTOM
await sh.env({ CUSTOM: "value" }).text();`
Set environment variables globally:
`tsecho $API_KEY
sh.env({ API_KEY: "secret" });
await sh.text(); // "secret\n"
sh.resetEnv(); // Restore initial environment
`
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):
`ts
const sh = createShellDSL({
fs: createVirtualFS(createFsFromVolume(vol)),
cwd: "/",
env: {},
commands: builtinCommands,
isTTY: true,
});
// Standalone command — stdout.isTTY is true
await shls /dir.text(); // "file1.txt file2.txt subdir\n"
// Piped command — intermediate stdout.isTTY is always false
await shls /dir | grep file.text(); // "file1.txt\nfile2.txt\n"`
| Context | stdout.isTTY |isTTY: true
|---------|----------------|
| Standalone command, shell has | true |false
| Intermediate command in pipeline | |> file
| Output redirected to file () | false |$(cmd)
| Command substitution () | false |isTTY: false
| Shell has (default) | false |
`ts`
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;
};
Globs are expanded by the interpreter before command execution:
`tsls *.txt
await sh; // Matches: a.txt, b.txt, ...cat src/*/.ts
await sh; // Recursive globecho file[123].txt
await sh; // Character classesecho {a,b,c}.txt
await sh; // Brace expansion: a.txt b.txt c.txt`
Use $(command) to capture command output:
`tsecho "Current dir: $(pwd)"
await sh.text();`
Nested substitution is supported:
`tsecho "Files: $(ls $(pwd))"
await sh.text();`
Commands are async functions that receive a CommandContext and return an exit code (0 = success):
`ts
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 shhello Alice.text(); // "Hello, Alice!\n"`
`ts`
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
}
`ts`
interface Stdin {
stream(): AsyncIterable
buffer(): Promise
text(): Promise
lines(): AsyncIterable
}
`ts`
interface Stdout {
write(chunk: Uint8Array): Promise
writeText(str: string): Promise
isTTY: boolean; // Whether output is a terminal
}
`ts`
const echo: Command = async (ctx) => {
await ctx.stdout.writeText(ctx.args.join(" ") + "\n");
return 0;
};
Read from stdin or files:
`ts`
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;
};
Pattern matching with stdin:
`ts
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;
};
`
`ts
const upper: Command = async (ctx) => {
const text = await ctx.stdin.text();
await ctx.stdout.writeText(text.toUpperCase());
return 0;
};
// Usage
await shecho "hello" | upper.text(); // "HELLO\n"`
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):
`ts
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 shdivide 1 0.text();
} catch (err) {
err.exitCode; // 1
err.stderr.toString(); // "divide: division by zero\n"
}
// Suppress with nothrow
const { exitCode } = await shdivide 1 0.nothrow();`
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:
`ts`
const path = ctx.fs.resolve(ctx.cwd, ctx.args[0]);
const content = await ctx.fs.readFile(path);
Accessing environment variables:
`ts`
const home = ctx.env["HOME"] ?? "/";
- Always register commands in the commands object. Don't try to match command names with regex on raw input — registered commands work correctly in pipelines, &&/|| chains, redirections, and subshells.return 0
- Always return an exit code. Forgetting leaves the exit code undefined.\n
- Don't forget trailing newlines. Most shell tools expect lines terminated with . Use writeText(value + "\n") rather than writeText(value).
Import all built-in commands:
`ts`
import { builtinCommands } from "shell-dsl/commands";
Or import individually:
`ts`
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 depth) |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 |
The VirtualFS interface wraps memfs for sandboxed file operations:
`ts
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));
`
`ts
interface VirtualFS {
// Reading
readFile(path: string): Promise
readdir(path: string): Promise
stat(path: string): Promise
exists(path: string): Promise
// Writing
writeFile(path: string, data: Buffer | string): Promise
appendFile(path: string, data: Buffer | string): Promise
mkdir(path: string, opts?: { recursive?: boolean }): Promise
// Deletion
rm(path: string, opts?: { recursive?: boolean; force?: boolean }): Promise
// Utilities
resolve(...paths: string[]): string;
dirname(path: string): string;
basename(path: string): string;
glob(pattern: string, opts?: { cwd?: string }): Promise
}
`
For scenarios where you need to access the real filesystem with sandboxing, use FileSystem or ReadOnlyFileSystem:
`ts
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 shcat /src/index.ts.text(); // Workscat /.env
await sh.text(); // Throws: excludedecho "x" > /config/app.json
await sh; // Throws: read-only`
| Permission | Read | Write |
|------------|------|-------|
| "read-write" | Yes | Yes |"read-only"
| | Yes | No |"excluded"
| | No | No |
When multiple rules match, the most specific wins:
1. More path segments: a/b/c beats a/bconfig/app.json
2. Literal beats wildcard: beats config/*src/
3. Single wildcard beats double: beats src/*
`ts`
const fs = new FileSystem("./project", {
"**": "read-only", // Default: read-only
"src/**": "read-write", // Override for src/
"src/generated/**": "excluded", // But not generated files
});
Convenience class that defaults all paths to read-only:
`ts
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
});
`
Omit the mount path for unrestricted access, but this is the same as just passing fs from node:fs:
`ts`
const fs = new FileSystem(); // Full filesystem access same as fs from node:fs
For advanced use cases (custom tooling, AST inspection):
`ts
// 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);
`
`ts`
sh.escape("hello world"); // "'hello world'"
sh.escape("$(rm -rf /)"); // "'$(rm -rf /)'"
sh.escape("safe"); // "safe"
Bypass escaping for trusted input:
`tsecho ${{ raw: "$(date)" }}
await sh.text();`
Warning: Use { raw: ... } with extreme caution when handling untrusted input.
1. No host access — All commands run in-process against a virtual filesystem
2. Automatic escaping — Interpolated values are escaped by default
3. Explicit command registry — Only registered commands can execute
4. No shell spawning — Never invokes /bin/sh or similar
The { raw: ... } escape hatch exists for advanced use cases but should be used with extreme caution.
Key exported types:
`ts`
import type {
Command,
CommandContext,
Stdin,
Stdout,
Stderr,
VirtualFS,
FileStat,
ExecResult,
ShellConfig,
RawValue,
Permission,
PermissionRules,
UnderlyingFS,
} from "shell-dsl";
`bash`
bun test
`bash``
bun run typecheck
MIT