This is a utility library for using TypeScript instead of shell scripts. It's not as lovable as Bun Shell , but it works on modern TypeScript runtimes, including Node.js 24+.
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.
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 modern runtimes, and even less-modern runtimes like Node.js 24+.
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 and --dry-run/--dryRun 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()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 and --yes/-y 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";- Node.js support target is 24+.
- Live command-output capture uses a
bash+teepipeline. - If
bashorteeis not available onPATH, Unix capture/file logging will fail with an explicit error.
π§ 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