diff --git a/.agent/README.md b/.agent/README.md new file mode 100644 index 0000000..a06878e --- /dev/null +++ b/.agent/README.md @@ -0,0 +1,10 @@ +# Agent Context Directory + +This directory is used to store context information for the agent. It may include configuration files, logs, or other data that the agent needs to operate effectively. + +Consider to keep this directory lightweight and organized to ensure optimal performance of the agent. + +## Rules + +- Do not store working plan or progress files here. +- Allow working files only development purposes and ensure they are cleaned up regularly. \ No newline at end of file diff --git a/.changeset/shaky-planes-lay.md b/.changeset/shaky-planes-lay.md new file mode 100644 index 0000000..0d2224d --- /dev/null +++ b/.changeset/shaky-planes-lay.md @@ -0,0 +1,5 @@ +--- +'@thaitype/shell': major +--- + +Introduce Fluent Shell API for an elegant, chainable interface for shell command diff --git a/.gitignore b/.gitignore index fd72422..f3f691e 100644 --- a/.gitignore +++ b/.gitignore @@ -129,3 +129,4 @@ dist .yarn/install-state.gz .pnp.* +.DS_Store \ No newline at end of file diff --git a/README.md b/README.md index fdc64a9..dbd4939 100644 --- a/README.md +++ b/README.md @@ -1,49 +1,52 @@ # @thaitype/shell -[![CI](https://github.com/thaitype/shell/actions/workflows/main.yml/badge.svg)](https://github.com/thaitype/shell/actions/workflows/main.yml) [![codecov](https://codecov.io/gh/thaitype/shell/graph/badge.svg?token=TUE7DJ6NKX)](https://codecov.io/gh/thaitype/shell) [![NPM Version](https://img.shields.io/npm/v/@thaitype/shell)](https://www.npmjs.com/package/@thaitype/shell) [![npm downloads](https://img.shields.io/npm/dt/@thaitype/shell)](https://www.npmjs.com/@thaitype/shell) +[![CI](https://github.com/thaitype/shell/actions/workflows/main.yml/badge.svg)](https://github.com/thaitype/shell/actions/workflows/main.yml) [![codecov](https://codecov.io/gh/thaitype/shell/graph/badge.svg?token=TUE7DJ6NKX)](https://codecov.io/gh/thaitype/shell) [![NPM Version](https://img.shields.io/npm/v/@thaitype/shell)](https://www.npmjs.com/package/@thaitype/shell) [![npm downloads](https://img.shields.io/npm/dt/@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 flexible output modes and better developer experience. +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. While `execa` is a powerful library, common tasks like logging commands, handling dry-run mode, or switching between capturing and streaming output require manual setup each time. +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. -`@thaitype/shell` solves this by providing: +**Modern Fluent API:** +```typescript +import { createShell } from '@thaitype/shell'; -- **Simplified API** - Run commands with a single, intuitive interface -- **Flexible output modes** - Easily switch between capturing, streaming, or both -- **Built-in dry-run** - Test scripts without actually executing commands -- **Automatic logging** - Optional verbose mode for debugging -- **Smart error handling** - Choose between simple error messages or full error objects -- **Type safety** - Full TypeScript support with comprehensive type definitions +const $ = createShell().asFluent(); -## Features +// Simple and elegant +const output = await $('echo hello world'); -- **Multiple output modes**: Capture output, stream live, or do both simultaneously -- **Dry-run mode**: Test your scripts without executing actual commands -- **Verbose logging**: Automatically log all executed commands with contextual information -- **Flexible error handling**: Choose to throw on errors or handle them gracefully -- **Schema validation**: Parse and validate JSON output with Standard Schema (Zod, Valibot, etc.) -- **Custom logger support**: Integrate with your preferred logging solution (debug/warn methods with context) -- **Deep merge options**: Shell-level defaults are deep merged with command-level options -- **Type-safe**: Written in TypeScript with full type definitions -- **ESM-first**: Modern ES modules support -- **Zero configuration**: Sensible defaults that work out of the box +// Chain operations +const lines = await $('ls -la').toLines(); -## Compatibility +// Parse JSON with validation +const pkg = await $('cat package.json').parse(schema); -This package is **ESM only** and requires: +// Handle errors gracefully +const result = await $('some-command').result(); +if (!result.success) { + console.error('Failed:', result.stderr); +} +``` -- **Node.js** >= 20 -- **ESM** module system (not CommonJS) +**Key Features:** -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. +- **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 +# or pnpm add @thaitype/shell # or yarn add @thaitype/shell @@ -51,331 +54,399 @@ yarn add @thaitype/shell bun add @thaitype/shell ``` -## Basic Usage - -```typescript -import { createShell } from '@thaitype/shell'; - -// Create a shell instance -const shell = createShell(); +## Compatibility -// Run a command -const result = await shell.run('echo "Hello World"'); +This package is **ESM only** and requires: -console.log(result.stdout); // "Hello World" -``` +- **Node.js** >= 20 +- **ESM** module system (not CommonJS) -## Example Usage +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. -### 1. Verbose Mode for Debugging +## Quick Start -Perfect for build scripts and CI/CD pipelines where you want to see what's being executed: +### Basic Usage - Fluent API ```typescript import { createShell } from '@thaitype/shell'; -const shell = createShell({ - verbose: true // Logs every command before execution -}); +// Create a fluent shell function +const $ = createShell().asFluent(); + +// Execute and get output +const output = await $('echo "Hello World"'); +console.log(output); // "Hello World" -await shell.run('npm install'); -// Output: $ npm install -// (then shows npm install output) +// Use function call syntax +const result = await $('ls -la'); +console.log(result); -await shell.run('npm run build'); -// Output: $ npm run build -// (then shows build output) +// Array syntax for precise arguments +const files = await $(['echo', 'file with spaces.txt']); ``` -### 2. Dry-Run Mode for Testing +## Fluent API Guide -Test your automation scripts without actually executing commands: +The fluent API provides an elegant, modern way to run shell commands with powerful features like lazy execution, memoization, and chainable operations. -```typescript -import { createShell } from '@thaitype/shell'; +### Command Execution -const shell = createShell({ - dryRun: true, // Commands are logged but not executed - verbose: true -}); +Execute commands using string or array syntax: -// These commands will be logged but not executed -await shell.run('rm -rf node_modules'); -// Output: $ rm -rf node_modules -// (nothing is actually deleted) +```typescript +const $ = createShell().asFluent(); -await shell.run('git push origin main'); -// Output: $ git push origin main -// (nothing is actually pushed) +// String command +const output = await $('echo hello'); + +// Array command (recommended for arguments with spaces) +const result = await $(['echo', 'hello world']); -console.log('Dry run complete - no actual changes made!'); +// With options +const output = await $('npm run build', { outputMode: 'all' }); ``` -### 3. Different Output Modes +### Non-Throwable Execution with `.result()` -Control how command output is handled: +Handle failures gracefully without try-catch: ```typescript -import { createShell } from '@thaitype/shell'; - -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); +const $ = createShell().asFluent(); -// Live mode: Stream output to console in real-time -await shell.run('npm test', { outputMode: 'live' }); -// Output appears in real-time as the command runs +const result = await $('some-command-that-might-fail').result(); -// All mode: Both capture AND stream simultaneously -const result2 = await shell.run('npm run build', { outputMode: 'all' }); -// Output streams to console AND is captured in result2.stdout -console.log('Build output was:', result2.stdout); +if (!result.success) { + console.error(`Command failed with exit code ${result.exitCode}`); + console.error(`Error: ${result.stderr}`); +} else { + console.log(`Output: ${result.stdout}`); +} ``` -### 4. Graceful Error Handling +### Working with Lines - `.toLines()` -Handle command failures without throwing exceptions using `safeRun()`: +Split output into an array of lines: ```typescript -import { createShell } from '@thaitype/shell'; - -const shell = createShell(); +const $ = createShell().asFluent(); -// safeRun() never throws, returns error result instead -const result = await shell.safeRun('some-command-that-might-fail'); +// Get directory listing as lines +const files = await $('ls -1 /tmp').toLines(); +files.forEach(file => console.log(`File: ${file}`)); -if (!result.success) { - console.error('Command failed with exit code:', result.exitCode); - console.error('Error output:', result.stderr); - // Handle the error gracefully -} else { - console.log('Success:', result.stdout); -} +// Read and process file lines +const lines = await $('cat /etc/hosts').toLines(); +const nonEmpty = lines.filter(line => line.trim() !== ''); ``` -### 5. Schema Validation with JSON Output +### JSON Parsing with Validation - `.parse()` -Parse and validate JSON output from commands using Standard Schema: +Parse and validate JSON output with Standard Schema: ```typescript import { createShell } from '@thaitype/shell'; import { z } from 'zod'; -const shell = createShell(); +const $ = createShell().asFluent(); -// Define a schema for package.json +// Define schema const packageSchema = z.object({ name: z.string(), version: z.string(), dependencies: z.record(z.string()).optional(), }); -// Parse and validate - throws if invalid -const pkg = await shell.runParse('cat package.json', packageSchema); +// Parse and validate (throws on error) +const pkg = await $('cat package.json').parse(packageSchema); console.log(`Package: ${pkg.name}@${pkg.version}`); -// Safe parse - returns result object -const apiSchema = z.object({ +// 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.object({ - id: z.number(), - name: z.string(), - })), + data: z.array(z.any()), }); -const result = await shell.safeRunParse( - 'curl -s https://api.example.com/users', - apiSchema -); +const result = await $('curl -s https://api.example.com/data').safeParse(schema); if (result.success) { - result.data.data.forEach(user => { - console.log(`User: ${user.name} (${user.id})`); - }); + console.log('Data:', result.data.data); } else { - console.error('API validation failed:', result.error); + console.error('Validation failed:', result.error); + // Handle error gracefully - could be: + // - Command failed + // - Invalid JSON + // - Schema validation failed } ``` -## API +### 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; -### `createShell(options?)` (Recommended) +// Second consumption - reuses first execution +const output2 = await handle; -Factory function to create a new Shell instance with better type inference. +// Works across different methods too +const result = await handle.result(); // Still same execution! -**Recommended:** Use `createShell()` instead of `new Shell()` for better developer experience and automatic type inference of the default output mode. +// All three share the same memoized result +console.log(output1 === output2); // true +``` + +### Output Modes + +Control how command output is handled: ```typescript -import { createShell } from '@thaitype/shell'; +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 -// Type inference automatically detects 'live' as default mode -const shell = createShell({ outputMode: 'live' }); +// Override mode per command +const output2 = await $(['npm', 'install'], { outputMode: 'all' }); ``` -### `new Shell(options?)` +**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. -Alternative constructor for creating a Shell instance. +## Example Use Cases -#### Options +### 1. Build Script with Progress ```typescript -interface ShellOptions { - /** Default output mode applied to all runs unless overridden */ - outputMode?: OutputMode; // 'capture' | 'live' | 'all', default: 'capture' - - /** If true, print commands but skip actual execution */ - dryRun?: boolean; // default: false - - /** If true, log every executed command */ - verbose?: boolean; // default: false - - /** - * Controls how errors are thrown when a command fails. - * - "simple" → Throws a short, human-readable error message - * - "raw" → Throws the full ExecaError object with complete details - */ - throwMode?: 'simple' | 'raw'; // default: 'simple' - - /** - * Optional custom logger for command output and diagnostics. - * Provides two logging methods: - * - debug(message, context) - Called for verbose command logging - * - warn(message, context) - Called for warnings - * - * The context parameter includes the command and final execa options. - */ - logger?: ShellLogger; +import { createShell } from '@thaitype/shell'; - /** - * Default execa options applied to all command executions. - * When command-level execaOptions are provided, they are deep merged - * with shell-level options. Command-level options override shell-level. - */ - execaOptions?: ExecaOptions; -} +const shell = createShell({ + outputMode: 'all', // Show output + capture + verbose: true // Log commands +}); -interface ShellLogger { - /** Called for verbose command logging. Defaults to console.debug */ - debug?(message: string, context: ShellLogContext): void; +const $ = shell.asFluent(); - /** Called for warnings. Defaults to console.warn */ - warn?(message: string, context: ShellLogContext): void; -} +console.log('šŸ—ļø Building project...'); -interface ShellLogContext { - /** The command being executed */ - command: string | string[]; +// Clean +await $('rm -rf dist'); - /** Execa options used for the command execution */ - execaOptions: ExecaOptions; +// Build +const buildResult = await $('npm run build').result(); +if (!buildResult.success) { + console.error('āŒ Build failed!'); + process.exit(1); } -``` -### `shell.run(command, options?)` +// Test +await $('npm test'); -Executes a shell command that **throws on error**. Recommended for most use cases where you want to fail fast. +console.log('āœ… Build complete!'); +``` -#### Parameters +### 2. Git Workflow Helper -- `command: string | string[]` - The command to execute. Can be a string (with automatic parsing) or an array of arguments. -- `options?: RunOptions` - Optional execution options. +```typescript +import { createShell } from '@thaitype/shell'; -#### Returns +const $ = createShell().asFluent(); -```typescript -interface StrictResult { - /** Captured stdout output, or null if not captured */ - stdout: string | null; +// Get current branch +const branch = await $('git rev-parse --abbrev-ref HEAD'); +console.log(`Current branch: ${branch}`); - /** Captured stderr output, or null if not captured */ - stderr: string | null; +// 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}`)); ``` -**Throws**: Error when command exits with non-zero code (format depends on `throwMode`). +### 3. System Information Gathering -### `shell.safeRun(command, options?)` +```typescript +import { createShell } from '@thaitype/shell'; +import { z } from 'zod'; -Executes a shell command that **never throws**. Returns error result instead. +const $ = createShell().asFluent(); -Use this when you want to handle errors programmatically without try/catch. +// Parse JSON output +const pkgSchema = z.object({ + name: z.string(), + version: z.string(), + engines: z.object({ + node: z.string(), + }).optional(), +}); -#### Parameters +const pkg = await $('cat package.json').parse(pkgSchema); -- `command: string | string[]` - The command to execute. -- `options?: RunOptions` - Optional execution options. +// 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]}`); +``` -#### Returns +### 4. Safe Command Execution ```typescript -interface SafeResult { - /** Captured stdout output, or null if not captured */ - stdout: string | null; +import { createShell } from '@thaitype/shell'; + +const $ = createShell().asFluent(); - /** Captured stderr output, or null if not captured */ - stderr: string | null; +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; + } - /** Exit code returned by the executed process */ - exitCode: number | undefined; + // Run tests + const tests = await $('npm test').result(); + if (!tests.success) { + console.error('āŒ Tests failed'); + return false; + } - /** True if command exited with code 0 */ - success: boolean; + // 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(); ``` -### `shell.execute(command, options?)` +### 5. Dry-Run Mode for Testing -Low-level method with explicit `throwOnError` control. +Test your automation scripts without actually executing commands: -#### Parameters +```typescript +import { createShell } from '@thaitype/shell'; -- `command: string | string[]` - The command to execute. -- `options?: RunOptions & { throwOnError?: boolean }` - Optional execution options including throwOnError flag. +const shell = createShell({ + dryRun: true, // Commands logged but not executed + verbose: true +}); -#### RunOptions +const $ = shell.asFluent(); -```typescript -interface RunOptions extends ExecaOptions { - /** Override the output behavior for this specific command */ - outputMode?: OutputMode; // 'capture' | 'live' | 'all' +// These commands will be logged but not executed +await $('rm -rf node_modules'); +// Output: $ rm -rf node_modules +// (nothing is actually deleted) - /** Override verbose logging for this specific command */ - verbose?: boolean; +await $('git push origin main'); +// Output: $ git push origin main +// (nothing is actually pushed) - /** Override dry-run mode for this specific command */ - dryRun?: boolean; -} +console.log('āœ… Dry run complete - no actual changes made!'); ``` -Inherits all options from [execa's Options](https://github.com/sindresorhus/execa#options). +## 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(); -**Deep Merge Behavior:** When both shell-level `execaOptions` and command-level options are provided, they are deep merged using the `deepmerge` library. Command-level options take precedence over shell-level options. For objects like `env`, the properties are merged. For primitives like `timeout`, the command-level value overrides the shell-level value. +try { + const result = await shell.run('npm test'); + console.log('Tests passed!', result.stdout); +} catch (error) { + console.error('Tests failed:', error.message); +} +``` -### `shell.runParse(command, schema, options?)` +### `shell.safeRun()` - Never Throws -Execute a command, parse its stdout as JSON, and validate it against a [Standard Schema](https://github.com/standard-schema/standard-schema). +```typescript +const shell = createShell(); -**Throws on error** - Command failure or validation failure will throw an exception. +const result = await shell.safeRun('npm test'); -#### Parameters +if (!result.success) { + console.error('Command failed with exit code:', result.exitCode); + console.error('Error output:', result.stderr); +} else { + console.log('Success:', result.stdout); +} +``` -- `command: string | string[]` - The command to execute. -- `schema: StandardSchemaV1` - A Standard Schema to validate the JSON output. -- `options?: RunOptions` - Optional execution options. +### Output Modes -#### Returns +```typescript +const shell = createShell(); -- Type-safe parsed and validated output based on the schema. +// Capture mode (default): Capture output for programmatic use +const result1 = await shell.run('ls -la', { outputMode: 'capture' }); +console.log('Files:', result1.stdout); -#### Throws +// 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 -- Error when command fails -- Error when output is not valid JSON -- Error when output doesn't match the schema +// 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); +``` -**Example with Zod:** +### Schema Validation ```typescript import { createShell } from '@thaitype/shell'; @@ -388,92 +459,166 @@ const packageSchema = z.object({ version: z.string(), }); -// Execute command and validate JSON output -const pkg = await shell.runParse( - 'cat package.json', - packageSchema -); +// Throws on error +const pkg = await shell.runParse('cat package.json', packageSchema); +console.log(`${pkg.name}@${pkg.version}`); -console.log(pkg.name, pkg.version); // Type-safe! +// 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); +} ``` -### `shell.safeRunParse(command, schema, options?)` +## API Reference -Execute a command, parse its stdout as JSON, and validate it against a Standard Schema. +### Factory Function -**Never throws** - Returns a result object with success/error information. +#### `createShell(options?)` -#### Parameters +Creates a new Shell instance with better type inference (recommended). -- `command: string | string[]` - The command to execute. -- `schema: StandardSchemaV1` - A Standard Schema to validate the JSON output. -- `options?: RunOptions` - Optional execution options. +```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 +Returns a fluent shell function that supports function calls. ```typescript -type StandardResult = - | { success: true; data: T } - | { success: false; error: Array<{ message: string }> }; +const $ = shell.asFluent(); + +// Function calls +await $('command'); +await $(['command', 'arg']); +await $(command, options); ``` -**Example with Zod:** +**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 -import { createShell } from '@thaitype/shell'; -import { z } from 'zod'; +const output: string = await $('command'); +``` -const shell = createShell(); +**Methods:** -const userSchema = z.object({ - username: z.string(), - id: z.number(), -}); +- **`.result()`** - Non-throwable execution + ```typescript + const result = await $('command').result(); + // result: { success: boolean, stdout: string, stderr: string, exitCode: number | undefined } + ``` -const result = await shell.safeRunParse( - 'curl -s https://api.example.com/user', - userSchema -); +- **`.toLines()`** - Split output into lines (throws on error) + ```typescript + const lines: string[] = await $('command').toLines(); + ``` -if (result.success) { - console.log('User:', result.data.username); -} else { - console.error('Validation failed:', result.error); +- **`.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 } ``` -### Output Modes +#### `shell.runParse(command, schema, options?)` + +Execute, parse, and validate JSON (throws on error). -- **`capture`** (default): Captures stdout/stderr for programmatic access. Output is not printed to console. -- **`live`**: Streams stdout/stderr directly to console in real-time. Output is not captured. -- **`all`**: Both captures AND streams output simultaneously. +**Returns:** `Promise` (inferred from schema) -## Advanced Examples +#### `shell.safeRunParse(command, schema, options?)` -### Using run() vs safeRun() +Execute, parse, and validate JSON (never throws). +**Returns:** `Promise>` ```typescript -import { createShell } from '@thaitype/shell'; +{ success: true, data: T } | { success: false, error: Error[] } +``` -const shell = createShell(); +### Options -// run() - Throws on error (fail fast) -try { - const result = await shell.run('npm test'); - console.log('Tests passed!', result.stdout); -} catch (error) { - console.error('Tests failed:', error.message); +#### `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 } +``` -// safeRun() - Never throws, check success flag -const result = await shell.safeRun('npm test'); -if (result.success) { - console.log('Tests passed!', result.stdout); -} else { - console.error('Tests failed with exit code:', result.exitCode); +#### `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 @@ -501,37 +646,31 @@ const shell = createShell({ } }); -await shell.run('npm install'); -// Commands are logged using Winston with contextual information +const $ = shell.asFluent(); +await $('npm install'); +// Commands logged via Winston with context ``` -### Combining with Execa Options +### Deep Merge Options ```typescript -import { createShell } from '@thaitype/shell'; - -// Shell-level default options const shell = createShell({ execaOptions: { - env: { API_KEY: 'default-key', NODE_ENV: 'development' }, + env: { API_KEY: 'default', NODE_ENV: 'dev' }, timeout: 5000, - cwd: '/default/directory' } }); -// Command-level options are deep merged with shell-level -const result = await shell.run('node script.js', { - env: { NODE_ENV: 'production', EXTRA: 'value' }, // Deep merged - timeout: 30000, // Overrides shell-level timeout - outputMode: 'capture' +const $ = shell.asFluent(); + +// Options are deep merged +const result = await $('node script.js', { + env: { NODE_ENV: 'prod', EXTRA: 'value' }, + timeout: 30000, }); -// Resulting options: -// { -// env: { API_KEY: 'default-key', NODE_ENV: 'production', EXTRA: 'value' }, -// timeout: 30000, -// cwd: '/default/directory' -// } +// Resulting env: { API_KEY: 'default', NODE_ENV: 'prod', EXTRA: 'value' } +// Resulting timeout: 30000 ``` ## License diff --git a/examples/azure.ts b/examples/azure.ts new file mode 100644 index 0000000..1b8a70a --- /dev/null +++ b/examples/azure.ts @@ -0,0 +1,19 @@ +import { createShell } from "src/shell.js"; +import { z } from "zod"; + +const azAccountShowResponse = z.object({ + name: z.string(), + user: z.object({ + name: z.string(), + type: z.string(), + }), +}); + +async function main() { + const shell = createShell(); + const result = await shell.runParse(`az account show`, azAccountShowResponse); + console.log("Current Active username", result.user.name); + console.log("Current Active Subscription Name", result.name); +} + +main(); \ No newline at end of file diff --git a/examples/fluentShell.ts b/examples/fluentShell.ts new file mode 100644 index 0000000..19e9a0a --- /dev/null +++ b/examples/fluentShell.ts @@ -0,0 +1,70 @@ +/** + * Example demonstrating the Fluent Shell API + * + * The fluent shell API provides a cleaner, more ergonomic way to run shell commands + * with helper methods for common output transformations. + */ + +import { createShell } from '../src/index.js'; +import { z } from 'zod'; + +async function main() { + // Create a fluent shell function + const $ = createShell({ verbose: false }).asFluent(); + + console.log('=== Example 1: Direct await ==='); + // Direct await - returns stdout as string + const result = await $('echo hello'); + console.log(`Result: ${result}`); + + console.log('\n=== Example 2: Using toLines() ==='); + // Split output into array of lines + const files = await $('ls -la').toLines(); + console.log('Files:'); + for (const file of files.slice(0, 5)) { + console.log(` - ${file}`); + } + + console.log('\n=== Example 3: Using parse() with Zod ==='); + // Parse JSON output with schema validation + const PackageSchema = z.object({ + name: z.string(), + version: z.string(), + type: z.string().optional(), + }); + + const pkg = await $('cat package.json').parse(PackageSchema); + console.log(`Package: ${pkg.name} v${pkg.version}`); + + console.log('\n=== Example 4: Chaining commands ==='); + // Use result of one command in another + const data = await $('echo test-dir'); + console.log(`Creating directory: ${data}`); + // Note: Using dry run to avoid actually creating the directory + const $dryRun = createShell({ dryRun: true }).asFluent(); + await $dryRun(`mkdir ${data}`); + + console.log('\n=== Example 5: Working with multiple lines ==='); + // Process lines individually + const lines = await $('printf "apple\\nbanana\\ncherry"').toLines(); + console.log('Fruits:'); + lines.forEach((fruit, index) => { + console.log(` ${index + 1}. ${fruit}`); + }); + + console.log('\n=== Example 6: Complex schema validation ==='); + // Use Zod schema with transformations + const TransformSchema = z.object({ + name: z.string(), + }).transform(data => ({ + ...data, + uppercase: data.name.toUpperCase(), + })); + + const transformResult = await $('echo \'{"name":"fluent-shell"}\'').parse(TransformSchema); + console.log(`Transformed result: ${transformResult.uppercase}`); + + console.log('\nāœ… All examples completed successfully!'); +} + +main().catch(console.error); diff --git a/src/shell.ts b/src/shell.ts index e93cd24..ed962c2 100644 --- a/src/shell.ts +++ b/src/shell.ts @@ -2,7 +2,7 @@ import type { StandardSchemaV1 } from '@standard-schema/spec'; import { execa, type Options as ExecaOptions, ExecaError } from 'execa'; import parseArgsStringToArgv from 'string-argv'; import deepmerge from 'deepmerge'; -import { standardSafeValidate, standardValidate, type StandardResult } from './standard-schema.js'; +import { standardSafeValidate, standardValidate, type ValidationResult } from './standard-schema.js'; /** * Output mode behavior for handling stdout/stderr. @@ -13,6 +13,12 @@ import { standardSafeValidate, standardValidate, type StandardResult } from './s */ export type OutputMode = 'capture' | 'live' | 'all'; +/** + * Output modes supported by FluentShell. + * Excludes 'live' mode since fluent operations require stdout for chaining and parsing. + */ +export type FluentOutputMode = Exclude; + /** * Type utility to determine if an output mode captures output. * Returns false for 'live' mode, true for 'capture' and 'all'. @@ -186,6 +192,19 @@ export interface RunOptions extends Omit, 'execaOptions'>, ShellExecaOptions {} +/** + * Options for fluent shell command execution. + * Restricts outputMode to exclude 'live' mode. + * + * @template Mode - The output mode for this command (capture or all only) + */ +export type FluentRunOptions = Omit< + RunOptions, + 'outputMode' +> & { + outputMode?: Mode; +}; + /** * Strict result returned by `run()` method (throws on error). * Only includes stdout/stderr, as the command either succeeds or throws. @@ -218,10 +237,198 @@ export interface SafeResult extends StrictResult = Throw extends true +export type ExecutionResult = Throw extends true ? StrictResult> : SafeResult>; +/** + * A handle for a command execution that can be awaited directly or have helper methods called on it. + * + * This type implements the PromiseLike interface, allowing it to be awaited directly to get stdout, + * while also providing helper methods for common transformations. + * + * @example Direct await + * ```typescript + * const result = await $('echo hello'); // Returns 'hello' + * ``` + * + * @example Using toLines() + * ```typescript + * const lines = await $('ls -la').toLines(); // Returns array of lines + * ``` + * + * @example Using parse() + * ```typescript + * const data = await $('echo \'{"key":"value"}\'').parse(schema); + * ``` + */ +export type CommandHandle = PromiseLike & { + /** + * Split stdout by newlines and return as array of strings. + * Handles both Unix (\n) and Windows (\r\n) line endings. + * + * @returns Promise resolving to array of lines from stdout + */ + toLines(): Promise; + + /** + * Parse stdout as JSON and validate with the provided Standard Schema V1. + * The schema must implement the Standard Schema V1 interface (e.g., Zod schemas). + * + * @template T - A Standard Schema V1 schema type + * @param schema - A Standard Schema V1 compatible schema + * @returns Promise resolving to the parsed and validated data + * + * @see https://github.com/standard-schema/standard-schema + */ + parse(schema: T): Promise>; +}; + +/** + * A function that creates CommandHandle instances for fluent command execution. + * + * @param command - Command to execute, as string or array of arguments + * @returns A CommandHandle that can be awaited or have helper methods called + */ +export type FluentShellFn = (command: string | string[]) => CommandHandle; + +/** + * Command handle with lazy execution and memoization. + * Extends CommandHandle with `.result()` method for non-throwable execution. + * + * Key features: + * - **Lazy execution**: Command doesn't run until first consumption (await, .result(), .toLines(), .parse()) + * - **Memoization**: Multiple consumptions share the same execution + * - **Throwable path**: `await handle` throws on non-zero exit code + * - **Non-throwable path**: `await handle.result()` returns result with success flag + * + * @example Throwable execution + * ```typescript + * const $ = createShell().asFluent(); + * try { + * const output = await $`exit 1`; // Throws error + * } catch (error) { + * console.error(error); + * } + * ``` + * + * @example Non-throwable execution + * ```typescript + * const $ = createShell().asFluent(); + * const r = await $`exit 1`.result(); // Doesn't throw + * if (!r.success) { + * console.error(r.exitCode, r.stderr); + * } + * ``` + * + * @example Memoization + * ```typescript + * const $ = createShell().asFluent(); + * const handle = $`echo test`; + * const a = await handle; // Executes once + * const b = await handle; // Reuses same execution + * const c = await handle.result(); // Still same execution + * ``` + */ +export type LazyCommandHandle = PromiseLike & { + /** + * Non-throwable execution - returns result object with success flag. + * This method never throws, even if the command fails. + * + * @returns Promise resolving to ExecutionResult with success, stdout, stderr, and exitCode + */ + result(): Promise>; + + /** + * Split stdout by newlines and return as array of strings. + * Handles both Unix (\n) and Windows (\r\n) line endings. + * Throws if command fails. + * + * @returns Promise resolving to array of lines from stdout + */ + toLines(): Promise; + + /** + * Parse stdout as JSON and validate with the provided schema. + * The schema must have a `parse(data: any): T` method (compatible with Zod and other validators). + * Throws if command fails or if JSON parsing/validation fails. + * + * @template T - The inferred output type from the schema + * @param schema - A schema object with a parse method (e.g., Zod schema) + * @returns Promise resolving to the parsed and validated data + */ + parse(schema: T): Promise>; + + /** + * Parse stdout as JSON and validate with schema (non-throwable). + * Returns ValidationResult instead of throwing on failure. + * + * This method never throws, even if: + * - The command fails + * - There's no stdout + * - JSON parsing fails + * - Schema validation fails + * + * @template T - A Standard Schema V1 schema type + * @param schema - A Standard Schema V1 compatible schema + * @returns Promise resolving to ValidationResult with either data or error + * + * @example + * ```typescript + * const $ = createShell().asFluent(); + * const result = await $`cat config.json`.safeParse(ConfigSchema); + * if (result.success) { + * console.log('Config:', result.data); + * } else { + * console.error('Validation failed:', result.error); + * } + * ``` + */ + safeParse(schema: T): Promise>>; +}; + +/** + * Function that supports string and array command execution. + * Returned by `shell.asFluent()`. + * + * Supports two call signatures: + * 1. String command: `$('echo hello')` or `$('echo hello', { outputMode: 'all' })` + * 2. Argv array: `$(['echo', 'hello'])` or `$(['echo', 'hello'], { outputMode: 'all' })` + * + * Note: FluentShell does not support 'live' mode. Use 'capture' or 'all' only. + * + * @example String command + * ```typescript + * const $ = createShell().asFluent(); + * const result = await $('echo hello'); + * ``` + * + * @example String command with template literal interpolation + * ```typescript + * const $ = createShell().asFluent(); + * const name = 'world'; + * const result = await $(`echo hello ${name}`); + * ``` + * + * @example Function call with options + * ```typescript + * const $ = createShell().asFluent(); + * const result = await $('echo hello', { outputMode: 'all' }); + * ``` + * + * @example Array call with options (recommended for complex arguments) + * ```typescript + * const $ = createShell().asFluent(); + * const result = await $(['echo', 'file with spaces.txt'], { outputMode: 'all' }); + * ``` + */ +export interface DollarFunction { + /** + * String or array command call with optional fluent options + */ + (command: string | string[], options?: FluentRunOptions): LazyCommandHandle; +} + /** * Factory function to create a new Shell instance with type inference. * Provides better type safety and convenience compared to using `new Shell()`. @@ -409,7 +616,7 @@ export class Shell { public async execute( cmd: string | string[], options?: RunOptions & { throwOnError?: Throw } - ): Promise> { + ): Promise> { const args = Array.isArray(cmd) ? cmd : parseArgsStringToArgv(cmd); const [program, ...cmdArgs] = args; @@ -451,7 +658,7 @@ export class Shell { } if (dryRun) { - return { stdout: '', stderr: '', exitCode: 0, success: true } as RunResult; + return { stdout: '', stderr: '', exitCode: 0, success: true } as ExecutionResult; } try { @@ -462,7 +669,7 @@ export class Shell { stderr: result.stderr ? String(result.stderr) : null, exitCode: result.exitCode, success: result.exitCode === 0, - } as RunResult; + } as ExecutionResult; } catch (error: unknown) { if (error instanceof ExecaError) { if (options?.throwOnError) { @@ -480,7 +687,7 @@ export class Shell { stderr: null, exitCode: undefined, success: false, - } as RunResult; + } as ExecutionResult; } } @@ -513,7 +720,7 @@ export class Shell { public async run( cmd: string | string[], options?: RunOptions - ): Promise> { + ): Promise> { return this.execute(cmd, { ...options, throwOnError: true }); } @@ -546,7 +753,7 @@ export class Shell { public async safeRun( cmd: string | string[], options?: RunOptions - ): Promise> { + ): Promise> { return this.execute(cmd, { ...options, throwOnError: false }); } @@ -574,7 +781,7 @@ export class Shell { cmd: string | string[], schema: T, options?: RunOptions - ): Promise>> { + ): Promise>> { const result = await this.safeRun(cmd, options); const verbose = options?.verbose ?? this.verbose; const fullCommand = Array.isArray(cmd) ? cmd.join(' ') : cmd; @@ -602,4 +809,242 @@ export class Shell { }; } } + + /** + * Validates that the output mode is compatible with FluentShell. + * Throws an error if 'live' mode is used. + * + * @param mode - The output mode to validate + * @throws {Error} If mode is 'live' + * + * @internal + */ + private assertFluentMode(mode: OutputMode): asserts mode is FluentOutputMode { + if (mode === 'live') { + throw new Error( + "FluentShell does not support outputMode: 'live'. " + + "Use 'capture' or 'all', or call shell.run(..., { outputMode: 'live' }) instead." + ); + } + } + + /** + * Create a lazy command handle with memoized execution. + * The command doesn't execute until first consumption (await, .result(), .toLines(), .parse()). + * Multiple consumptions share the same execution result. + * + * @param command - Command to execute (string or array) + * @param options - Fluent execution options (validated to not use 'live' mode) + * @returns LazyCommandHandle with deferred execution + * + * @internal + */ + private createLazyHandle(command: string | string[], options: FluentRunOptions): LazyCommandHandle { + // Memoized execution promise - null until first consumption + let executionPromise: Promise> | null = null; + + // Lazy executor - runs command once and memoizes result + const start = (): Promise> => { + if (executionPromise === null) { + executionPromise = this.safeRun(command, options as RunOptions).then(result => ({ + success: result.success, + stdout: result.stdout ?? '', + stderr: result.stderr ?? '', + exitCode: result.exitCode, + })); + } + return executionPromise; + }; + + const handle: Partial = {}; + + // Throwable path: await handle + // Checks success and throws error if command failed + handle.then = (onFulfilled, onRejected) => { + return start() + .then(result => { + if (!result.success) { + // Throw error based on throwMode setting + if (this.throwMode === 'simple') { + const args = Array.isArray(command) ? command : parseArgsStringToArgv(command); + throw new Error(`Command failed: ${args.join(' ')}\nExit code: ${result.exitCode}\n${result.stderr}`); + } else { + // For 'raw' mode, we'd need the original ExecaError + // Since we're using safeRun, we'll throw a simplified error + throw new Error(`Command failed with exit code ${result.exitCode}: ${result.stderr}`); + } + } + return result.stdout; + }) + .then(onFulfilled, onRejected); + }; + + // Non-throwable path: handle.result() + // Returns CommandResult with success flag, never throws + handle.result = () => start(); + + // Helper method: split stdout into lines + // Throws if command failed + handle.toLines = () => + start().then(result => { + if (!result.success) { + const args = Array.isArray(command) ? command : parseArgsStringToArgv(command); + throw new Error(`Command failed: ${args.join(' ')}\nExit code: ${result.exitCode}\n${result.stderr}`); + } + if (!result.stdout) return []; + return result.stdout.split(/\r?\n/); + }); + + // Helper method: parse JSON and validate with schema + // Throws if command failed or if JSON parsing/validation fails + handle.parse = (schema: T): Promise> => { + return start().then(result => { + if (!result.success) { + const args = Array.isArray(command) ? command : parseArgsStringToArgv(command); + throw new Error(`Command failed: ${args.join(' ')}\nExit code: ${result.exitCode}\n${result.stderr}`); + } + return standardValidate(schema, JSON.parse(result.stdout ?? '{}')); + }); + }; + + // Helper method: parse JSON and validate with schema (non-throwable) + // Returns ValidationResult instead of throwing + handle.safeParse = ( + schema: T + ): Promise>> => { + return start().then(result => { + const args = Array.isArray(command) ? command : parseArgsStringToArgv(command); + const commandStr = args.join(' '); + + // Check if command failed + if (!result.success) { + return { + success: false, + error: [ + { + message: `Command failed: ${commandStr}\nExit code: ${result.exitCode}\n${result.stderr}`, + }, + ], + }; + } + + // Check if there's no output + if (!result.stdout) { + return { + success: false, + error: [ + { + message: `The command produced no output to validate: ${commandStr}`, + }, + ], + }; + } + + // Try to parse JSON + try { + const parsed = JSON.parse(result.stdout); + // Use standardSafeValidate for schema validation (non-throwing) + return standardSafeValidate(schema, parsed); + } catch (e: unknown) { + return { + success: false, + error: [ + { + message: `Unable to parse JSON: ${e instanceof Error ? e.message : String(e)}\nCommand: ${commandStr}`, + }, + ], + }; + } + }); + }; + + return handle as LazyCommandHandle; + } + + /** + * Create a fluent shell function with lazy execution support. + * + * Returns a function that supports: + * - Function calls: `$('echo hello')` or `$(['echo', 'hello'], { outputMode: 'all' })` + * - Lazy execution: command doesn't run until consumed + * - Memoization: multiple consumptions share one execution + * - Non-throwable path: `.result()` returns result with success flag + * + * Note: FluentShell requires stdout for chaining, so 'live' mode is not supported. + * If the shell instance has `outputMode: 'live'`, this method will throw an error. + * + * @returns DollarFunction that supports function calls + * + * @throws {Error} If shell instance has `outputMode: 'live'` + * + * @example Basic usage + * ```typescript + * const $ = createShell().asFluent(); + * const result = await $('echo hello'); + * ``` + * + * @example Function call with mode override + * ```typescript + * const shell = createShell({ outputMode: 'capture' }); + * const $ = shell.asFluent(); + * const result = await $('echo hello', { outputMode: 'all' }); // Uses 'all' mode + * ``` + * + * @example Array syntax for precise arguments + * ```typescript + * const $ = createShell().asFluent(); + * const result = await $(['echo', 'file with spaces.txt']); + * ``` + * + * @example Error case - live mode not supported + * ```typescript + * const shell = createShell({ outputMode: 'live' }); + * shell.asFluent(); // āŒ Throws error + * ``` + * + * @example Non-throwable execution + * ```typescript + * const $ = createShell().asFluent(); + * const r = await $('sh -c "exit 1"').result(); + * if (!r.success) { + * console.error(`Failed with exit code ${r.exitCode}`); + * } + * ``` + * + * @example Lazy + memoization + * ```typescript + * const $ = createShell().asFluent(); + * const handle = $('echo test'); + * const a = await handle; // Executes once + * const b = await handle; // Reuses execution + * const c = await handle.result(); // Still same execution + * ``` + * + * @example Helper methods + * ```typescript + * const $ = createShell().asFluent(); + * const lines = await $('ls -la').toLines(); + * const data = await $('cat package.json').parse(schema); + * ``` + */ + public asFluent(): DollarFunction { + // Validate shell-level outputMode + this.assertFluentMode(this.outputMode); + + return ((command: string | string[], options?: FluentRunOptions): LazyCommandHandle => { + // Determine effective output mode (options override shell default) + const effectiveMode = (options?.outputMode ?? this.outputMode) as OutputMode; + + // Validate effective mode + this.assertFluentMode(effectiveMode); + + // Create lazy handle with effective options + const effectiveOptions: FluentRunOptions = { + ...(options ?? {}), + outputMode: effectiveMode, + }; + + return this.createLazyHandle(command, effectiveOptions); + }) as DollarFunction; + } } diff --git a/src/standard-schema.ts b/src/standard-schema.ts index a1c4f65..cbedfd8 100644 --- a/src/standard-schema.ts +++ b/src/standard-schema.ts @@ -19,7 +19,7 @@ export async function standardValidate( return result.value; } -export type StandardResult = +export type ValidationResult = | { success: true; data: T; @@ -32,7 +32,7 @@ export type StandardResult = export async function standardSafeValidate( schema: T, input: StandardSchemaV1.InferInput -): Promise>> { +): Promise>> { let result = schema['~standard'].validate(input); if (result instanceof Promise) result = await result; diff --git a/test/shell.test.ts b/test/shell.test.ts index 007c174..27809f5 100644 --- a/test/shell.test.ts +++ b/test/shell.test.ts @@ -736,4 +736,859 @@ describe('Shell', () => { } }); }); + + describe('Fluent Shell API - asFluent', () => { + it('should create a fluent shell function', () => { + const shell = createShell(); + const $ = shell.asFluent(); + + expect(typeof $).toBe('function'); + }); + + it('should execute command and return stdout when awaited directly', async () => { + const $ = createShell().asFluent(); + + const result = await $('echo hello'); + + expect(result).toBe('hello'); + }); + + it('should handle command as array', async () => { + const $ = createShell().asFluent(); + + const result = await $(['echo', 'world']); + + expect(result).toBe('world'); + }); + + it('should return empty string for commands with no output', async () => { + const $ = createShell().asFluent(); + + const result = await $('true'); + + expect(result).toBe(''); + }); + + it('should work with shell configuration', async () => { + const $ = createShell({ verbose: true }).asFluent(); + + const result = await $('echo test'); + + expect(result).toBe('test'); + }); + + it('should propagate errors when command fails', async () => { + const $ = createShell().asFluent(); + + await expect($('sh -c "exit 1"')).rejects.toThrow(); + }); + }); + + describe('Fluent Shell API - toLines()', () => { + it('should split output into array of lines', async () => { + const $ = createShell().asFluent(); + + const lines = await $('printf "line1\\nline2\\nline3"').toLines(); + + expect(lines).toEqual(['line1', 'line2', 'line3']); + }); + + it('should handle Windows line endings', async () => { + const $ = createShell().asFluent(); + + const lines = await $('printf "line1\\r\\nline2\\r\\nline3"').toLines(); + + expect(lines).toEqual(['line1', 'line2', 'line3']); + }); + + it('should return empty array for commands with no output', async () => { + const $ = createShell().asFluent(); + + const lines = await $('true').toLines(); + + expect(lines).toEqual([]); + }); + + it('should handle single line output', async () => { + const $ = createShell().asFluent(); + + const lines = await $('echo single').toLines(); + + expect(lines).toEqual(['single']); + }); + + it('should handle output with trailing newline', async () => { + const $ = createShell().asFluent(); + + const lines = await $('printf "a\\nb\\nc\\n"').toLines(); + + expect(lines.length).toBeGreaterThan(0); + expect(lines).toContain('a'); + expect(lines).toContain('b'); + expect(lines).toContain('c'); + }); + + it('should propagate errors when command fails', async () => { + const $ = createShell().asFluent(); + + await expect($('sh -c "exit 1"').toLines()).rejects.toThrow(); + }); + }); + + describe('Fluent Shell API - parse()', () => { + it('should parse JSON output with Zod schema', async () => { + const $ = createShell().asFluent(); + const schema = z.object({ + name: z.string(), + id: z.number(), + }); + + const result = await $('echo \'{"name":"test","id":42}\'').parse(schema); + + expect(result.name).toBe('test'); + expect(result.id).toBe(42); + }); + + it('should work with nested objects', async () => { + const $ = createShell().asFluent(); + const schema = z.object({ + user: z.object({ + name: z.string(), + email: z.string(), + }), + }); + + const result = await $('echo \'{"user":{"name":"John","email":"john@example.com"}}\'').parse(schema); + + expect(result.user.name).toBe('John'); + expect(result.user.email).toBe('john@example.com'); + }); + + it('should work with arrays', async () => { + const $ = createShell().asFluent(); + const schema = z.object({ + items: z.array(z.string()), + }); + + const result = await $('echo \'{"items":["a","b","c"]}\'').parse(schema); + + expect(result.items).toEqual(['a', 'b', 'c']); + }); + + it('should throw error when JSON is invalid', async () => { + const $ = createShell().asFluent(); + const schema = z.object({ value: z.string() }); + + await expect( + $('echo "not valid json"').parse(schema) + ).rejects.toThrow(); + }); + + it('should throw error when schema validation fails', async () => { + const $ = createShell().asFluent(); + const schema = z.object({ + name: z.string(), + count: z.number(), + }); + + await expect( + $('echo \'{"name":"test","count":"not-a-number"}\'').parse(schema) + ).rejects.toThrow(); + }); + + it('should propagate command execution errors', async () => { + const $ = createShell().asFluent(); + const schema = z.object({ value: z.string() }); + + await expect($('sh -c "exit 1"').parse(schema)).rejects.toThrow(); + }); + }); + + describe('Fluent Shell API - Chaining Commands', () => { + it('should allow chaining commands using results', async () => { + const $ = createShell().asFluent(); + + const data = await $('echo test'); + const result = await $(`echo ${data}`); + + expect(result).toBe('test'); + }); + + it('should work with toLines() results', async () => { + const $ = createShell().asFluent(); + + const lines = await $('printf "a\\nb\\nc"').toLines(); + + expect(lines).toHaveLength(3); + + // Use first line in another command + const result = await $(`echo ${lines[0]}`); + expect(result).toBe('a'); + }); + + it('should work with parse() results', async () => { + const $ = createShell().asFluent(); + const schema = z.object({ + dir: z.string(), + }); + + const config = await $('echo \'{"dir":"tmp"}\'').parse(schema); + const result = await $(`echo ${config.dir}`); + + expect(result).toBe('tmp'); + }); + }); + + describe('Fluent Shell API - Integration with Shell Options', () => { + it('should respect verbose mode', async () => { + const mockDebug = vi.fn(); + const shell = createShell({ + verbose: true, + logger: { debug: mockDebug } + }); + const $ = shell.asFluent(); + + await $('echo test'); + + expect(mockDebug).toHaveBeenCalledWith('$ echo test', expect.any(Object)); + }); + + it('should respect dry run mode', async () => { + const $ = createShell({ dryRun: true }).asFluent(); + + // This would fail if executed, but should succeed in dry run + const result = await $('sh -c "exit 1"'); + + expect(result).toBe(''); + }); + + it('should respect throwMode setting', async () => { + const $ = createShell({ throwMode: 'simple' }).asFluent(); + + try { + await $('sh -c "exit 1"'); + expect.fail('Should have thrown'); + } catch (error) { + const message = (error as Error).message; + expect(message).toContain('Command failed'); + expect(message).toContain('Exit code: 1'); + } + }); + + it('should reject live mode at shell level', () => { + // Fluent shell should throw when shell has live mode + expect(() => createShell({ outputMode: 'live' }).asFluent()).toThrow( + "FluentShell does not support outputMode: 'live'" + ); + }); + }); + + describe('Fluent Shell API - Edge Cases', () => { + it('should handle empty string output', async () => { + const $ = createShell().asFluent(); + + const result = await $('echo -n ""'); + + expect(result).toBe(''); + }); + + it('should handle commands with special characters', async () => { + const $ = createShell().asFluent(); + + const result = await $('echo "hello $world"'); + + expect(result).toContain('hello'); + }); + + it('should handle multiline output', async () => { + const $ = createShell().asFluent(); + + const result = await $('printf "line1\\nline2"'); + + expect(result).toContain('line1'); + expect(result).toContain('line2'); + }); + + it('should not allow awaiting twice', async () => { + const $ = createShell().asFluent(); + + const handle = $('echo test'); + const result1 = await handle; + const result2 = await handle; // Awaiting the same promise again + + expect(result1).toBe('test'); + expect(result2).toBe('test'); + }); + + it('should handle long output', async () => { + const $ = createShell().asFluent(); + + // Generate 100 lines + const result = await $('seq 1 100').toLines(); + + expect(result.length).toBeGreaterThanOrEqual(100); + }); + }); + + describe('Fluent Shell API - .result() Non-throwable Execution', () => { + it('should return success result for successful command', async () => { + const $ = createShell().asFluent(); + + const result = await $('echo test').result(); + + expect(result.success).toBe(true); + expect(result.stdout).toBe('test'); + expect(result.stderr).toBe(''); + expect(result.exitCode).toBe(0); + }); + + it('should return failure result without throwing', async () => { + const $ = createShell().asFluent(); + + const result = await $('sh -c "exit 1"').result(); + + expect(result.success).toBe(false); + expect(result.exitCode).toBe(1); + }); + + it('should work with function call syntax', async () => { + const $ = createShell().asFluent(); + + const result = await $('sh -c "exit 42"').result(); + + expect(result.success).toBe(false); + expect(result.exitCode).toBe(42); + }); + + it('should capture stderr in result', async () => { + const $ = createShell().asFluent(); + + const result = await $('sh -c "echo error >&2; exit 1"').result(); + + expect(result.success).toBe(false); + expect(result.stderr).toBe('error'); + }); + + it('should work with array command syntax', async () => { + const $ = createShell().asFluent(); + + const result = await $(['echo', 'test']).result(); + + expect(result.success).toBe(true); + expect(result.stdout).toBe('test'); + }); + + it('should include both stdout and stderr on success', async () => { + const $ = createShell().asFluent(); + + const result = await $('sh -c "echo out; echo err >&2"').result(); + + expect(result.success).toBe(true); + expect(result.stdout).toBe('out'); + expect(result.stderr).toBe('err'); + }); + }); + + describe('Fluent Shell API - Lazy Execution', () => { + it('should not execute immediately when handle is created', async () => { + const mockDebug = vi.fn(); + const $ = createShell({ verbose: true, logger: { debug: mockDebug } }).asFluent(); + + // Create handle - should NOT execute yet + const handle = $('echo test'); + + // No execution yet + expect(mockDebug).not.toHaveBeenCalled(); + + // Consume the handle - NOW it executes + await handle; + + // Now it executed + expect(mockDebug).toHaveBeenCalledWith('$ echo test', expect.any(Object)); + }); + + it('should execute on first await', async () => { + const mockDebug = vi.fn(); + const $ = createShell({ verbose: true, logger: { debug: mockDebug } }).asFluent(); + + const handle = $('echo test'); + expect(mockDebug).not.toHaveBeenCalled(); + + const result = await handle; + + expect(result).toBe('test'); + expect(mockDebug).toHaveBeenCalledTimes(1); + }); + + it('should execute on first .result() call', async () => { + const mockDebug = vi.fn(); + const $ = createShell({ verbose: true, logger: { debug: mockDebug } }).asFluent(); + + const handle = $('echo test'); + expect(mockDebug).not.toHaveBeenCalled(); + + const result = await handle.result(); + + expect(result.success).toBe(true); + expect(result.stdout).toBe('test'); + expect(mockDebug).toHaveBeenCalledTimes(1); + }); + + it('should execute on first .toLines() call', async () => { + const mockDebug = vi.fn(); + const $ = createShell({ verbose: true, logger: { debug: mockDebug } }).asFluent(); + + const handle = $('echo test'); + expect(mockDebug).not.toHaveBeenCalled(); + + const result = await handle.toLines(); + + expect(result).toEqual(['test']); + expect(mockDebug).toHaveBeenCalledTimes(1); + }); + + it('should execute on first .parse() call', async () => { + const mockDebug = vi.fn(); + const $ = createShell({ verbose: true, logger: { debug: mockDebug } }).asFluent(); + const schema = z.object({ value: z.string() }); + + const handle = $('echo \'{"value":"test"}\''); + expect(mockDebug).not.toHaveBeenCalled(); + + const result = await handle.parse(schema); + + expect(result.value).toBe('test'); + expect(mockDebug).toHaveBeenCalledTimes(1); + }); + }); + + describe('Fluent Shell API - Memoization', () => { + it('should reuse execution when awaited multiple times', async () => { + const mockDebug = vi.fn(); + const $ = createShell({ verbose: true, logger: { debug: mockDebug } }).asFluent(); + + const handle = $('echo test'); + + const result1 = await handle; + const result2 = await handle; + const result3 = await handle; + + expect(result1).toBe('test'); + expect(result2).toBe('test'); + expect(result3).toBe('test'); + + // Should only execute once + expect(mockDebug).toHaveBeenCalledTimes(1); + }); + + it('should share execution between await and .result()', async () => { + const mockDebug = vi.fn(); + const $ = createShell({ verbose: true, logger: { debug: mockDebug } }).asFluent(); + + const handle = $('echo test'); + + const result1 = await handle; + const result2 = await handle.result(); + + expect(result1).toBe('test'); + expect(result2.stdout).toBe('test'); + expect(result2.success).toBe(true); + + // Should only execute once + expect(mockDebug).toHaveBeenCalledTimes(1); + }); + + it('should share execution between .result() and .toLines()', async () => { + const mockDebug = vi.fn(); + const $ = createShell({ verbose: true, logger: { debug: mockDebug } }).asFluent(); + + const handle = $('printf "a\nb"'); + + const result1 = await handle.result(); + const result2 = await handle.toLines(); + + expect(result1.stdout).toBe('a\nb'); + expect(result2).toEqual(['a', 'b']); + + // Should only execute once + expect(mockDebug).toHaveBeenCalledTimes(1); + }); + + it('should share execution between all methods', async () => { + const mockDebug = vi.fn(); + const $ = createShell({ verbose: true, logger: { debug: mockDebug } }).asFluent(); + + const handle = $('echo \'{"value":"test"}\''); + const schema = z.object({ value: z.string() }); + + const result1 = await handle; + const result2 = await handle.result(); + const result3 = await handle.parse(schema); + + expect(result1).toBe('{"value":"test"}'); + expect(result2.stdout).toBe('{"value":"test"}'); + expect(result3.value).toBe('test'); + + // Should only execute once + expect(mockDebug).toHaveBeenCalledTimes(1); + }); + + it('should handle errors consistently across multiple consumptions', async () => { + const $ = createShell().asFluent(); + + const handle = $('sh -c "exit 1"'); + + // First consumption throws + await expect(handle).rejects.toThrow(); + + // Second consumption also throws (same error) + await expect(handle).rejects.toThrow(); + + // .result() doesn't throw but shows failure + const result = await handle.result(); + expect(result.success).toBe(false); + expect(result.exitCode).toBe(1); + }); + + }); + + describe('Fluent Shell API - safeParse() Non-throwable', () => { + it('should return success result for valid JSON and schema', async () => { + const $ = createShell().asFluent(); + const schema = z.object({ + name: z.string(), + version: z.string(), + }); + + const result = await $('echo \'{"name":"test","version":"1.0.0"}\'').safeParse(schema); + + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.name).toBe('test'); + expect(result.data.version).toBe('1.0.0'); + } + }); + + it('should return error when command fails', async () => { + const $ = createShell().asFluent(); + const schema = z.object({ value: z.string() }); + + const result = await $('sh -c "exit 1"').safeParse(schema); + + expect(result.success).toBe(false); + if (!result.success) { + expect(result.error[0].message).toContain('Command failed'); + expect(result.error[0].message).toContain('Exit code: 1'); + } + }); + + it('should return error when no output', async () => { + const $ = createShell().asFluent(); + const schema = z.object({ value: z.string() }); + + const result = await $('true').safeParse(schema); + + expect(result.success).toBe(false); + if (!result.success) { + expect(result.error[0].message).toContain('produced no output'); + } + }); + + it('should return error when JSON is invalid', async () => { + const $ = createShell().asFluent(); + const schema = z.object({ value: z.string() }); + + const result = await $('echo "not valid json"').safeParse(schema); + + expect(result.success).toBe(false); + if (!result.success) { + expect(result.error[0].message).toContain('Unable to parse JSON'); + } + }); + + it('should return error when schema validation fails', async () => { + const $ = createShell().asFluent(); + const schema = z.object({ + name: z.string(), + count: z.number(), + }); + + const result = await $('echo \'{"name":"test","count":"not-a-number"}\'').safeParse(schema); + + expect(result.success).toBe(false); + if (!result.success) { + expect(result.error.length).toBeGreaterThan(0); + } + }); + + it('should work with function call syntax', async () => { + const $ = createShell().asFluent(); + const schema = z.object({ value: z.string() }); + + const result = await $('echo \'{"value":"hello"}\'').safeParse(schema); + + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.value).toBe('hello'); + } + }); + + it('should work with array command syntax', async () => { + const $ = createShell().asFluent(); + const schema = z.object({ value: z.string() }); + + const result = await $(['echo', '{"value":"world"}']).safeParse(schema); + + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.value).toBe('world'); + } + }); + + it('should work with nested objects', async () => { + const $ = createShell().asFluent(); + const schema = z.object({ + user: z.object({ + name: z.string(), + email: z.string(), + }), + }); + + const result = await $('echo \'{"user":{"name":"Alice","email":"alice@example.com"}}\'').safeParse(schema); + + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.user.name).toBe('Alice'); + expect(result.data.user.email).toBe('alice@example.com'); + } + }); + + it('should work with arrays', async () => { + const $ = createShell().asFluent(); + const schema = z.object({ + items: z.array(z.string()), + }); + + const result = await $('echo \'{"items":["a","b","c"]}\'').safeParse(schema); + + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.items).toEqual(['a', 'b', 'c']); + } + }); + + it('should never throw on any error', async () => { + const $ = createShell().asFluent(); + const schema = z.object({ value: z.string() }); + + // Command failure - should not throw + const result1 = await $('sh -c "exit 1"').safeParse(schema); + expect(result1.success).toBe(false); + + // Invalid JSON - should not throw + const result2 = await $('echo "{{{"').safeParse(schema); + expect(result2.success).toBe(false); + + // Schema validation failure - should not throw + const result3 = await $('echo \'{"value":123}\'').safeParse(schema); + expect(result3.success).toBe(false); + + // All should have returned without throwing + expect(true).toBe(true); + }); + + it('should be memoized with other methods', async () => { + const mockDebug = vi.fn(); + const $ = createShell({ verbose: true, logger: { debug: mockDebug } }).asFluent(); + const schema = z.object({ value: z.string() }); + + const handle = $('echo \'{"value":"test"}\''); + + // First call with safeParse + const result1 = await handle.safeParse(schema); + expect(result1.success).toBe(true); + + // Second call with parse (should reuse execution) + const result2 = await handle.parse(schema); + expect(result2.value).toBe('test'); + + // Third call with result (should still reuse) + const result3 = await handle.result(); + expect(result3.success).toBe(true); + + // Should only execute once + expect(mockDebug).toHaveBeenCalledTimes(1); + }); + + it('should work with Zod transformations', async () => { + const $ = createShell().asFluent(); + const schema = z + .object({ + name: z.string(), + }) + .transform(data => ({ + ...data, + uppercase: data.name.toUpperCase(), + })); + + const result = await $('echo \'{"name":"hello"}\'').safeParse(schema); + + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.uppercase).toBe('HELLO'); + } + }); + }); + + describe('Fluent Shell API - OutputMode Support', () => { + it('should work with capture mode (default)', async () => { + const shell = createShell({ outputMode: 'capture' }); + const $ = shell.asFluent(); + + const result = await $('echo hello'); + + expect(result).toBe('hello'); + }); + + it('should work with all mode from shell options', async () => { + const shell = createShell({ outputMode: 'all' }); + const $ = shell.asFluent(); + + const result = await $(['echo', 'world']).result(); + + expect(result.success).toBe(true); + expect(result.stdout).toBe('world'); + }); + + it('should throw when shell has live mode', () => { + const shell = createShell({ outputMode: 'live' }); + + expect(() => shell.asFluent()).toThrow( + "FluentShell does not support outputMode: 'live'. Use 'capture' or 'all', or call shell.run(..., { outputMode: 'live' }) instead." + ); + }); + + it('should throw when overriding to live mode', async () => { + const shell = createShell({ outputMode: 'capture' }); + const $ = shell.asFluent(); + + await expect(async () => { + await $(['echo', 'x'], { outputMode: 'live' as any }); + }).rejects.toThrow("FluentShell does not support outputMode: 'live'"); + }); + + it('should allow overriding to all mode', async () => { + const shell = createShell({ outputMode: 'capture' }); + const $ = shell.asFluent(); + + const result = await $(['echo', 'test'], { outputMode: 'all' }).result(); + + expect(result.success).toBe(true); + expect(result.stdout).toBe('test'); + }); + + it('should inherit outputMode from ShellOptions', async () => { + const shell = createShell({ outputMode: 'all' }); + const $ = shell.asFluent(); + + const result = await $('echo inherited').result(); + + expect(result.success).toBe(true); + expect(result.stdout).toBe('inherited'); + }); + + it('should support toLines() in all mode', async () => { + const shell = createShell({ outputMode: 'all' }); + const $ = shell.asFluent(); + + const lines = await $('printf "line1\\nline2"').toLines(); + + expect(lines).toEqual(['line1', 'line2']); + }); + + it('should support parse() in all mode', async () => { + const shell = createShell({ outputMode: 'all' }); + const $ = shell.asFluent(); + const schema = z.object({ value: z.string() }); + + const data = await $('echo \'{"value":"test"}\'').parse(schema); + + expect(data.value).toBe('test'); + }); + + it('should memoize execution with options', async () => { + const shell = createShell({ outputMode: 'capture' }); + const $ = shell.asFluent(); + const handle = $(['echo', 'memo'], { outputMode: 'all' }); + + const result1 = await handle.result(); + const result2 = await handle.result(); + + expect(result1.stdout).toBe('memo'); + expect(result2.stdout).toBe('memo'); + // Results should be the same object (memoized) + expect(result1).toBe(result2); + }); + + it('should provide clear error message for live mode at shell level', () => { + const shell = createShell({ outputMode: 'live' }); + + expect(() => shell.asFluent()).toThrow( + "FluentShell does not support outputMode: 'live'. Use 'capture' or 'all', or call shell.run(..., { outputMode: 'live' }) instead." + ); + }); + + it('should allow capture mode override on shell with all mode', async () => { + const shell = createShell({ outputMode: 'all' }); + const $ = shell.asFluent(); + + const result = await $(['echo', 'override'], { outputMode: 'capture' }).result(); + + expect(result.success).toBe(true); + expect(result.stdout).toBe('override'); + }); + + + it('should handle options merging with outputMode override', async () => { + const shell = createShell({ outputMode: 'capture', verbose: true }); + const $ = shell.asFluent(); + + const result = await $(['echo', 'test'], { outputMode: 'all', dryRun: true }).result(); + + // In dry run mode, should return success without executing + expect(result.success).toBe(true); + expect(result.stdout).toBe(''); + }); + + it('should support safeParse() in all mode', async () => { + const shell = createShell({ outputMode: 'all' }); + const $ = shell.asFluent(); + const schema = z.object({ key: z.string() }); + + const result = await $('echo \'{"key":"value"}\'').safeParse(schema); + + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.key).toBe('value'); + } + }); + + it('should work with function call without options', async () => { + const shell = createShell({ outputMode: 'capture' }); + const $ = shell.asFluent(); + + const result = await $('echo no-options'); + + expect(result).toBe('no-options'); + }); + + it('should work with array call without options', async () => { + const shell = createShell({ outputMode: 'capture' }); + const $ = shell.asFluent(); + + const result = await $(['echo', 'array-no-options']); + + expect(result).toBe('array-no-options'); + }); + }); });