https://github.com/thaitype/shell
A lightweight, type-safe wrapper around execa for running shell commands with flexible output modes and better developer experience.
https://github.com/thaitype/shell
cli command-line execa nodejs typescript
Last synced: 3 months ago
JSON representation
A lightweight, type-safe wrapper around execa for running shell commands with flexible output modes and better developer experience.
- Host: GitHub
- URL: https://github.com/thaitype/shell
- Owner: thaitype
- License: mit
- Created: 2025-11-07T15:30:47.000Z (5 months ago)
- Default Branch: main
- Last Pushed: 2025-11-07T16:35:19.000Z (5 months ago)
- Last Synced: 2025-11-07T17:29:49.332Z (5 months ago)
- Topics: cli, command-line, execa, nodejs, typescript
- Language: TypeScript
- Homepage:
- Size: 143 KB
- Stars: 3
- Watchers: 0
- Forks: 0
- Open Issues: 0
-
Metadata Files:
- Readme: README.md
- Changelog: CHANGELOG.md
- License: LICENSE
Awesome Lists containing this project
README
# @thaitype/shell
[](https://github.com/thaitype/shell/actions/workflows/main.yml) [](https://codecov.io/gh/thaitype/shell) [](https://www.npmjs.com/package/@thaitype/shell) [](https://www.npmjs.com/@thaitype/shell)
A lightweight, type-safe wrapper around [execa](https://github.com/sindresorhus/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:**
```typescript
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
```bash
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](https://github.com/sindresorhus/execa), this package is pure ESM. Please [read this](https://gist.github.com/sindresorhus/a39789f98801d908bbc7ff3ecc99d99c) if you need help migrating from CommonJS.
## Quick Start
### Basic Usage - Fluent API
```typescript
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:
```typescript
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:
```typescript
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:
```typescript
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:
```typescript
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:
```typescript
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:
```typescript
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:
```typescript
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
```typescript
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
```typescript
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
```typescript
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
```typescript
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:
```typescript
import { createShell } from '@thaitype/shell';
const shell = createShell({
dryRun: true, // Commands logged but not executed
verbose: true
});
const $ = shell.asFluent();
// These commands will be logged but not executed
await $('rm -rf node_modules');
// Output: $ rm -rf node_modules
// (nothing is actually deleted)
await $('git push origin main');
// Output: $ git push origin main
// (nothing is actually pushed)
console.log('✅ Dry run complete - no actual changes made!');
```
## Traditional Shell API
For cases where you need more control or don't want the fluent API, use the traditional methods:
### `shell.run()` - Throws on Error
```typescript
import { createShell } from '@thaitype/shell';
const shell = createShell();
try {
const result = await shell.run('npm test');
console.log('Tests passed!', result.stdout);
} catch (error) {
console.error('Tests failed:', error.message);
}
```
### `shell.safeRun()` - Never Throws
```typescript
const shell = createShell();
const result = await shell.safeRun('npm test');
if (!result.success) {
console.error('Command failed with exit code:', result.exitCode);
console.error('Error output:', result.stderr);
} else {
console.log('Success:', result.stdout);
}
```
### Output Modes
```typescript
const shell = createShell();
// Capture mode (default): Capture output for programmatic use
const result1 = await shell.run('ls -la', { outputMode: 'capture' });
console.log('Files:', result1.stdout);
// Live mode: Stream output to console in real-time (no capture)
await shell.run('npm test', { outputMode: 'live' });
// Output appears in real-time, stdout/stderr will be null
// All mode: Both capture AND stream simultaneously
const result2 = await shell.run('npm run build', { outputMode: 'all' });
// Output streams to console AND is captured
console.log('Build output was:', result2.stdout);
```
### Schema Validation
```typescript
import { createShell } from '@thaitype/shell';
import { z } from 'zod';
const shell = createShell();
const packageSchema = z.object({
name: z.string(),
version: z.string(),
});
// Throws on error
const pkg = await shell.runParse('cat package.json', packageSchema);
console.log(`${pkg.name}@${pkg.version}`);
// Never throws
const result = await shell.safeRunParse('cat package.json', packageSchema);
if (result.success) {
console.log(`${result.data.name}@${result.data.version}`);
} else {
console.error('Validation failed:', result.error);
}
```
## API Reference
### Factory Function
#### `createShell(options?)`
Creates a new Shell instance with better type inference (recommended).
```typescript
const shell = createShell({
outputMode: 'capture', // 'capture' | 'live' | 'all'
dryRun: false,
verbose: false,
throwMode: 'simple', // 'simple' | 'raw'
logger: {
debug: (msg, ctx) => console.debug(msg),
warn: (msg, ctx) => console.warn(msg),
},
execaOptions: {
env: { NODE_ENV: 'production' },
timeout: 30000,
cwd: '/app',
},
});
```
### Fluent API
#### `shell.asFluent()`
Returns a fluent shell function that supports function calls.
```typescript
const $ = shell.asFluent();
// Function calls
await $('command');
await $(['command', 'arg']);
await $(command, options);
```
**Returns:** `DollarFunction` that creates `LazyCommandHandle` instances.
**Throws:** Error if shell has `outputMode: 'live'` (fluent API requires output capture).
#### `LazyCommandHandle`
Handle returned by fluent API with lazy execution and memoization.
**Direct await - Throwable:**
```typescript
const output: string = await $('command');
```
**Methods:**
- **`.result()`** - Non-throwable execution
```typescript
const result = await $('command').result();
// result: { success: boolean, stdout: string, stderr: string, exitCode: number | undefined }
```
- **`.toLines()`** - Split output into lines (throws on error)
```typescript
const lines: string[] = await $('command').toLines();
```
- **`.parse(schema)`** - Parse and validate JSON (throws on error)
```typescript
const data: T = await $('command').parse(schema);
```
- **`.safeParse(schema)`** - Parse and validate JSON (never throws)
```typescript
const result = await $('command').safeParse(schema);
// result: { success: true, data: T } | { success: false, error: Error[] }
```
### Traditional Shell Methods
#### `shell.run(command, options?)`
Execute command that throws on error.
**Returns:** `Promise`
```typescript
{ stdout: string | null, stderr: string | null }
```
#### `shell.safeRun(command, options?)`
Execute command that never throws.
**Returns:** `Promise`
```typescript
{
stdout: string | null,
stderr: string | null,
exitCode: number | undefined,
success: boolean
}
```
#### `shell.runParse(command, schema, options?)`
Execute, parse, and validate JSON (throws on error).
**Returns:** `Promise` (inferred from schema)
#### `shell.safeRunParse(command, schema, options?)`
Execute, parse, and validate JSON (never throws).
**Returns:** `Promise>`
```typescript
{ success: true, data: T } | { success: false, error: Error[] }
```
### Options
#### `ShellOptions`
```typescript
interface ShellOptions {
outputMode?: 'capture' | 'live' | 'all'; // default: 'capture'
dryRun?: boolean; // default: false
verbose?: boolean; // default: false
throwMode?: 'simple' | 'raw'; // default: 'simple'
logger?: ShellLogger;
execaOptions?: ExecaOptions; // Merged with command options
}
```
#### `RunOptions`
```typescript
interface RunOptions extends ExecaOptions {
outputMode?: 'capture' | 'live' | 'all';
verbose?: boolean;
dryRun?: boolean;
}
```
All options from [execa](https://github.com/sindresorhus/execa#options) are supported. Shell-level and command-level options are deep merged.
## Advanced Usage
### Custom Logger Integration
```typescript
import { createShell } from '@thaitype/shell';
import winston from 'winston';
const logger = winston.createLogger({
level: 'info',
format: winston.format.json(),
transports: [new winston.transports.Console()]
});
const shell = createShell({
verbose: true,
logger: {
debug: (message, context) => {
logger.debug(message, {
command: context.command,
cwd: context.execaOptions.cwd
});
},
warn: (message, context) => {
logger.warn(message, { command: context.command });
}
}
});
const $ = shell.asFluent();
await $('npm install');
// Commands logged via Winston with context
```
### Deep Merge Options
```typescript
const shell = createShell({
execaOptions: {
env: { API_KEY: 'default', NODE_ENV: 'dev' },
timeout: 5000,
}
});
const $ = shell.asFluent();
// Options are deep merged
const result = await $('node script.js', {
env: { NODE_ENV: 'prod', EXTRA: 'value' },
timeout: 30000,
});
// Resulting env: { API_KEY: 'default', NODE_ENV: 'prod', EXTRA: 'value' }
// Resulting timeout: 30000
```
## License
MIT - see [LICENSE](LICENSE) file for details.
## Contributing
Contributions are welcome! Please feel free to submit a Pull Request.
### Development Setup
1. Clone the repository:
```bash
git clone https://github.com/thaitype/shell.git
cd shell
```
2. Install dependencies:
```bash
pnpm install
```
3. Run tests:
```bash
pnpm test
```
4. Build:
```bash
pnpm build
```
### Guidelines
- Ensure all tests pass before submitting PR
- Add tests for new features
- Follow the existing code style
- Update documentation as needed
## Author
Thada Wangthammang
## Links
- [GitHub Repository](https://github.com/thaitype/shell)
- [NPM Package](https://www.npmjs.com/package/@thaitype/shell)
- [Issue Tracker](https://github.com/thaitype/shell/issues)