This is a utility library for using TypeScript instead of shell scripts. It's not as lovable as Bun Shell, but it runs on Bun, Deno, and Node.js 24.2+.
import { add, banner, execute, runQuiet, switchGhAuth, validate } from '@axhxrx/script';
const tag = 'v0.1.0';
validate('On main branch', () => {
const branch = runQuiet('git branch --show-current').trim();
return branch === 'main' || `Expected main, on '${branch}'`;
});
banner('Release Prep');
add('deno install && deno check && deno lint')
.description('Deno: check types & lint');
add('bun install && bun test ')
.description('Bun: run test suite');
add(`gh release create ${tag} --draft --generate-notes`)
.description(`Create draft GitHub release ${tag}`)
.confirm(`Create draft release ${tag}?`)
.or(switchGhAuth)
.and(`gh release create ${tag} --draft --generate-notes`);
await execute();Run that script:
./bin./create-release.ts
🔍 Running validations...
○ On main branch... ✓
📋 Execution Plan
── Release Prep ──
1. Deno: check types & lint
└─ deno install && deno check && deno lint
2. Bun: run test suite
└─ bun install && bun test
3. Create draft GitHub release v0.1.0 (confirm: skippable, has fallback)
└─ gh release create v0.1.0 --draft --generate-notes
Total: 3 steps
Proceed with execution? [Y/n]:
...and the rest goes how you'd expect.
The weakest case for using this library is if you were already going to write your script in TypeScript. So let's start there.
If your script is roughly:
await doThis();
await doThat();
if (something()) await doThirdThing();then you might not benefit from this. You'd have to e.g. do:
add(doThis);
add(doThat);
if (something()) add(doThirdThing);
await execute();So that's some additional stuff to think about. You have to add() all the steps to your plan, and then execute() the plan. What you get in return is:
- Plan preview + confirmation before anything runs.
--dry-runis free. - Consistent, structured output — banners, per-step timing, unambiguous pass/fail.
- Auto-logged run artifacts with system context, captured in stdout and (optionally) written to a log file via
.file()or--auto-log-to. - Mix shell commands and TypeScript functions in the same plan, when you inevitably end up needing both.
- Composable failure handling:
.or(),.and(),.skipIf(),.confirm(),.onError('warn')— the basic knobs are there whenever you need them. - Consistent CLI surface: the standard arg parsing is free, and always works the same way.
TL;DR — if your script is just three await function calls and it won't ever grow? You don't need this. But if it's something that has to be maintained over time, those benefits may justify the small amount of additional ceremony. Or not! ¯\_(ಠ_ಠ)_/¯
OTOH, if you were going to write an actual shell script...
Shell scripts are great, until they suck. They're easy to get started with — just add some commands! But as soon as you need an if or a loop you begin the descent into madness. Five seconds later, you are staring at:
VAL=$(grep -o '"'"$1"'":"[^"]*"' file.json | sed 's/"'"$1"'":"\([^"]*\)"/\1/') && [ -n "$VAL" ] && export "$1"="$VAL" || export "$1"="${2:-$(cat /dev/stdin 2>/dev/null || echo '')}"Shell scripts are like baby pythons. 🐍 Cute when little, but they tend to live a long time, keep growing, and finally crush you to death in your sleep and then swallow your corpse whole.
I know, right? 99.164% of shell scripts written since 2020 shouldn't have been. But old habits die hard. Also, out of the box, runtime-agnostic TypeScript isn't exactly pithy for scripts that mostly just execute commands.
I mean, which is better:
hostname=$(hostname)vs
let hostname: string;
try {
const result = execSync("hostname", {
encoding: "utf-8",
stdio: "pipe",
});
hostname = result.trim();
} catch (error: unknown) {
hostname = "";
}You can easily write a couple functions to make that more pleasant, but for the "zero to executing a couple shell commands", TypeScript hasn't always given us ergonmic ways to do it.
Bun Shell is actually pretty great, and if you are OK with Bun-only, it's probably a better alternative to shell scripts than this library is.
Not as cute and concise as Bun Shell, but it works on every modern TypeScript runtime — Bun, Deno, and even less-modern runtimes like Node.js 24.2+.
The point is to just make it more ergonomic to write your build scripts and deploy scripts and whatever scripts in TypeScript, and never write another shell script again.
import { add, execute } from "@axhxrx/script";
add("deno check");
add("deno lint");
add("bun test");
add("dprint fmt **/*.ts");
await execute();Or, if you prefer:
const steps = `
deno check
deno lint
bun test
dprint fmt **/*.ts
`;
add(steps);
await execute({ yes: true });Or, if you are a bona-fide O.G. radguy warez kingpin, and you love OOP:
import { Script } from "@axhxrx/script";
const s = new Script();
s.add(`
deno check
deno lint
bun test
dprint fmt **/*.ts
`);
await s.execute();Those are all equivalent, and it's obvious at a glance what this script code will do.
Before doing it, though, by default that code will confirm the plan before executing it:
📋 Execution Plan
1. deno check
2. deno lint
3. bun test
4. dprint fmt **/*.ts
Total: 4 steps
Proceed with execution? [Y/n]:Use execute({ yes: true }); to skip the confirmation. By default, execute() parses --yes/-y, --dry-run/--dryRun, and --auto-log-to <dir> from the command line args. Pass execute({ parseArgs: false }) to opt out.
OK, fine. But the above still isn't really any better than this bash script:
deno check
deno lint
bun test
dprint fmt **/*.tsSo, what's the point? Well, the benefits of this library start to make themselves apparent when you need to add conditional logic, both during execution and maybe also to decide what to execute. Or add pre-flight validation steps. Or make some steps conditional based on the results of previous steps. Or support standard things like --dry-run or -y arguments.
// add() just adds steps to the plan, they won't
// run until you call execute(). So you can use
// if/else to conditionally add steps, without
// ending up with partially executed steps.
if (args.gcloudAuth) {
banner("🔐 AUTHENTICATE WITH GCLOUD");
// steps can be modified via builder-pattern methods:
add("gcloud auth login")
.description("Authenticate with gcloud")
.interactive()
.onError("warn")
.cwd("~")
.confirm("⚠️ May cause computer explosion. Are you sure?", true)
.canSkip(false)
.validate(() => {
return doSomething();
});
}| Method | Description |
|---|---|
.description() |
Optional user-friendly description shows up when executing, in addition to the raw command |
.interactive() |
Inherit stdin so the user can type in the auth code |
.onError() |
User might cancel this step, it's ok, don't quit |
.cwd() |
Set cwd for this step |
.confirm() |
Optional per-step confirmation for dangerous steps |
.canSkip() |
Should execution keep going even if confirm() answer is no? |
.validate() |
Step-level validation executes right before the step is executed (and thus can depend on the results of previous steps). |
// You can also schedule script-level validations any time before calling execute(). All validations run before any steps are executed. This is for pre-flight sanity-checking.
validate("No bombs detected", async () => {
const somebody = await mainScreenTurnOn();
return !somebody.seUpUsTheBomb; // we're good to go
});add(`echo "Working in $(pwd)..."`);
// Inline function step:
add(() => {
const branch =
runQuiet("git branch --show-current").trim() || "(detached HEAD)";
console.log(`Branch: ${branch}`);
}).description("Show current branch");
// Named function step:
add(summarizeLocalChanges).description("Summarize local changes");
add("git log --oneline -3").description("Show recent commits");
await execute();Shell scripts have || and &&. This library has .or() and .and():
// If push fails (e.g. wrong auth), switch accounts and retry
add('git push origin main')
.description('Push to remote')
.or('gh auth switch') // runs only if push fails
.and('git push origin main') // runs only if auth switch succeededChains are a flat linked list -- each .or() or .and() attaches to the previous step and returns a builder for the new one. There's no precedence or nesting:
add('git push origin main') // A
.or('gh auth switch') // B: runs if A fails
.and('git push origin main') // C: runs if B succeeded
.and('echo "All done"') // D: runs if C succeededThe execution walks the chain left to right: A → (fail?) → B → (ok?) → C → (ok?) → D.
Important: this is not the same as shell's A || B && C. In shell, C runs after either A or B succeeds. Here, each link only activates for the matching outcome of the step it's attached to. If A has an .or() link and A succeeds, the chain stops -- C never runs. The .or() path (and everything after it) is only reached when A fails.
The execution plan shows chains with visual indicators:
1. Push to remote
└─ git push origin main
↩️ .or() gh auth switch
↪️ .and() git push origin main
↪️ .and() echo "All done"
Log command output to files, with optional timestamps and auto-redaction of secrets:
import { createScript } from '@axhxrx/script'
const script = createScript()
// Script-level: capture everything
await script.file({ path: './build.log', timestamps: true })
// Step-level: log just this step's output
script.add('npm test')
.file({ path: './test.log', output: 'command', redact: 'auto' })
await script.execute()You can automatically log all script output to a timestamped file in a directory, via CLI arg or env var:
# Via CLI argument
./deploy.ts --auto-log-to ./logs --yes
# Via environment variable
SCRIPT_AUTO_LOG_TO=./logs ./deploy.ts --yesBoth create a file like ./logs/2026-04-02T12-30-00-000Z-deploy.log with full output and timestamps. The CLI arg takes precedence over the env var. If script.file() was called explicitly, both are ignored.
The log file header includes system info (invocation command, username, hostname, IP, platform, runtime version, and kernel info) for audit and debugging.
The library also exports helpers for common scripting tasks:
| Function | Description |
|---|---|
run(cmd) |
Execute a shell command, stream output to terminal |
runQuiet(cmd) |
Execute silently, return output as string |
parseScriptArgs() |
Parse --dry-run, --yes/-y, and --auto-log-to from CLI args |
autoRedact(text) |
Redact common secrets (API keys, tokens, passwords) |
promptYesNo(question) |
Interactive yes/no prompt |
promptForValue(question) |
Interactive text input prompt |
getGhAuthUsername() |
Get current gh CLI authenticated user |
switchGhAuth() |
Switch gh CLI auth account |
getGitConfig(key) |
Read a git config value |
setGitConfig(key, value) |
Write a git config value |
getFileInfo(path) |
Get file name, content, SHA-256 hash, and size |
assertCwd(expected) |
Safety check: ensure cwd matches before dangerous ops |
# Bun
bunx jsr add @axhxrx/script
# pnpm
pnpm i jsr:@axhxrx/script
# npm
npx jsr add @axhxrx/script
# Deno
deno add jsr:@axhxrx/scriptWith Deno, you can alternatively just import it from JSR without adding it to your project (cool):
import * as script from "jsr:@axhxrx/script";- Supported runtimes: Bun, Deno, and Node.js 24.2+.
- Live command-output capture uses a
bash+teepipeline. - If
bashorteeis not available onPATH, Unix capture/file logging will fail with an explicit error. - The test suite runs on all three runtimes. Use
bun run test:bun,bun run test:node,bun run test:deno, orbun run testto run all three in sequence. The tests use @axhxrx/test to work around a gap in Deno's implementation of the Node test API.
📖 2026-04-21: release 0.1.6 — Update README to clarify when to and not to use this
🔧 2026-04-20: release 0.1.5 — Add retryCommand() & retry-command.ts CLI
🔧 2026-04-09: release 0.1.4 — Fix missing export (of ask())
🔧 2026-03-29: release 0.1.3 — Add .skipIf() for more ergonomic skip conditions
📖 2026-03-28: release 0.1.2 — 🔧 Fix bug where --yes didn't propagate to nested step-level confirmations, so they'd still prompt
📖 2026-03-28: release 0.1.1 — update README
🎅 2026-03-28: release 0.1.0
🤖 2025-12-26: repo initialized by Bottie McBotface bot@axhxrx.com