diff --git a/.changeset/chubby-cups-sip.md b/.changeset/chubby-cups-sip.md new file mode 100644 index 0000000..f0d234b --- /dev/null +++ b/.changeset/chubby-cups-sip.md @@ -0,0 +1,5 @@ +--- +'@thaitype/shell': major +--- + +Refactor Shell Option, More Type Safety, Meaningful Option, Support Run with Standard Schema which provide type-safety command line diff --git a/CLAUDE.md b/CLAUDE.md index ebff12c..130c798 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -26,11 +26,25 @@ src/ ### Shell Class Design -The `Shell` class (`src/shell.ts`) is the only export and implements: +The `Shell` class (`src/shell.ts`) provides three public methods: + +1. **`run()`** - Recommended for most use cases. Throws on error. + - Returns `StrictResult` with stdout and stderr only + - Command either succeeds or throws an exception + +2. **`safeRun()`** - Never throws. Returns error result instead. + - Returns `SafeResult` with stdout, stderr, exitCode, and success flag + - Always succeeds, check `result.success` to determine outcome + +3. **`execute()`** - Low-level method with explicit throwOnError control + - Pass `{ throwOnError: true }` for run() behavior + - Pass `{ throwOnError: false }` for safeRun() behavior + +**Implementation Details:** 1. **Output Modes** - Three strategies for handling stdout/stderr: - `capture`: Pipes output for programmatic access (default) - - `live`: Inherits stdio, streams to console in real-time + - `live`: Inherits stdio, streams to console in real-time (returns null for stdout/stderr) - `all`: Combines both - captures AND streams simultaneously Implementation detail: Maps output modes to execa stdio configuration using a `stdioMap` object @@ -50,9 +64,10 @@ The `Shell` class (`src/shell.ts`) is the only export and implements: ### Key Interfaces -- `ShellOptions`: Constructor configuration (defaultOutputMode, dryRun, verbose, throwOnError, throwMode, logger) +- `ShellOptions`: Constructor configuration (defaultOutputMode, dryRun, verbose, throwMode, logger) - `RunOptions`: Per-command overrides, extends `ExecaOptions` from execa -- `RunResult`: Structured return value (stdout, stderr, exitCode, isError, isSuccess) +- `StrictResult`: Return type for `run()` (stdout, stderr) +- `SafeResult`: Return type for `safeRun()` (stdout, stderr, exitCode, success) ## Development Commands @@ -73,9 +88,10 @@ The build uses Babel's `annotate-pure-calls` plugin for better tree-shaking. pnpm test # Run tests in watch mode (Vitest) pnpm test:ci # Run tests once (for CI) pnpm test:coverage # Generate coverage report +pnpm test:coverage:feedback # Run coverage with detailed feedback on uncovered lines ``` -Note: Currently no test files exist in the repository. Tests should be added as `*.test.ts` or `*.spec.ts` files (they're excluded from builds). +Tests are located in `test/shell.test.ts` and focus on Shell class logic (not execa features). ### Linting & Type Checking ```bash @@ -115,7 +131,12 @@ this.logger?.(`$ ${args.join(' ')}`); ``` ### Error Handling Strategy -When catching `ExecaError`, the class checks `throwOnError` before re-throwing. If disabled, it returns a `RunResult` with `isError: true` instead of throwing. +Three approaches to error handling: +- `run()`: Always throws on error (uses `reject: true` in execa) +- `safeRun()`: Never throws, returns result with `success: false` (uses `reject: false` in execa) +- `execute({ throwOnError })`: Explicit control over throw behavior + +When using `safeRun()` or `execute({ throwOnError: false })`, check `result.success` to determine if the command succeeded. ### Stdio Configuration Output modes are implemented by mapping to execa's stdio arrays: diff --git a/README.md b/README.md index 6dd9f80..fdc64a9 100644 --- a/README.md +++ b/README.md @@ -21,9 +21,11 @@ Running shell commands in Node.js often involves repetitive boilerplate and deal - **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 +- **Verbose logging**: Automatically log all executed commands with contextual information - **Flexible error handling**: Choose to throw on errors or handle them gracefully -- **Custom logger support**: Integrate with your preferred logging solution +- **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 @@ -52,10 +54,10 @@ bun add @thaitype/shell ## Basic Usage ```typescript -import { Shell } from '@thaitype/shell'; +import { createShell } from '@thaitype/shell'; // Create a shell instance -const shell = new Shell(); +const shell = createShell(); // Run a command const result = await shell.run('echo "Hello World"'); @@ -70,9 +72,9 @@ console.log(result.stdout); // "Hello World" Perfect for build scripts and CI/CD pipelines where you want to see what's being executed: ```typescript -import { Shell } from '@thaitype/shell'; +import { createShell } from '@thaitype/shell'; -const shell = new Shell({ +const shell = createShell({ verbose: true // Logs every command before execution }); @@ -90,9 +92,9 @@ await shell.run('npm run build'); Test your automation scripts without actually executing commands: ```typescript -import { Shell } from '@thaitype/shell'; +import { createShell } from '@thaitype/shell'; -const shell = new Shell({ +const shell = createShell({ dryRun: true, // Commands are logged but not executed verbose: true }); @@ -114,9 +116,9 @@ console.log('Dry run complete - no actual changes made!'); Control how command output is handled: ```typescript -import { Shell } from '@thaitype/shell'; +import { createShell } from '@thaitype/shell'; -const shell = new Shell(); +const shell = createShell(); // Capture mode (default): Capture output for programmatic use const result1 = await shell.run('ls -la', { outputMode: 'capture' }); @@ -134,18 +136,17 @@ console.log('Build output was:', result2.stdout); ### 4. Graceful Error Handling -Handle command failures without throwing exceptions: +Handle command failures without throwing exceptions using `safeRun()`: ```typescript -import { Shell } from '@thaitype/shell'; +import { createShell } from '@thaitype/shell'; -const shell = new Shell({ - throwOnError: false // Don't throw on non-zero exit codes -}); +const shell = createShell(); -const result = await shell.run('some-command-that-might-fail'); +// safeRun() never throws, returns error result instead +const result = await shell.safeRun('some-command-that-might-fail'); -if (result.isError) { +if (!result.success) { console.error('Command failed with exit code:', result.exitCode); console.error('Error output:', result.stderr); // Handle the error gracefully @@ -154,27 +155,81 @@ if (result.isError) { } ``` +### 5. Schema Validation with JSON Output + +Parse and validate JSON output from commands using Standard Schema: + +```typescript +import { createShell } from '@thaitype/shell'; +import { z } from 'zod'; + +const shell = createShell(); + +// Define a schema for package.json +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); +console.log(`Package: ${pkg.name}@${pkg.version}`); + +// Safe parse - returns result object +const apiSchema = z.object({ + status: z.string(), + data: z.array(z.object({ + id: z.number(), + name: z.string(), + })), +}); + +const result = await shell.safeRunParse( + 'curl -s https://api.example.com/users', + apiSchema +); + +if (result.success) { + result.data.data.forEach(user => { + console.log(`User: ${user.name} (${user.id})`); + }); +} else { + console.error('API validation failed:', result.error); +} +``` + ## API +### `createShell(options?)` (Recommended) + +Factory function to create a new Shell instance with better type inference. + +**Recommended:** Use `createShell()` instead of `new Shell()` for better developer experience and automatic type inference of the default output mode. + +```typescript +import { createShell } from '@thaitype/shell'; + +// Type inference automatically detects 'live' as default mode +const shell = createShell({ outputMode: 'live' }); +``` + ### `new Shell(options?)` -Creates a new Shell instance with the specified configuration. +Alternative constructor for creating a Shell instance. #### Options ```typescript interface ShellOptions { /** Default output mode applied to all runs unless overridden */ - defaultOutputMode?: OutputMode; // 'capture' | 'live' | 'all' + outputMode?: OutputMode; // 'capture' | 'live' | 'all', default: 'capture' /** If true, print commands but skip actual execution */ - dryRun?: boolean; + dryRun?: boolean; // default: false /** If true, log every executed command */ - verbose?: boolean; - - /** If true, throw an error when a command exits with non-zero code */ - throwOnError?: boolean; // default: true + verbose?: boolean; // default: false /** * Controls how errors are thrown when a command fails. @@ -183,38 +238,79 @@ interface ShellOptions { */ throwMode?: 'simple' | 'raw'; // default: 'simple' - /** Optional custom logger function for command output */ - logger?: (message: string) => void; + /** + * 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; + + /** + * 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; +} + +interface ShellLogger { + /** Called for verbose command logging. Defaults to console.debug */ + debug?(message: string, context: ShellLogContext): void; + + /** Called for warnings. Defaults to console.warn */ + warn?(message: string, context: ShellLogContext): void; +} + +interface ShellLogContext { + /** The command being executed */ + command: string | string[]; + + /** Execa options used for the command execution */ + execaOptions: ExecaOptions; } ``` ### `shell.run(command, options?)` -Executes a shell command and returns a structured result. +Executes a shell command that **throws on error**. Recommended for most use cases where you want to fail fast. #### Parameters - `command: string | string[]` - The command to execute. Can be a string (with automatic parsing) or an array of arguments. - `options?: RunOptions` - Optional execution options. -#### RunOptions +#### Returns ```typescript -interface RunOptions extends ExecaOptions { - /** Override the output behavior for this specific command */ - outputMode?: OutputMode; // 'capture' | 'live' | 'all' +interface StrictResult { + /** Captured stdout output, or null if not captured */ + stdout: string | null; - /** Whether to throw error on non-zero exit */ - throwOnError?: boolean; + /** Captured stderr output, or null if not captured */ + stderr: string | null; } ``` -Inherits all options from [execa's Options](https://github.com/sindresorhus/execa#options). +**Throws**: Error when command exits with non-zero code (format depends on `throwMode`). + +### `shell.safeRun(command, options?)` + +Executes a shell command that **never throws**. Returns error result instead. + +Use this when you want to handle errors programmatically without try/catch. + +#### Parameters + +- `command: string | string[]` - The command to execute. +- `options?: RunOptions` - Optional execution options. #### Returns ```typescript -interface RunResult { +interface SafeResult { /** Captured stdout output, or null if not captured */ stdout: string | null; @@ -224,11 +320,125 @@ interface RunResult { /** Exit code returned by the executed process */ exitCode: number | undefined; - /** Indicates whether the command exited with an error */ - isError: boolean; + /** True if command exited with code 0 */ + success: boolean; +} +``` + +### `shell.execute(command, options?)` + +Low-level method with explicit `throwOnError` control. + +#### Parameters + +- `command: string | string[]` - The command to execute. +- `options?: RunOptions & { throwOnError?: boolean }` - Optional execution options including throwOnError flag. + +#### RunOptions + +```typescript +interface RunOptions extends ExecaOptions { + /** Override the output behavior for this specific command */ + outputMode?: OutputMode; // 'capture' | 'live' | 'all' + + /** Override verbose logging for this specific command */ + verbose?: boolean; - /** Indicates whether the command executed successfully */ - isSuccess: boolean; + /** Override dry-run mode for this specific command */ + dryRun?: boolean; +} +``` + +Inherits all options from [execa's Options](https://github.com/sindresorhus/execa#options). + +**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. + +### `shell.runParse(command, schema, options?)` + +Execute a command, parse its stdout as JSON, and validate it against a [Standard Schema](https://github.com/standard-schema/standard-schema). + +**Throws on error** - Command failure or validation failure will throw an exception. + +#### Parameters + +- `command: string | string[]` - The command to execute. +- `schema: StandardSchemaV1` - A Standard Schema to validate the JSON output. +- `options?: RunOptions` - Optional execution options. + +#### Returns + +- Type-safe parsed and validated output based on the schema. + +#### Throws + +- Error when command fails +- Error when output is not valid JSON +- Error when output doesn't match the schema + +**Example with Zod:** + +```typescript +import { createShell } from '@thaitype/shell'; +import { z } from 'zod'; + +const shell = createShell(); + +const packageSchema = z.object({ + name: z.string(), + version: z.string(), +}); + +// Execute command and validate JSON output +const pkg = await shell.runParse( + 'cat package.json', + packageSchema +); + +console.log(pkg.name, pkg.version); // Type-safe! +``` + +### `shell.safeRunParse(command, schema, options?)` + +Execute a command, parse its stdout as JSON, and validate it against a Standard Schema. + +**Never throws** - Returns a result object with success/error information. + +#### Parameters + +- `command: string | string[]` - The command to execute. +- `schema: StandardSchemaV1` - A Standard Schema to validate the JSON output. +- `options?: RunOptions` - Optional execution options. + +#### Returns + +```typescript +type StandardResult = + | { success: true; data: T } + | { success: false; error: Array<{ message: string }> }; +``` + +**Example with Zod:** + +```typescript +import { createShell } from '@thaitype/shell'; +import { z } from 'zod'; + +const shell = createShell(); + +const userSchema = z.object({ + username: z.string(), + id: z.number(), +}); + +const result = await shell.safeRunParse( + 'curl -s https://api.example.com/user', + userSchema +); + +if (result.success) { + console.log('User:', result.data.username); +} else { + console.error('Validation failed:', result.error); } ``` @@ -240,10 +450,34 @@ interface RunResult { ## Advanced Examples +### Using run() vs safeRun() + +```typescript +import { createShell } from '@thaitype/shell'; + +const shell = createShell(); + +// 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); +} + +// 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); +} +``` + ### Custom Logger Integration ```typescript -import { Shell } from '@thaitype/shell'; +import { createShell } from '@thaitype/shell'; import winston from 'winston'; const logger = winston.createLogger({ @@ -252,29 +486,52 @@ const logger = winston.createLogger({ transports: [new winston.transports.Console()] }); -const shell = new Shell({ +const shell = createShell({ verbose: true, - logger: (message) => logger.info(message) + logger: { + debug: (message, context) => { + logger.debug(message, { + command: context.command, + cwd: context.execaOptions.cwd + }); + }, + warn: (message, context) => { + logger.warn(message, { command: context.command }); + } + } }); await shell.run('npm install'); -// Commands are logged using Winston +// Commands are logged using Winston with contextual information ``` ### Combining with Execa Options ```typescript -import { Shell } from '@thaitype/shell'; - -const shell = new Shell(); +import { createShell } from '@thaitype/shell'; + +// Shell-level default options +const shell = createShell({ + execaOptions: { + env: { API_KEY: 'default-key', NODE_ENV: 'development' }, + timeout: 5000, + cwd: '/default/directory' + } +}); -// Pass any execa options +// Command-level options are deep merged with shell-level const result = await shell.run('node script.js', { - cwd: '/custom/directory', - env: { NODE_ENV: 'production' }, - timeout: 30000, + env: { NODE_ENV: 'production', EXTRA: 'value' }, // Deep merged + timeout: 30000, // Overrides shell-level timeout outputMode: 'capture' }); + +// Resulting options: +// { +// env: { API_KEY: 'default-key', NODE_ENV: 'production', EXTRA: 'value' }, +// timeout: 30000, +// cwd: '/default/directory' +// } ``` ## License diff --git a/examples/run.ts b/examples/run.ts new file mode 100644 index 0000000..9c5da87 --- /dev/null +++ b/examples/run.ts @@ -0,0 +1,13 @@ + +import { createShell } from "src/shell.js"; + +async function main() { + const shell = createShell({ + outputMode: 'capture', + verbose: true + }); + const result = await shell.run("echo Hello, World!"); + console.log("Command Output:", result.stdout); +} + +main(); \ No newline at end of file diff --git a/examples/runParse.ts b/examples/runParse.ts new file mode 100644 index 0000000..a4e2c5c --- /dev/null +++ b/examples/runParse.ts @@ -0,0 +1,14 @@ +import { createShell } from "src/shell.js"; +import { z } from "zod"; + +const schema = z.object({ + username: z.string(), +}); + +async function main() { + const shell = createShell(); + const result = await shell.runParse(`echo '{ "username": "John" }'`, schema); + console.log("Command Output:", result.username); +} + +main(); \ No newline at end of file diff --git a/examples/safeRun.ts b/examples/safeRun.ts new file mode 100644 index 0000000..25b5751 --- /dev/null +++ b/examples/safeRun.ts @@ -0,0 +1,16 @@ +import { createShell } from "src/shell.js"; + +async function main() { + const shell = createShell({ + outputMode: 'capture', + verbose: true, + }); + const result = await shell.safeRun("echo Hello, World!"); + if (result.success) { + console.log("Command Output:", result.stdout); + } else { + console.error("Command Error:", result.stderr); + } +} + +main(); \ No newline at end of file diff --git a/examples/safeRunParse.ts b/examples/safeRunParse.ts new file mode 100644 index 0000000..56376d7 --- /dev/null +++ b/examples/safeRunParse.ts @@ -0,0 +1,20 @@ +import { createShell } from "src/shell.js"; +import { z } from "zod"; + +const schema = z.object({ + username: z.string(), +}); + +async function main() { + const shell = createShell({ + verbose: true + }); + const result = await shell.safeRunParse(`echo '{ "username1": "John"' }`, schema); + if(result.success) { + console.log("Command Output:", result.data.username); + } else { + console.error("Validation Error:", result.error); + } +} + +main(); \ No newline at end of file diff --git a/package.json b/package.json index 3d74d9f..2884142 100644 --- a/package.json +++ b/package.json @@ -53,7 +53,8 @@ "typescript": "^5.8.2", "typescript-eslint": "^8.27.0", "vite-tsconfig-paths": "^5.1.4", - "vitest": "^3.2.4" + "vitest": "^3.2.4", + "zod": "^4.1.12" }, "keywords": [], "author": "Thada Wangthammang", @@ -74,6 +75,8 @@ "node": ">=20" }, "dependencies": { + "@standard-schema/spec": "^1.0.0", + "deepmerge": "^4.3.1", "execa": "^9.6.0", "string-argv": "^0.3.2" } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 4c836fb..85ee7f2 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -8,6 +8,12 @@ importers: .: dependencies: + '@standard-schema/spec': + specifier: ^1.0.0 + version: 1.0.0 + deepmerge: + specifier: ^4.3.1 + version: 4.3.1 execa: specifier: ^9.6.0 version: 9.6.0 @@ -75,6 +81,9 @@ importers: vitest: specifier: ^3.2.4 version: 3.2.4(tsx@4.20.6) + zod: + specifier: ^4.1.12 + version: 4.1.12 packages: @@ -612,6 +621,9 @@ packages: resolution: {integrity: sha512-tlqY9xq5ukxTUZBmoOp+m61cqwQD5pHJtFY3Mn8CA8ps6yghLH/Hw8UPdqg4OLmFW3IFlcXnQNmo/dh8HzXYIQ==} engines: {node: '>=18'} + '@standard-schema/spec@1.0.0': + resolution: {integrity: sha512-m2bOd0f2RT9k8QJx1JN85cZYyH1RqFBdlwtkSlf4tBDYLCiiZnv1fIIwacK6cqwXavOydf0NPToMQgpKq+dVlA==} + '@types/chai@5.2.3': resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==} @@ -901,6 +913,10 @@ packages: deep-is@0.1.4: resolution: {integrity: sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==} + deepmerge@4.3.1: + resolution: {integrity: sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==} + engines: {node: '>=0.10.0'} + detect-indent@6.1.0: resolution: {integrity: sha512-reYkTUJAZb9gUuZ2RvVCNhVHdg62RHnJ7WJl8ftMi4diZ6NWlciOzQN88pUhSELEwflJht4oQDv0F0BMlwaYtA==} engines: {node: '>=8'} @@ -1978,6 +1994,9 @@ packages: resolution: {integrity: sha512-CzhO+pFNo8ajLM2d2IW/R93ipy99LWjtwblvC1RsoSUMZgyLbYFr221TnSNT7GjGdYui6P459mw9JH/g/zW2ug==} engines: {node: '>=18'} + zod@4.1.12: + resolution: {integrity: sha512-JInaHOamG8pt5+Ey8kGmdcAcg3OL9reK8ltczgHTAwNhMys/6ThXHityHxVV2p3fkw/c+MAvBHFVYHFZDmjMCQ==} + snapshots: '@babel/cli@7.28.3(@babel/core@7.28.5)': @@ -2529,6 +2548,8 @@ snapshots: '@sindresorhus/merge-streams@4.0.0': {} + '@standard-schema/spec@1.0.0': {} + '@types/chai@5.2.3': dependencies: '@types/deep-eql': 4.0.2 @@ -2851,6 +2872,8 @@ snapshots: deep-is@0.1.4: {} + deepmerge@4.3.1: {} + detect-indent@6.1.0: {} dir-glob@3.0.1: @@ -3894,3 +3917,5 @@ snapshots: yocto-queue@0.1.0: {} yoctocolors@2.1.2: {} + + zod@4.1.12: {} diff --git a/src/shell.ts b/src/shell.ts index 3e4625b..e93cd24 100644 --- a/src/shell.ts +++ b/src/shell.ts @@ -1,92 +1,415 @@ -/** - * A utility class for running shell commands with flexible output modes and configuration options. - * Supports dry-run, verbose logging, output streaming, and error handling control. - * - * @example - * const shell = new Shell({ verbose: true }); - * const result = await shell.run("bash delay.sh"); - * if (result.isSuccess) console.log(result.stdout); - */ +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'; -/** Output mode behavior for handling stdout/stderr */ +/** + * Output mode behavior for handling stdout/stderr. + * + * - `'capture'` - Captures output for programmatic access (default) + * - `'live'` - Streams output to console in real-time + * - `'all'` - Both captures AND streams output simultaneously + */ export type OutputMode = 'capture' | 'live' | 'all'; -/** Configuration options for Shell instance */ -export interface ShellOptions { - /** Default output mode applied to all runs unless overridden */ - defaultOutputMode?: OutputMode; - /** If true, print commands but skip actual execution */ +/** + * Type utility to determine if an output mode captures output. + * Returns false for 'live' mode, true for 'capture' and 'all'. + */ +type CaptureForMode = M extends 'live' ? false : true; + +/** + * Default options that can be overridden per command execution. + * + * These options can be set at the Shell instance level and overridden per command. + * When both shell-level and command-level options are provided, they are deep merged, + * with command-level options taking precedence. + */ +export interface OverridableCommandOptions { + /** + * Default execa options applied to all command executions. + * + * When command-level execaOptions are provided, they are deep merged with + * shell-level options using the `deepmerge` library. Command-level options + * override shell-level options. + * + * @example + * ```typescript + * // Shell-level defaults + * const shell = createShell({ + * execaOptions: { + * env: { API_KEY: 'default' }, + * timeout: 5000 + * } + * }); + * + * // Command-level override (deep merged) + * await shell.run('command', { + * env: { EXTRA: 'value' }, // Both API_KEY and EXTRA available + * timeout: 10000 // Overrides shell-level timeout + * }); + * ``` + */ + execaOptions?: ShellExecaOptions; + /** + * Default output mode applied to all runs unless overridden. + * + * @default 'capture' + */ + outputMode?: Mode; + /** + * If true, print commands but skip actual execution. + * Useful for testing scripts without making real changes. + * + * @default false + */ dryRun?: boolean; - /** If true, log every executed command */ + /** + * If true, log every executed command to the logger. + * Helpful for debugging and CI/CD pipelines. + * + * @default false + */ verbose?: boolean; - /** If true, throw an error when a command exits with non-zero code, @default true */ - throwOnError?: boolean; +} + +export interface ShellLogger { + /** + * Called when Shell wants to emit a debug-level log. + * Defaults to console.debug. + */ + debug?(message: string, context: ShellLogContext): void; + + /** + * Called when Shell wants to emit a warning or non-fatal issue. + * Defaults to console.warn. + */ + warn?(message: string, context: ShellLogContext): void; +} + +/** Provides contextual info about where the log originated. */ +export interface ShellLogContext { + /** + * The command that was being executed when the log was generated. + */ + command: string | string[]; + /** + * Execa options used for the command execution. + */ + execaOptions: ExecaOptions; +} + +/** + * Configuration options for Shell instance. + * + * @template Mode - The default output mode type (defaults to 'capture') + */ +export interface ShellOptions extends OverridableCommandOptions { /** * Controls how errors are thrown when a command fails. - * - `"simple"` → Throws a short, human-readable error message. + * - `"simple"` → Throws a short, human-readable error message with command, exit code, and stderr. * - `"raw"` → Throws the full ExecaError object with complete details. * + * Only applies when using `run()` or `execute()` with `throwOnError: true`. + * * @default "simple" */ throwMode?: 'simple' | 'raw'; - /** Optional custom logger function for command output */ - logger?: (message: string) => void; + + /** + * Optional custom logger for command output and diagnostics. + * + * Provides two logging methods: + * - `debug(message, context)` - Called for verbose command logging (defaults to console.debug) + * - `warn(message, context)` - Called for warnings (defaults to console.warn) + * + * The context parameter includes the command and final execa options. + * + * @example + * ```typescript + * const shell = createShell({ + * verbose: true, + * logger: { + * debug: (message, context) => { + * console.log(`[DEBUG] ${message}`); + * console.log('Command:', context.command); + * }, + * warn: (message, context) => { + * console.warn(`[WARN] ${message}`, context); + * } + * } + * }); + * ``` + * + * @default { debug: console.debug, warn: console.warn } + */ + logger?: ShellLogger; } -/** Options for an individual command execution */ -export interface RunOptions extends ExecaOptions { - /** Override the output behavior for this specific command */ - outputMode?: OutputMode; - /** Whether to throw error on non-zero exit */ - throwOnError?: boolean; +/** + * Execa options that can be passed to Shell methods. + * We handle some properties internally, so we omit them to avoid conflicts: + * - `reject` - handled by throwOnError + * - `verbose` - we use our own boolean verbose flag + * - `stdout`/`stderr` - handled by outputMode + * + * All other execa options (like `cwd`, `env`, `timeout`) can be passed through. + */ +export type ShellExecaOptions = Omit; + +/** + * Options for an individual command execution. + * + * Extends overridable command options (outputMode, verbose, dryRun) and execa options. + * All execa options provided here will be deep merged with shell-level execaOptions, + * with command-level options taking precedence. + * + * @template Mode - The output mode type for this specific command + * + * @example + * ```typescript + * const shell = createShell({ + * execaOptions: { env: { API_KEY: 'default' } } + * }); + * + * // Command-level options are deep merged with shell-level + * await shell.run('command', { + * outputMode: 'live', // Override output mode + * verbose: true, // Override verbose + * env: { EXTRA: 'value' }, // Merged with shell-level env + * timeout: 5000 // Added to shell-level options + * }); + * ``` + */ +export interface RunOptions + extends Omit, 'execaOptions'>, + ShellExecaOptions {} + +/** + * Strict result returned by `run()` method (throws on error). + * Only includes stdout/stderr, as the command either succeeds or throws. + * + * @template Capture - Whether output is captured (false for 'live' mode) + */ +export interface StrictResult { + /** Captured stdout output (string if captured, null if live mode) */ + stdout: Capture extends true ? string : null; + /** Captured stderr output (string if captured, null if live mode) */ + stderr: Capture extends true ? string : null; } -/** The structured result returned by Shell.run() */ -export interface RunResult { - /** Captured stdout output, or null if not captured */ - stdout: string | null; - /** Captured stderr output, or null if not captured */ - stderr: string | null; - /** Exit code returned by the executed process */ +/** + * Safe result returned by `safeRun()` method (never throws). + * Includes all execution details including exit code and error flags. + * + * @template Capture - Whether output is captured (false for 'live' mode) + */ +export interface SafeResult extends StrictResult { + /** Exit code returned by the executed process (undefined if command failed to start) */ exitCode: number | undefined; - /** Indicates whether the command exited with an error */ - isError: boolean; - /** Indicates whether the command executed successfully */ - isSuccess: boolean; + /** True if the command exited with code 0 */ + success: boolean; } -export class Shell { - private defaultOutputMode: OutputMode; +/** + * Result type that varies based on whether the command throws on error. + * + * @template Throw - Whether the method throws on error (true for run(), false for safeRun()) + * @template Mode - The output mode used for the command + */ +export type RunResult = Throw extends true + ? StrictResult> + : SafeResult>; + +/** + * Factory function to create a new Shell instance with type inference. + * Provides better type safety and convenience compared to using `new Shell()`. + * + * @template DefaultMode - The default output mode type (inferred from options) + * @param options - Configuration options for the Shell instance + * @returns A new Shell instance with the specified configuration + * + * @example Basic usage + * ```typescript + * const shell = createShell({ + * outputMode: 'live', + * verbose: true + * }); + * await shell.run('npm install'); // Output streams to console + * ``` + * + * @example With default execaOptions and custom logger + * ```typescript + * const shell = createShell({ + * execaOptions: { + * env: { NODE_ENV: 'production', API_URL: 'https://api.example.com' }, + * timeout: 30000 + * }, + * verbose: true, + * logger: { + * debug: (message, context) => { + * console.log(`[${new Date().toISOString()}] ${message}`); + * console.log('Execa options:', context.execaOptions); + * } + * } + * }); + * + * // All commands inherit shell-level execaOptions + * await shell.run('npm install'); + * + * // Command-level options are deep merged with shell-level + * await shell.run('npm test', { + * env: { TEST_ENV: 'true' }, // Merged with shell-level env + * timeout: 120000 // Overrides shell-level timeout + * }); + * ``` + */ +export function createShell(options: ShellOptions = {}) { + return new Shell(options); +} + +/** + * Type-safe Shell class for executing commands with configurable behavior. + * Use `createShell()` factory function for better type inference. + * + * @template DefaultMode - The default output mode for this instance (defaults to 'capture') + * + * @example Creating a shell instance + * ```typescript + * const shell = new Shell({ + * outputMode: 'capture', + * verbose: true + * }); + * ``` + * + * @example Using the static factory + * ```typescript + * const shell = Shell.create({ + * dryRun: true + * }); + * ``` + */ +export class Shell { + private outputMode: OutputMode; private dryRun: boolean; private verbose: boolean; - private throwOnError: boolean; private throwMode: 'simple' | 'raw'; - private logger?: (message: string) => void; + private logger: ShellLogger; + private execaOptions: ShellExecaOptions; + + /** + * Static factory method (alias for createShell). + * + * Factory function to create a new Shell instance with type inference. + * Provides better type safety and convenience compared to using `new Shell()`. + * + * @template DefaultMode - The default output mode type (inferred from options) + * @param options - Configuration options for the Shell instance + * @returns A new Shell instance with the specified configuration + * + * @example + * ```typescript + * const shell = Shell.create({ + * outputMode: 'live', + * verbose: true + * }); + * await shell.run('npm install'); // Output streams to console + * ``` + */ + public static create = createShell; /** * Create a new Shell instance. - * @param options - Configuration options for default behavior. + * + * @param options - Configuration options for default behavior + * + * @example Basic usage + * ```typescript + * const shell = new Shell({ + * outputMode: 'capture', + * verbose: true, + * throwMode: 'simple' + * }); + * ``` + * + * @example With default execaOptions + * ```typescript + * const shell = new Shell({ + * execaOptions: { + * env: { NODE_ENV: 'production' }, + * timeout: 30000, + * cwd: '/app' + * }, + * logger: { + * debug: (msg, ctx) => console.log(`[DEBUG] ${msg}`, ctx), + * warn: (msg, ctx) => console.warn(`[WARN] ${msg}`, ctx) + * } + * }); + * ``` */ - constructor(options: ShellOptions = {}) { - this.defaultOutputMode = options.defaultOutputMode ?? 'capture'; + constructor(options: ShellOptions = {}) { + this.outputMode = options.outputMode ?? 'capture'; this.dryRun = options.dryRun ?? false; this.verbose = options.verbose ?? false; - this.throwOnError = options.throwOnError ?? true; // default true - this.throwMode = options.throwMode ?? 'simple'; // default "simple" - this.logger = options.logger ?? console.log; + this.throwMode = options.throwMode ?? 'simple'; + this.execaOptions = options.execaOptions ?? {}; + this.logger = { + debug: options.logger?.debug ?? ((message: string, context: ShellLogContext) => console.debug(message, context)), + warn: options.logger?.warn ?? ((message: string, context: ShellLogContext) => console.warn(message, context)), + }; } /** - * Run a command using shell arguments or a string command line. - * Supports quoting and escaping via `string-argv`. + * Low-level method to execute a command with full control over error handling. + * + * Use `run()` for commands that should throw on error, or `safeRun()` for commands + * that should return an error result instead. + * + * This method deep merges command-level options with shell-level `execaOptions`, + * allowing fine-grained control over command execution while maintaining defaults. + * + * @template Throw - Whether to throw on error (true) or return error result (false) + * @template Mode - The output mode for this command + * + * @param cmd - Command to execute, as string or array of arguments + * @param options - Optional overrides including throwOnError flag and any execa options. + * Execa options are deep merged with shell-level execaOptions. * - * @param cmd - Command to execute, as string or array of arguments. - * @param options - Optional overrides for this execution. - * @returns A structured {@link RunResult} containing outputs and exit info. + * @returns A result object with type-safe stdout/stderr based on output mode and throw mode + * + * @example Throws on error + * ```typescript + * const result = await shell.execute('echo test', { throwOnError: true }); + * console.log(result.stdout); // No need to check success + * ``` + * + * @example Returns error result + * ```typescript + * const result = await shell.execute('might-fail', { throwOnError: false }); + * if (result.success) { + * console.log(result.stdout); + * } + * ``` + * + * @example With deep merged options + * ```typescript + * const shell = createShell({ + * execaOptions: { env: { API_KEY: 'default' }, timeout: 5000 } + * }); + * + * // Command-level env is merged, timeout is overridden + * await shell.execute('command', { + * throwOnError: true, + * env: { EXTRA: 'value' }, // Merged: both API_KEY and EXTRA available + * timeout: 10000 // Overrides: 10000 instead of 5000 + * }); + * ``` */ - async run(cmd: string | string[], options?: RunOptions): Promise { + public async execute( + cmd: string | string[], + options?: RunOptions & { throwOnError?: Throw } + ): Promise> { const args = Array.isArray(cmd) ? cmd : parseArgsStringToArgv(cmd); const [program, ...cmdArgs] = args; @@ -94,7 +417,10 @@ export class Shell { throw new Error('No command provided.'); } - const outputMode = options?.outputMode ?? this.defaultOutputMode; + // Merge command-level overrides with instance defaults + const outputMode = options?.outputMode ?? this.outputMode; + const verbose = options?.verbose ?? this.verbose; + const dryRun = options?.dryRun ?? this.dryRun; const stdioMap: Record = { capture: { stdout: 'pipe', stderr: 'pipe' }, @@ -102,31 +428,44 @@ export class Shell { all: { stdout: ['pipe', 'inherit'], stderr: ['pipe', 'inherit'] }, }; - if (this.verbose || this.dryRun) { - this.logger?.(`$ ${args.join(' ')}`); + // Extract our custom properties to avoid passing them to execa + const { outputMode: _, verbose: __, dryRun: ___, ...commandExecaOptions } = options ?? {}; + + // Deep merge shell-level and command-level execa options + // Command-level options override shell-level options + const mergedExecaOptions = deepmerge(this.execaOptions, commandExecaOptions); + + const finalExecaOptions: ExecaOptions = { + ...stdioMap[outputMode], + reject: options?.throwOnError ?? true, + ...mergedExecaOptions, + }; + + const logContext: ShellLogContext = { + command: cmd, + execaOptions: finalExecaOptions, + }; + + if (verbose || dryRun) { + this.logger.debug?.(`$ ${args.join(' ')}`, logContext); } - if (this.dryRun) { - return { stdout: '', stderr: '', exitCode: 0, isError: false, isSuccess: true }; + if (dryRun) { + return { stdout: '', stderr: '', exitCode: 0, success: true } as RunResult; } try { - const result = await execa(program, cmdArgs, { - ...stdioMap[outputMode], - reject: options?.throwOnError ?? this.throwOnError, - ...options, - }); + const result = await execa(program, cmdArgs, finalExecaOptions); return { stdout: result.stdout ? String(result.stdout) : null, stderr: result.stderr ? String(result.stderr) : null, exitCode: result.exitCode, - isError: result.exitCode !== 0, - isSuccess: result.exitCode === 0, - }; + success: result.exitCode === 0, + } as RunResult; } catch (error: unknown) { if (error instanceof ExecaError) { - if (this.throwOnError || options?.throwOnError) { + if (options?.throwOnError) { if (this.throwMode === 'raw') { throw error; } else { @@ -140,8 +479,126 @@ export class Shell { stdout: null, stderr: null, exitCode: undefined, - isError: true, - isSuccess: false, + success: false, + } as RunResult; + } + } + + /** + * Execute a command that throws an error on failure. + * + * This is the recommended method for most use cases where you want to fail fast. + * Returns only stdout/stderr since the command either succeeds or throws. + * + * @template Mode - The output mode for this command (defaults to instance default) + * + * @param cmd - Command to execute, as string or array of arguments + * @param options - Optional overrides for this execution + * + * @returns Result with stdout and stderr (type-safe based on output mode) + * + * @throws {Error} When command exits with non-zero code (format depends on throwMode) + * + * @example + * ```typescript + * const shell = new Shell({ throwMode: 'simple' }); + * try { + * const result = await shell.run('npm test'); + * console.log('Tests passed:', result.stdout); + * } catch (error) { + * console.error('Tests failed:', error.message); + * } + * ``` + */ + public async run( + cmd: string | string[], + options?: RunOptions + ): Promise> { + return this.execute(cmd, { ...options, throwOnError: true }); + } + + /** + * Execute a command that never throws, returning an error result instead. + * + * Use this when you want to handle errors programmatically without try/catch. + * Returns a result with exitCode, and success flags. + * + * @template Mode - The output mode for this command (defaults to instance default) + * + * @param cmd - Command to execute, as string or array of arguments + * @param options - Optional overrides for this execution + * + * @returns Result with stdout, stderr, exitCode, and success + * + * @example + * ```typescript + * const shell = new Shell(); + * const result = await shell.safeRun('lint-code'); + * + * if (!result.success) { + * console.warn('Linting failed with exit code:', result.exitCode); + * console.warn('Errors:', result.stderr); + * } else { + * console.log('Linting passed!'); + * } + * ``` + */ + public async safeRun( + cmd: string | string[], + options?: RunOptions + ): Promise> { + return this.execute(cmd, { ...options, throwOnError: false }); + } + + public async runParse( + cmd: string | string[], + schema: T, + options?: RunOptions + ): Promise> { + const result = await this.run(cmd, options); + const verbose = options?.verbose ?? this.verbose; + const verboseOutput = verbose ? `\nStdout: ${result.stdout}\nStderr: ${result.stderr}` : ''; + if (verbose) { + // Extract our custom properties to get clean execa options + const { outputMode: _, verbose: __, dryRun: ___, ...execaOptions } = options ?? {}; + const logContext: ShellLogContext = { + command: cmd, + execaOptions: execaOptions, + }; + this.logger.debug?.('Validation Output:' + verboseOutput, logContext); + } + return standardValidate(schema, JSON.parse(result.stdout ?? '{}')); + } + + public async safeRunParse( + cmd: string | string[], + schema: T, + options?: RunOptions + ): Promise>> { + const result = await this.safeRun(cmd, options); + const verbose = options?.verbose ?? this.verbose; + const fullCommand = Array.isArray(cmd) ? cmd.join(' ') : cmd; + const verboseOutput = verbose ? `\nStdout: ${result.stdout}\nStderr: ${result.stderr}` : ''; + const verboseCommand = verbose ? `\nCommand: ${fullCommand}` : ''; + const verboseInfo = verboseCommand + verboseOutput; + if (!result.stdout) { + return { + success: false, + error: [{ message: `The command produced no output to validate. ${verboseInfo}` }], + }; + } + if (!result.success) { + return { + success: false, + error: [{ message: `The command failed with exit code ${result.exitCode}. ${verboseInfo}` }], + }; + } + try { + return standardSafeValidate(schema, JSON.parse(result.stdout)); + } catch (e: unknown) { + return { + success: false, + error: [{ message: 'Unable to Parse JSON: ' + (e instanceof Error ? e.message : String(e)) + verboseInfo }], }; } } diff --git a/src/standard-schema.ts b/src/standard-schema.ts new file mode 100644 index 0000000..a1c4f65 --- /dev/null +++ b/src/standard-schema.ts @@ -0,0 +1,51 @@ +import type { StandardSchemaV1 } from '@standard-schema/spec'; + +/** + * Validate input against a Standard Schema. + * @see https://github.com/standard-schema/standard-schema + */ +export async function standardValidate( + schema: T, + input: StandardSchemaV1.InferInput +): Promise> { + let result = schema['~standard'].validate(input); + if (result instanceof Promise) result = await result; + + // if the `issues` field exists, the validation failed + if (result.issues) { + throw new Error(JSON.stringify(result.issues, null, 2)); + } + + return result.value; +} + +export type StandardResult = + | { + success: true; + data: T; + } + | { + success: false; + error: ReadonlyArray; + }; + +export async function standardSafeValidate( + schema: T, + input: StandardSchemaV1.InferInput +): Promise>> { + let result = schema['~standard'].validate(input); + if (result instanceof Promise) result = await result; + + // if the `issues` field exists, the validation failed + if (result.issues) { + return { + success: false, + error: result.issues, + }; + } + + return { + success: true, + data: result.value as StandardSchemaV1.InferOutput, + }; +} diff --git a/test/shell.test.ts b/test/shell.test.ts index 0d08419..007c174 100644 --- a/test/shell.test.ts +++ b/test/shell.test.ts @@ -1,5 +1,6 @@ import { describe, it, expect, vi } from 'vitest'; -import { Shell } from '../src/shell.js'; +import { Shell, createShell } from '../src/shell.js'; +import { z } from 'zod'; describe('Shell', () => { describe('Command Parsing', () => { @@ -7,8 +8,6 @@ describe('Shell', () => { const shell = new Shell(); const result = await shell.run('echo "Hello World"'); - expect(result.isSuccess).toBe(true); - expect(result.exitCode).toBe(0); expect(result.stdout).toBe('Hello World'); }); @@ -16,7 +15,6 @@ describe('Shell', () => { const shell = new Shell(); const result = await shell.run(['echo', 'Hello', 'World']); - expect(result.isSuccess).toBe(true); expect(result.stdout).toBe('Hello World'); }); @@ -43,14 +41,14 @@ describe('Shell', () => { }); it('should use specified default mode', async () => { - const shell = new Shell({ defaultOutputMode: 'capture' }); + const shell = new Shell({ outputMode: 'capture' }); const result = await shell.run('echo "Test"'); expect(result.stdout).toBe('Test'); }); it('should override default mode with per-command option', async () => { - const shell = new Shell({ defaultOutputMode: 'live' }); + const shell = new Shell({ outputMode: 'live' }); const result = await shell.run('echo "Override"', { outputMode: 'capture' }); // Override to capture, so stdout should be captured @@ -63,16 +61,14 @@ describe('Shell', () => { // In 'all' mode, output is both captured and streamed expect(result.stdout).toBe('All Mode'); - expect(result.isSuccess).toBe(true); }); it('should handle live mode', async () => { const shell = new Shell(); const result = await shell.run('echo "Live"', { outputMode: 'live' }); - // In live mode, command still succeeds - expect(result.exitCode).toBe(0); - expect(result.isSuccess).toBe(true); + // In live mode, output is not captured + expect(result.stdout).toBe(null); }); }); @@ -80,9 +76,9 @@ describe('Shell', () => { it('should not execute commands in dry run mode', async () => { const shell = new Shell({ dryRun: true }); // This command would fail if executed, but should succeed in dry run - const result = await shell.run('sh -c "exit 1"'); + const result = await shell.safeRun('sh -c "exit 1"'); - expect(result.isSuccess).toBe(true); + expect(result.success).toBe(true); expect(result.exitCode).toBe(0); expect(result.stdout).toBe(''); expect(result.stderr).toBe(''); @@ -90,68 +86,84 @@ describe('Shell', () => { it('should return mock success result in dry run mode', async () => { const shell = new Shell({ dryRun: true }); - const result = await shell.run('echo "test"'); + const result = await shell.safeRun('echo "test"'); expect(result).toEqual({ stdout: '', stderr: '', exitCode: 0, - isError: false, - isSuccess: true + success: true }); }); it('should log commands in dry run mode when verbose', async () => { - const mockLogger = vi.fn(); - const shell = new Shell({ dryRun: true, verbose: true, logger: mockLogger }); + const mockDebug = vi.fn(); + const shell = new Shell({ + dryRun: true, + verbose: true, + logger: { debug: mockDebug } + }); await shell.run('echo test'); - expect(mockLogger).toHaveBeenCalledWith('$ echo test'); + expect(mockDebug).toHaveBeenCalledWith('$ echo test', expect.any(Object)); }); it('should not log commands in dry run mode without verbose', async () => { - const mockLogger = vi.fn(); - const shell = new Shell({ dryRun: true, verbose: false, logger: mockLogger }); + const mockDebug = vi.fn(); + const shell = new Shell({ + dryRun: true, + verbose: false, + logger: { debug: mockDebug } + }); await shell.run('echo test'); - // dryRun alone logs, but let's check it does log - expect(mockLogger).toHaveBeenCalledWith('$ echo test'); + // dryRun alone logs, so it should still log + expect(mockDebug).toHaveBeenCalledWith('$ echo test', expect.any(Object)); }); }); describe('Verbose Mode', () => { it('should log commands when verbose is enabled', async () => { - const mockLogger = vi.fn(); - const shell = new Shell({ verbose: true, logger: mockLogger }); + const mockDebug = vi.fn(); + const shell = new Shell({ + verbose: true, + logger: { debug: mockDebug } + }); await shell.run('echo test'); - expect(mockLogger).toHaveBeenCalledWith('$ echo test'); + expect(mockDebug).toHaveBeenCalledWith('$ echo test', expect.any(Object)); }); it('should not log commands when verbose is disabled', async () => { - const mockLogger = vi.fn(); - const shell = new Shell({ verbose: false, logger: mockLogger }); + const mockDebug = vi.fn(); + const shell = new Shell({ + verbose: false, + logger: { debug: mockDebug } + }); await shell.run('echo test'); - expect(mockLogger).not.toHaveBeenCalled(); + expect(mockDebug).not.toHaveBeenCalled(); }); it('should log array commands correctly', async () => { - const mockLogger = vi.fn(); - const shell = new Shell({ verbose: true, logger: mockLogger }); + const mockDebug = vi.fn(); + const shell = new Shell({ + verbose: true, + logger: { debug: mockDebug } + }); await shell.run(['echo', 'hello', 'world']); - expect(mockLogger).toHaveBeenCalledWith('$ echo hello world'); + expect(mockDebug).toHaveBeenCalledWith('$ echo hello world', expect.any(Object)); }); }); - describe('Error Handling - throwOnError', () => { - it('should throw error by default when command fails', async () => { + describe('Error Handling - run() vs safeRun()', () => { + it('should throw error by default when command fails with run()', async () => { const shell = new Shell(); await expect(shell.run('sh -c "exit 1"')).rejects.toThrow(); @@ -185,34 +197,32 @@ describe('Shell', () => { } }); - it('should not throw when throwOnError is false at constructor level', async () => { - const shell = new Shell({ throwOnError: false }); - const result = await shell.run('sh -c "exit 1"'); + it('should not throw when using safeRun()', async () => { + const shell = new Shell(); + const result = await shell.safeRun('sh -c "exit 1"'); - expect(result.isError).toBe(true); - expect(result.isSuccess).toBe(false); + expect(result.success).toBe(false); expect(result.exitCode).toBe(1); }); - it('should respect per-command throwOnError option', async () => { - const shell = new Shell({ throwOnError: true }); - // Override to not throw for this specific command - const result = await shell.run('sh -c "exit 1"', { throwOnError: false }); + it('should respect execute with throwOnError false', async () => { + const shell = new Shell(); + // Use execute() with explicit throwOnError: false + const result = await shell.execute('sh -c "exit 1"', { throwOnError: false }); - expect(result.isError).toBe(true); + expect(result.success).toBe(false); expect(result.exitCode).toBe(1); }); - it('should return error result when throwOnError is false', async () => { - const shell = new Shell({ throwOnError: false }); - const result = await shell.run('sh -c "exit 42"'); + it('should return error result when using safeRun() with different exit codes', async () => { + const shell = new Shell(); + const result = await shell.safeRun('sh -c "exit 42"'); expect(result).toEqual({ stdout: null, stderr: null, exitCode: 42, - isError: true, - isSuccess: false + success: false }); }); }); @@ -247,58 +257,62 @@ describe('Shell', () => { describe('Logger Integration', () => { it('should use custom logger when provided', async () => { const logs: string[] = []; - const customLogger = (msg: string) => logs.push(msg); + const customDebug = (msg: string) => logs.push(msg); - const shell = new Shell({ verbose: true, logger: customLogger }); + const shell = new Shell({ + verbose: true, + logger: { debug: customDebug } + }); await shell.run('echo test'); expect(logs).toContain('$ echo test'); }); - it('should use console.log by default', async () => { - const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); + it('should use console.debug by default', async () => { + const consoleSpy = vi.spyOn(console, 'debug').mockImplementation(() => {}); const shell = new Shell({ verbose: true }); await shell.run('echo test'); - expect(consoleSpy).toHaveBeenCalledWith('$ echo test'); + expect(consoleSpy).toHaveBeenCalledWith('$ echo test', expect.any(Object)); consoleSpy.mockRestore(); }); it('should call logger for both verbose and dryRun', async () => { - const mockLogger = vi.fn(); - const shell = new Shell({ verbose: true, dryRun: true, logger: mockLogger }); + const mockDebug = vi.fn(); + const shell = new Shell({ + verbose: true, + dryRun: true, + logger: { debug: mockDebug } + }); await shell.run('echo test'); - expect(mockLogger).toHaveBeenCalledTimes(1); - expect(mockLogger).toHaveBeenCalledWith('$ echo test'); + expect(mockDebug).toHaveBeenCalledTimes(1); + expect(mockDebug).toHaveBeenCalledWith('$ echo test', expect.any(Object)); }); }); describe('Result Structure', () => { - it('should return correct structure for successful command', async () => { + it('should return correct structure for successful command with safeRun', async () => { const shell = new Shell(); - const result = await shell.run('echo success'); + const result = await shell.safeRun('echo success'); expect(result).toHaveProperty('stdout'); expect(result).toHaveProperty('stderr'); expect(result).toHaveProperty('exitCode'); - expect(result).toHaveProperty('isSuccess'); - expect(result).toHaveProperty('isError'); + expect(result).toHaveProperty('success'); - expect(result.isSuccess).toBe(true); - expect(result.isError).toBe(false); + expect(result.success).toBe(true); expect(result.exitCode).toBe(0); }); - it('should set isSuccess and isError correctly', async () => { + it('should set success correctly', async () => { const shell = new Shell(); - const result = await shell.run('echo test'); + const result = await shell.safeRun('echo test'); expect(result.exitCode).toBe(0); - expect(result.isSuccess).toBe(true); - expect(result.isError).toBe(false); + expect(result.success).toBe(true); }); it('should return null for empty stderr', async () => { @@ -310,7 +324,7 @@ describe('Shell', () => { it('should capture both stdout and stderr', async () => { const shell = new Shell(); - const result = await shell.run('sh -c "echo stdout; echo stderr >&2; exit 0"'); + const result = await shell.safeRun('sh -c "echo stdout; echo stderr >&2; exit 0"'); expect(result.stdout).toBe('stdout'); expect(result.stderr).toBe('stderr'); @@ -331,14 +345,17 @@ describe('Shell', () => { }); it('should accept all valid options', () => { - const mockLogger = vi.fn(); + const mockDebug = vi.fn(); + const mockWarn = vi.fn(); const shell = new Shell({ - defaultOutputMode: 'capture', + outputMode: 'capture', dryRun: false, verbose: true, - throwOnError: true, throwMode: 'simple', - logger: mockLogger + logger: { + debug: mockDebug, + warn: mockWarn + } }); expect(shell).toBeInstanceOf(Shell); @@ -348,45 +365,375 @@ describe('Shell', () => { const shell = new Shell({ verbose: true }); const result = await shell.run('echo test'); - expect(result.isSuccess).toBe(true); + expect(result.stdout).toBe('test'); }); it('should handle empty options object', async () => { const shell = new Shell({}); const result = await shell.run('echo test'); - expect(result.isSuccess).toBe(true); + expect(result.stdout).toBe('test'); }); }); describe('Option Inheritance and Override', () => { - it('should use constructor throwOnError by default', async () => { - const shell = new Shell({ throwOnError: false }); - const result = await shell.run('sh -c "exit 1"'); + it('should use safeRun to not throw', async () => { + const shell = new Shell(); + const result = await shell.safeRun('sh -c "exit 1"'); - expect(result.isError).toBe(true); + expect(result.success).toBe(false); }); - it('should override constructor throwOnError with run option', async () => { - const shell = new Shell({ throwOnError: false }); + it('should use execute with throwOnError to control behavior', async () => { + const shell = new Shell(); await expect( - shell.run('sh -c "exit 1"', { throwOnError: true }) + shell.execute('sh -c "exit 1"', { throwOnError: true }) ).rejects.toThrow(); }); it('should use constructor defaultOutputMode by default', async () => { - const shell = new Shell({ defaultOutputMode: 'capture' }); + const shell = new Shell({ outputMode: 'capture' }); const result = await shell.run('echo test'); expect(result.stdout).toBe('test'); }); it('should override constructor defaultOutputMode with run option', async () => { - const shell = new Shell({ defaultOutputMode: 'live' }); + const shell = new Shell({ outputMode: 'live' }); const result = await shell.run('echo test', { outputMode: 'capture' }); expect(result.stdout).toBe('test'); }); + + it('should override verbose at command level', async () => { + const mockDebug = vi.fn(); + const shell = new Shell({ + verbose: false, + logger: { debug: mockDebug } + }); + + // This command should log because we override verbose to true + await shell.run('echo test', { verbose: true }); + expect(mockDebug).toHaveBeenCalledWith('$ echo test', expect.any(Object)); + }); + + it('should override dryRun at command level', async () => { + const shell = new Shell({ dryRun: false }); + + // This command should be in dry run mode even though default is false + const result = await shell.safeRun('sh -c "exit 1"', { dryRun: true }); + + expect(result.success).toBe(true); // Dry run always succeeds + expect(result.exitCode).toBe(0); + }); + + it('should deep merge execaOptions from shell and command level', async () => { + const shell = new Shell({ + execaOptions: { + env: { SHELL_VAR: 'from-shell' }, + timeout: 5000 + } + }); + + // Command-level should override shell-level + const result = await shell.run('echo $SHELL_VAR $CMD_VAR', { + env: { CMD_VAR: 'from-command' } + }); + + // Both env vars should be available (deep merge) + // Note: This test verifies the merge happens, actual execution depends on shell + expect(result.stdout).toBeDefined(); + }); + + it('should allow command-level execaOptions to override shell-level', async () => { + const shell = new Shell({ + execaOptions: { + timeout: 1000 + } + }); + + // Command-level timeout should override shell-level + const result = await shell.run('echo test', { + timeout: 10000 // Higher timeout at command level + }); + + expect(result.stdout).toBe('test'); + }); + }); + + describe('Edge Cases - ExecaError Handling', () => { + it('should return error result when command not found with safeRun', async () => { + const shell = new Shell(); + const result = await shell.safeRun('this-command-definitely-does-not-exist-12345'); + + expect(result.success).toBe(false); + expect(result.exitCode).toBeUndefined(); + expect(result.stdout).toBe(null); + expect(result.stderr).toBe(null); + }); + + it('should throw when command not found with run', async () => { + const shell = new Shell(); + + await expect( + shell.run('this-command-definitely-does-not-exist-12345') + ).rejects.toThrow(); + }); + + it('should handle execaOptions reject override in safeRun', async () => { + const shell = new Shell(); + + // Edge case: safeRun with reject: true in execaOptions + // This causes execa to throw even though throwOnError is false + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const result = await shell.safeRun('sh -c "exit 1"', { reject: true } as any); + + expect(result.success).toBe(false); + expect(result.exitCode).toBeUndefined(); + expect(result.stdout).toBe(null); + expect(result.stderr).toBe(null); + }); + + it('should handle non-ExecaError in execute', async () => { + const shell = new Shell(); + + // This will trigger a non-ExecaError (command not found) + await expect( + shell.execute('this-command-definitely-does-not-exist-12345', { throwOnError: true }) + ).rejects.toThrow(); + }); + }); + + describe('Factory Function', () => { + it('should create Shell instance using createShell factory', async () => { + const shell = createShell({ verbose: true }); + expect(shell).toBeInstanceOf(Shell); + + const result = await shell.run('echo factory'); + expect(result.stdout).toBe('factory'); + }); + + it('should create Shell with typed output mode', async () => { + const shell = createShell({ outputMode: 'capture' }); + const result = await shell.run('echo test'); + expect(result.stdout).toBe('test'); + }); + }); + + describe('Schema Validation - runParse', () => { + it('should parse and validate JSON output', async () => { + const shell = createShell(); + const schema = z.object({ + name: z.string(), + version: z.string(), + }); + + const result = await shell.runParse( + 'echo \'{"name":"test-package","version":"1.0.0"}\'', + schema + ); + + expect(result.name).toBe('test-package'); + expect(result.version).toBe('1.0.0'); + }); + + it('should handle async schema validation', async () => { + const shell = createShell(); + + // Create a custom async standard schema + const asyncSchema = { + '~standard': { + version: 1, + vendor: 'custom', + validate: async (input: unknown) => { + // Simulate async validation + await new Promise(resolve => setTimeout(resolve, 10)); + + if (typeof input === 'object' && input !== null && 'value' in input) { + return { value: input }; + } + return { + issues: [{ message: 'Invalid data' }] + }; + } + } + }; + + const result = await shell.runParse( + 'echo \'{"value":"async-test"}\'', + asyncSchema as any + ); + + expect(result).toHaveProperty('value'); + }); + + it('should parse with verbose mode', async () => { + const mockDebug = vi.fn(); + const shell = createShell({ + verbose: true, + logger: { debug: mockDebug } + }); + + const schema = z.object({ value: z.string() }); + + await shell.runParse('echo \'{"value":"test"}\'', schema); + + // Should log validation output + expect(mockDebug).toHaveBeenCalledWith( + expect.stringContaining('Validation Output:'), + expect.any(Object) + ); + }); + + it('should throw when JSON is invalid', async () => { + const shell = createShell(); + const schema = z.object({ name: z.string() }); + + await expect( + shell.runParse('echo "not json"', schema) + ).rejects.toThrow(); + }); + + it('should throw when validation fails', async () => { + const shell = createShell(); + const schema = z.object({ + name: z.string(), + count: z.number(), + }); + + await expect( + shell.runParse('echo \'{"name":"test","count":"not-a-number"}\'', schema) + ).rejects.toThrow(); + }); + }); + + describe('Schema Validation - safeRunParse', () => { + it('should parse and validate JSON output successfully', async () => { + const shell = createShell(); + const schema = z.object({ + name: z.string(), + version: z.string(), + }); + + const result = await shell.safeRunParse( + 'echo \'{"name":"test-pkg","version":"2.0.0"}\'', + schema + ); + + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.name).toBe('test-pkg'); + expect(result.data.version).toBe('2.0.0'); + } + }); + + it('should return error when command produces no output', async () => { + const shell = createShell(); + const schema = z.object({ value: z.string() }); + + const result = await shell.safeRunParse('true', schema); + + expect(result.success).toBe(false); + if (!result.success) { + expect(result.error[0].message).toContain('produced no output'); + } + }); + + it('should return error when command fails', async () => { + const shell = createShell(); + const schema = z.object({ value: z.string() }); + + const result = await shell.safeRunParse('sh -c "echo output && exit 1"', schema); + + expect(result.success).toBe(false); + if (!result.success) { + expect(result.error[0].message).toContain('failed with exit code'); + } + }); + + it('should return error when JSON is invalid', async () => { + const shell = createShell(); + const schema = z.object({ value: z.string() }); + + const result = await shell.safeRunParse('echo "not valid json{{"', schema); + + expect(result.success).toBe(false); + if (!result.success) { + expect(result.error[0].message).toContain('Unable to Parse JSON'); + } + }); + + it('should return error when validation fails', async () => { + const shell = createShell(); + const schema = z.object({ + name: z.string(), + count: z.number(), + }); + + const result = await shell.safeRunParse( + 'echo \'{"name":"test","count":"not-a-number"}\'', + schema + ); + + expect(result.success).toBe(false); + if (!result.success) { + expect(result.error.length).toBeGreaterThan(0); + } + }); + + it('should include verbose info in error messages', async () => { + const shell = createShell({ verbose: true }); + const schema = z.object({ value: z.string() }); + + const result = await shell.safeRunParse('true', schema); + + expect(result.success).toBe(false); + if (!result.success) { + expect(result.error[0].message).toContain('Command:'); + } + }); + + it('should handle array commands in verbose mode', async () => { + const shell = createShell({ verbose: true }); + const schema = z.object({ value: z.string() }); + + const result = await shell.safeRunParse(['echo', '{"value":"test"}'], schema); + + expect(result.success).toBe(true); + }); + + it('should handle async schema validation with safeRunParse', async () => { + const shell = createShell(); + + // Create a custom async standard schema + const asyncSchema = { + '~standard': { + version: 1, + vendor: 'custom', + validate: async (input: unknown) => { + // Simulate async validation + await new Promise(resolve => setTimeout(resolve, 10)); + + if (typeof input === 'object' && input !== null && 'value' in input) { + return { value: input }; + } + return { + issues: [{ message: 'Invalid async data' }] + }; + } + } + }; + + const result = await shell.safeRunParse( + 'echo \'{"value":"async-safe-test"}\'', + asyncSchema as any + ); + + expect(result.success).toBe(true); + if (result.success) { + expect(result.data).toHaveProperty('value'); + } + }); }); });