Skip to content
/ shell Public

A lightweight, type-safe wrapper around execa for running shell commands with flexible output modes and better developer experience.

License

Notifications You must be signed in to change notification settings

thaitype/shell

Repository files navigation

@thaitype/shell

CI codecov NPM Version npm downloads

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 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

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

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

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

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).

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.

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:

const output: string = await $('command');

Methods:

  • .result() - Non-throwable execution

    const result = await $('command').result();
    // result: { success: boolean, stdout: string, stderr: string, exitCode: number | undefined }
  • .toLines() - Split output into lines (throws on error)

    const lines: string[] = await $('command').toLines();
  • .parse<T>(schema) - Parse and validate JSON (throws on error)

    const data: T = await $('command').parse(schema);
  • .safeParse<T>(schema) - Parse and validate JSON (never throws)

    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<StrictResult>

{ stdout: string | null, stderr: string | null }

shell.safeRun(command, options?)

Execute command that never throws.

Returns: Promise<SafeResult>

{
  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<T> (inferred from schema)

shell.safeRunParse(command, schema, options?)

Execute, parse, and validate JSON (never throws).

Returns: Promise<ValidationResult<T>>

{ success: true, data: T } | { success: false, error: Error[] }

Options

ShellOptions

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

interface RunOptions extends ExecaOptions {
  outputMode?: 'capture' | 'live' | 'all';
  verbose?: boolean;
  dryRun?: boolean;
}

All options from execa are supported. Shell-level and command-level options are deep merged.

Advanced Usage

Custom Logger Integration

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

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 file for details.

Contributing

Contributions are welcome! Please feel free to submit a Pull Request.

Development Setup

  1. Clone the repository:

    git clone https://github.com/thaitype/shell.git
    cd shell
  2. Install dependencies:

    pnpm install
  3. Run tests:

    pnpm test
  4. Build:

    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

About

A lightweight, type-safe wrapper around execa for running shell commands with flexible output modes and better developer experience.

Topics

Resources

License

Stars

Watchers

Forks

Packages

No packages published

Contributors 2

  •  
  •