Shell
A lightweight, type-safe wrapper around execa for running shell commands with flexible output modes and better developer experience.
Install / Use
/learn @thaitype/ShellREADME
@thaitype/shell
A lightweight, type-safe wrapper around execa for running shell commands with an elegant fluent API and flexible output modes.
Why @thaitype/shell?
Running shell commands in Node.js often involves repetitive boilerplate and dealing with low-level stdio configuration. @thaitype/shell provides a modern, fluent API that makes shell scripting in TypeScript/JavaScript feel natural and enjoyable.
Modern Fluent API:
import { createShell } from '@thaitype/shell';
const $ = createShell().asFluent();
// Simple and elegant
const output = await $('echo hello world');
// Chain operations
const lines = await $('ls -la').toLines();
// Parse JSON with validation
const pkg = await $('cat package.json').parse(schema);
// Handle errors gracefully
const result = await $('some-command').result();
if (!result.success) {
console.error('Failed:', result.stderr);
}
Key Features:
- Fluent API - Elegant function call syntax with chainable methods
- Type-safe - Full TypeScript support with automatic type inference
- Flexible output modes - Capture, stream live, or both simultaneously
- Schema validation - Built-in JSON parsing with Standard Schema (Zod, Valibot, etc.)
- Smart error handling - Choose between throwing or non-throwing APIs
- Lazy execution - Commands don't run until consumed
- Memoization - Multiple consumptions share the same execution
- Dry-run mode - Test scripts without executing commands
- Verbose logging - Debug with automatic command logging
Installation
npm install @thaitype/shell
# or
pnpm add @thaitype/shell
# or
yarn add @thaitype/shell
# or
bun add @thaitype/shell
Compatibility
This package is ESM only and requires:
- Node.js >= 20
- ESM module system (not CommonJS)
Following the same philosophy as execa, this package is pure ESM. Please read this if you need help migrating from CommonJS.
Quick Start
Basic Usage - Fluent API
import { createShell } from '@thaitype/shell';
// Create a fluent shell function
const $ = createShell().asFluent();
// Execute and get output
const output = await $('echo "Hello World"');
console.log(output); // "Hello World"
// Use function call syntax
const result = await $('ls -la');
console.log(result);
// Array syntax for precise arguments
const files = await $(['echo', 'file with spaces.txt']);
Fluent API Guide
The fluent API provides an elegant, modern way to run shell commands with powerful features like lazy execution, memoization, and chainable operations.
Command Execution
Execute commands using string or array syntax:
const $ = createShell().asFluent();
// String command
const output = await $('echo hello');
// Array command (recommended for arguments with spaces)
const result = await $(['echo', 'hello world']);
// With options
const output = await $('npm run build', { outputMode: 'all' });
Non-Throwable Execution with .result()
Handle failures gracefully without try-catch:
const $ = createShell().asFluent();
const result = await $('some-command-that-might-fail').result();
if (!result.success) {
console.error(`Command failed with exit code ${result.exitCode}`);
console.error(`Error: ${result.stderr}`);
} else {
console.log(`Output: ${result.stdout}`);
}
Working with Lines - .toLines()
Split output into an array of lines:
const $ = createShell().asFluent();
// Get directory listing as lines
const files = await $('ls -1 /tmp').toLines();
files.forEach(file => console.log(`File: ${file}`));
// Read and process file lines
const lines = await $('cat /etc/hosts').toLines();
const nonEmpty = lines.filter(line => line.trim() !== '');
JSON Parsing with Validation - .parse()
Parse and validate JSON output with Standard Schema:
import { createShell } from '@thaitype/shell';
import { z } from 'zod';
const $ = createShell().asFluent();
// Define schema
const packageSchema = z.object({
name: z.string(),
version: z.string(),
dependencies: z.record(z.string()).optional(),
});
// Parse and validate (throws on error)
const pkg = await $('cat package.json').parse(packageSchema);
console.log(`Package: ${pkg.name}@${pkg.version}`);
// API response example
const userSchema = z.object({
id: z.number(),
username: z.string(),
email: z.string().email(),
});
const user = await $('curl -s https://api.example.com/user/1').parse(userSchema);
console.log(`User: ${user.username} (${user.email})`);
Non-Throwable Parsing - .safeParse()
Parse JSON without throwing exceptions:
const $ = createShell().asFluent();
const schema = z.object({
status: z.string(),
data: z.array(z.any()),
});
const result = await $('curl -s https://api.example.com/data').safeParse(schema);
if (result.success) {
console.log('Data:', result.data.data);
} else {
console.error('Validation failed:', result.error);
// Handle error gracefully - could be:
// - Command failed
// - Invalid JSON
// - Schema validation failed
}
Lazy Execution and Memoization
Commands don't execute until consumed, and multiple consumptions share execution:
const $ = createShell().asFluent();
// Create handle - command hasn't run yet
const handle = $('echo expensive operation');
// First consumption - executes command
const output1 = await handle;
// Second consumption - reuses first execution
const output2 = await handle;
// Works across different methods too
const result = await handle.result(); // Still same execution!
// All three share the same memoized result
console.log(output1 === output2); // true
Output Modes
Control how command output is handled:
const shell = createShell({ outputMode: 'capture' }); // Default
const $ = shell.asFluent();
// Capture mode: Output is captured for programmatic use
const output = await $('npm run build');
console.log(output);
// All mode: Both capture AND stream to console
const shell2 = createShell({ outputMode: 'all' });
const $2 = shell2.asFluent();
const result = await $2('npm test').result();
// Test output appears in real-time on console
// AND is available in result.stdout
// Override mode per command
const output2 = await $(['npm', 'install'], { outputMode: 'all' });
Important: Fluent API does not support 'live' mode (streaming only, no capture) because fluent operations require stdout for chaining, parsing, and memoization. Use the traditional Shell API if you need live-only mode.
Example Use Cases
1. Build Script with Progress
import { createShell } from '@thaitype/shell';
const shell = createShell({
outputMode: 'all', // Show output + capture
verbose: true // Log commands
});
const $ = shell.asFluent();
console.log('🏗️ Building project...');
// Clean
await $('rm -rf dist');
// Build
const buildResult = await $('npm run build').result();
if (!buildResult.success) {
console.error('❌ Build failed!');
process.exit(1);
}
// Test
await $('npm test');
console.log('✅ Build complete!');
2. Git Workflow Helper
import { createShell } from '@thaitype/shell';
const $ = createShell().asFluent();
// Get current branch
const branch = await $('git rev-parse --abbrev-ref HEAD');
console.log(`Current branch: ${branch}`);
// Check for uncommitted changes
const status = await $('git status --porcelain').result();
if (status.stdout.trim() !== '') {
console.log('⚠️ You have uncommitted changes');
}
// Get recent commits as lines
const commits = await $('git log --oneline -5').toLines();
console.log('Recent commits:');
commits.forEach(commit => console.log(` ${commit}`));
3. System Information Gathering
import { createShell } from '@thaitype/shell';
import { z } from 'zod';
const $ = createShell().asFluent();
// Parse JSON output
const pkgSchema = z.object({
name: z.string(),
version: z.string(),
engines: z.object({
node: z.string(),
}).optional(),
});
const pkg = await $('cat package.json').parse(pkgSchema);
// Get Node version
const nodeVersion = await $('node --version');
// Get system info as lines
const osInfo = await $('uname -a').toLines();
console.log(`Project: ${pkg.name}@${pkg.version}`);
console.log(`Node: ${nodeVersion}`);
console.log(`OS: ${osInfo[0]}`);
4. Safe Command Execution
import { createShell } from '@thaitype/shell';
const $ = createShell().asFluent();
async function deployApp() {
// Test connection
const ping = await $('curl -s https://api.example.com/health').result();
if (!ping.success) {
console.error('❌ API is not reachable');
return false;
}
// Run tests
const tests = await $('npm test').result();
if (!tests.success) {
console.error('❌ Tests failed');
return false;
}
// Deploy
const deploy = await $('npm run deploy').result();
if (!deploy.success) {
console.error('❌ Deployment failed');
console.error(deploy.stderr);
return false;
}
console.log('✅ Deployment successful!');
return true;
}
await deployApp();
5. Dry-Run Mode for Testing
Test your automation scripts without actually executing commands:
import { createShell } from '@thaitype/shell';
const
