Parse bash scripts into an AST, run transform plugins, and serialize back to bash.
Input Script --> parse() --> AST --> plugins --> serialize() --> Output Script
BashTransformPipeline is a standalone typed pipeline builder. It does not depend on Bash for execution — it only parses, transforms, and serializes. The output is a plain bash string that can be executed by any shell, child_process.exec, Docker container, SSH session, or any other runtime.
Each .use() call intersects the plugin's metadata type into the result:
import { BashTransformPipeline, TeePlugin, CommandCollectorPlugin } from "just-bash";
import { execSync } from "node:child_process";
const pipeline = new BashTransformPipeline()
.use(new TeePlugin({ outputDir: "/tmp/logs" }))
.use(new CommandCollectorPlugin());
// Transform the script — no execution happens here
const result = pipeline.transform("echo hello | grep hello");
result.script; // transformed bash string, ready to execute anywhere
result.metadata.teeFiles; // TeeFileInfo[] ← typed!
result.metadata.commands; // string[] ← typed!
// Execute with a real shell
execSync(result.script);
// Or pass to any other runtime
// await ssh.exec(result.script);
// await docker.exec(["bash", "-c", result.script]);Standalone function. Converts a ScriptNode AST back to a bash string:
import { parse, serialize } from "just-bash";
const ast = parse("echo hello | cat");
const script = serialize(ast); // "echo hello | cat"The serializer targets functional equivalence, not whitespace-exact round-tripping. The invariant is:
parse(serialize(parse(input))) === parse(input)
Register plugins directly on a Bash instance. When plugins are registered, exec() automatically applies them before execution and returns metadata in the result:
import { Bash, CommandCollectorPlugin, TeePlugin } from "just-bash";
const bash = new Bash();
bash.registerTransformPlugin(new TeePlugin({ outputDir: "/tmp/logs" }));
bash.registerTransformPlugin(new CommandCollectorPlugin());
// exec() applies transforms automatically and returns metadata
const result = await bash.exec("echo hello | grep hello");
result.metadata?.commands; // ["echo", "exit", "grep", "tee"]
// transform() is also available for transform-only (no execution)
const transformed = bash.transform("echo hello | grep hello");
transformed.script; // re-serialized bash string
transformed.metadata; // merged metadata from all pluginsWhen transform plugins are registered, exec() parses the script, runs all plugins on the AST, executes the transformed AST, and returns the result with metadata attached.
| Field | Type | Description |
|---|---|---|
stdout |
string |
Standard output |
stderr |
string |
Standard error |
exitCode |
number |
Exit code |
env |
Record<string, string> |
Environment after execution |
metadata |
Record<string, unknown> | undefined |
Merged metadata from all plugins (only set when plugins are registered) |
Parses the script, runs all registered plugins in sequence, and serializes the final AST back to a bash string. Returns:
| Field | Type | Description |
|---|---|---|
script |
string |
Re-serialized bash script |
ast |
ScriptNode |
Final transformed AST |
metadata |
Record<string, unknown> |
Merged metadata from all plugins |
A plugin implements the TransformPlugin<TMetadata> interface:
import type { TransformPlugin, TransformContext, TransformResult } from "just-bash";
interface MyMetadata {
myKey: string;
}
const myPlugin: TransformPlugin<MyMetadata> = {
name: "my-plugin",
transform(context: TransformContext): TransformResult<MyMetadata> {
// context.ast - current AST (ScriptNode)
// context.metadata - accumulated metadata from prior plugins
return {
ast: context.ast, // required: return the (possibly modified) AST
metadata: { myKey: "value" }, // optional: merged into metadata for next plugin
};
},
};Plugins are synchronous. Each plugin receives the AST and metadata output by the previous plugin.
Key types for walking/transforming the AST (all exported from just-bash):
| Type | Description |
|---|---|
ScriptNode |
Root node: list of statements |
StatementNode |
Pipelines joined by && / ` |
PipelineNode |
Commands joined by | |
CommandNode |
Union: SimpleCommandNode | CompoundCommand | FunctionDefNode |
SimpleCommandNode |
name args... [redirections] with assignments |
WordNode |
A shell word made of WordPart[] |
The full set of AST types is defined in src/ast/types.ts.
Captures stdout from each command in a pipeline by inserting tee commands. Only wraps commands that are already in pipelines (2+ commands) — standalone commands are never modified.
The transformed script is valid standard bash and can be executed by /bin/bash, child_process.exec, Docker, SSH, or any other runtime.
import { TeePlugin } from "just-bash";
// Capture stdout from all pipeline commands
new TeePlugin({ outputDir: "/tmp/logs" });
// Only capture specific commands
new TeePlugin({
outputDir: "/tmp/logs",
targetCommandPattern: /^grep$/,
});
// Use a fixed timestamp (useful for testing)
new TeePlugin({
outputDir: "/tmp/logs",
timestamp: new Date("2024-01-15T10:30:45.123Z"),
});Options:
| Option | Type | Description |
|---|---|---|
outputDir |
string |
Directory for output files |
targetCommandPattern |
{ test(input: string): boolean } |
Filter which commands to wrap (default: all) |
timestamp |
Date |
Fixed timestamp (default: new Date()) |
Filename format: {isoTimestamp}-{3-digit-index}-{commandName}.stdout.txt
Colons in ISO timestamps are replaced with - for filesystem safety: 2024-01-15T10-30-45.123Z
Metadata: Returns TeePluginMetadata with a teeFiles array of TeeFileInfo:
interface TeeFileInfo {
commandIndex: number; // Global counter across the entire script
commandName: string; // e.g. "echo", "grep", "unknown"
command: string; // Full command with arguments, e.g. "grep -r pattern src/"
stdoutFile: string; // Full path to stdout capture file
}Semantics preservation: The plugin is designed to produce zero observable differences compared to the original script:
- Standalone commands (
echo hello,cd /tmp,read x,VAR=val) are never wrapped. Only commands already in pipelines are modified, since they already run in subshell-like contexts. - Exit codes are restored via PIPESTATUS save+restore. After the wrapped pipeline, a dummy pipeline
(exit $saved0) | (exit $saved1)reconstructs the original PIPESTATUS array and sets$?to the correct value. |&pipes are preserved. The original pipe type is used forcmd |& tee(so tee captures stderr too), and a regular pipe is used fortee | next_cmd.&&/||chains work correctly because PIPESTATUS restoration feeds the correct exit code into the chain operator.- stderr flows through normally — no stderr redirections are added.
Transform examples:
echo hello
--> echo hello (standalone: not wrapped)
echo hello | grep foo
--> echo hello | tee /tmp/logs/...-000-echo.stdout.txt
| grep foo | tee /tmp/logs/...-001-grep.stdout.txt
; __tps0=${PIPESTATUS[0]} __tps1=${PIPESTATUS[2]}
; (exit $__tps0) | (exit $__tps1)
cd /tmp; VAR=hello
--> cd /tmp; VAR=hello (standalone: not wrapped)
echo hello | grep foo && echo found
--> echo hello | tee ... | grep foo | tee ...
; __tps0=... __tps1=...
; (exit $__tps0) | (exit $__tps1)
&& echo found (standalone: not wrapped)
Known limitation: shopt -s lastpipe
When lastpipe is enabled, bash runs the last command of a pipeline in the current shell (not a subshell). The tee plugin inserts tee after the last command, making the original last command no longer last. This changes its execution context from current-shell to subshell.
This is not fixable at AST transform time because lastpipe is a runtime shopt setting not visible in the AST. lastpipe is off by default and rarely used.
Walks the entire AST and collects all command names into sorted metadata. Does not modify the AST.
import { BashTransformPipeline, CommandCollectorPlugin } from "just-bash";
const pipeline = new BashTransformPipeline()
.use(new CommandCollectorPlugin());
const result = pipeline.transform(`
if true; then
echo $(cat file | wc -l)
fi
`);
result.metadata.commands; // ["cat", "echo", "true", "wc"]Metadata: Returns CommandCollectorMetadata with a commands array of sorted unique command names.
Walks into: compound command bodies (if/for/while/case/subshell/group), function definitions, command substitutions ($(...) and `...`), and process substitutions.
The serializer handles all AST node types produced by the parser:
- Commands: simple commands, all compound commands (
if/for/while/until/case/subshell/group), function definitions, arithmetic commands((...)), conditional commands[[ ]] - Words: literals, single/double quotes, escapes, parameter expansion (all 15+ operations), command substitution, arithmetic expansion, brace expansion, tilde expansion, globs, process substitution
- Redirections: all operators (
<,>,>>,>&,<&,<>,>|,&>,&>>,<<<), heredocs (<<,<<-), fd variables - Arithmetic: all expression types (binary, unary, ternary, assignment, grouping, array elements, dynamic expressions)
- Conditionals: binary/unary tests,
&&/||/!, grouping
src/transform/
types.ts -- TransformPlugin, TransformContext, etc. (generic)
pipeline.ts -- BashTransformPipeline typed builder
serialize.ts -- AST -> bash string
serialize.test.ts -- round-trip tests
transform.test.ts -- plugin + pipeline + integration tests
plugins/
tee-plugin.ts -- Per-command stdout capture via tee
command-collector.ts -- Command name extraction