From 5a4e8fc90bc23808599c4878a2c6775ed905c848 Mon Sep 17 00:00:00 2001 From: Thada Wangthammang Date: Sat, 8 Nov 2025 09:20:56 +0700 Subject: [PATCH 01/30] fix: enhance ShellOptions and RunOptions to support generic error handling --- src/shell.ts | 27 ++++++++++++++++----------- 1 file changed, 16 insertions(+), 11 deletions(-) diff --git a/src/shell.ts b/src/shell.ts index 3e4625b..a249f40 100644 --- a/src/shell.ts +++ b/src/shell.ts @@ -14,7 +14,7 @@ import parseArgsStringToArgv from 'string-argv'; export type OutputMode = 'capture' | 'live' | 'all'; /** Configuration options for Shell instance */ -export interface ShellOptions { +export interface ShellOptions { /** Default output mode applied to all runs unless overridden */ defaultOutputMode?: OutputMode; /** If true, print commands but skip actual execution */ @@ -22,7 +22,7 @@ export interface ShellOptions { /** If true, log every executed command */ verbose?: boolean; /** If true, throw an error when a command exits with non-zero code, @default true */ - throwOnError?: boolean; + throwOnError?: ThrowOnError; /** * Controls how errors are thrown when a command fails. * - `"simple"` → Throws a short, human-readable error message. @@ -36,19 +36,22 @@ export interface ShellOptions { } /** Options for an individual command execution */ -export interface RunOptions extends ExecaOptions { +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; + throwOnError?: ThrowOnError; } /** The structured result returned by Shell.run() */ -export interface RunResult { +export interface StrictResult { /** Captured stdout output, or null if not captured */ stdout: string | null; /** Captured stderr output, or null if not captured */ stderr: string | null; +} + +export interface SafeResult extends StrictResult { /** Exit code returned by the executed process */ exitCode: number | undefined; /** Indicates whether the command exited with an error */ @@ -57,7 +60,9 @@ export interface RunResult { isSuccess: boolean; } -export class Shell { +export type RunResult = Throw extends true ? StrictResult : SafeResult; + +export class Shell { private defaultOutputMode: OutputMode; private dryRun: boolean; private verbose: boolean; @@ -69,7 +74,7 @@ export class Shell { * Create a new Shell instance. * @param options - Configuration options for default behavior. */ - constructor(options: ShellOptions = {}) { + constructor(options: ShellOptions = {}) { this.defaultOutputMode = options.defaultOutputMode ?? 'capture'; this.dryRun = options.dryRun ?? false; this.verbose = options.verbose ?? false; @@ -86,7 +91,7 @@ export class Shell { * @param options - Optional overrides for this execution. * @returns A structured {@link RunResult} containing outputs and exit info. */ - async run(cmd: string | string[], options?: RunOptions): Promise { + async run(cmd: string | string[], options?: RunOptions): Promise> { const args = Array.isArray(cmd) ? cmd : parseArgsStringToArgv(cmd); const [program, ...cmdArgs] = args; @@ -107,7 +112,7 @@ export class Shell { } if (this.dryRun) { - return { stdout: '', stderr: '', exitCode: 0, isError: false, isSuccess: true }; + return { stdout: '', stderr: '', exitCode: 0, isError: false, isSuccess: true } as RunResult; } try { @@ -123,7 +128,7 @@ export class Shell { exitCode: result.exitCode, isError: result.exitCode !== 0, isSuccess: result.exitCode === 0, - }; + } as RunResult; } catch (error: unknown) { if (error instanceof ExecaError) { if (this.throwOnError || options?.throwOnError) { @@ -142,7 +147,7 @@ export class Shell { exitCode: undefined, isError: true, isSuccess: false, - }; + } as RunResult; } } } From dffd8aa8ff1f87cc0a0dd0c9fd5a794b030fd222 Mon Sep 17 00:00:00 2001 From: Thada Wangthammang Date: Sat, 8 Nov 2025 09:24:05 +0700 Subject: [PATCH 02/30] feat: add basic example for Shell usage --- examples/basic.ts | 10 ++++++++++ 1 file changed, 10 insertions(+) create mode 100644 examples/basic.ts diff --git a/examples/basic.ts b/examples/basic.ts new file mode 100644 index 0000000..fb66b70 --- /dev/null +++ b/examples/basic.ts @@ -0,0 +1,10 @@ + +import { Shell } from "src/shell.js"; + +async function main() { + const shell = new Shell(); + const result = await shell.run("echo Hello, World!"); + console.log("Command Output:", result.stdout); +} + +main(); \ No newline at end of file From c1ff5d477bd153f7806e8771f4064d509bf188c0 Mon Sep 17 00:00:00 2001 From: Thada Wangthammang Date: Sat, 8 Nov 2025 10:11:08 +0700 Subject: [PATCH 03/30] feat: enhance Shell class with configurable output modes and improved type safety --- examples/basic.ts | 2 +- src/shell.ts | 34 ++++++++++++++++++++++------------ 2 files changed, 23 insertions(+), 13 deletions(-) diff --git a/examples/basic.ts b/examples/basic.ts index fb66b70..107cfd5 100644 --- a/examples/basic.ts +++ b/examples/basic.ts @@ -2,7 +2,7 @@ import { Shell } from "src/shell.js"; async function main() { - const shell = new Shell(); + const shell = new Shell({ defaultOutputMode: 'live', verbose: true }); const result = await shell.run("echo Hello, World!"); console.log("Command Output:", result.stdout); } diff --git a/src/shell.ts b/src/shell.ts index a249f40..5e98eb5 100644 --- a/src/shell.ts +++ b/src/shell.ts @@ -12,6 +12,10 @@ import parseArgsStringToArgv from 'string-argv'; /** Output mode behavior for handling stdout/stderr */ export type OutputMode = 'capture' | 'live' | 'all'; +type DefaultOutputMode = 'capture'; + +type CaptureForMode = + M extends 'live' ? false : true; /** Configuration options for Shell instance */ export interface ShellOptions { @@ -36,22 +40,22 @@ export interface ShellOptions { } /** Options for an individual command execution */ -export interface RunOptions extends ExecaOptions { +export interface RunOptions extends ExecaOptions { /** Override the output behavior for this specific command */ - outputMode?: OutputMode; + outputMode?: Mode; /** Whether to throw error on non-zero exit */ throwOnError?: ThrowOnError; } /** The structured result returned by Shell.run() */ -export interface StrictResult { +export interface StrictResult { /** Captured stdout output, or null if not captured */ - stdout: string | null; + stdout: Capture extends true ? string : null; /** Captured stderr output, or null if not captured */ - stderr: string | null; + stderr: Capture extends true ? string : null; } -export interface SafeResult extends StrictResult { +export interface SafeResult extends StrictResult { /** Exit code returned by the executed process */ exitCode: number | undefined; /** Indicates whether the command exited with an error */ @@ -60,9 +64,12 @@ export interface SafeResult extends StrictResult { isSuccess: boolean; } -export type RunResult = Throw extends true ? StrictResult : SafeResult; +export type RunResult = + Throw extends true + ? StrictResult> + : SafeResult>; -export class Shell { +export class Shell { private defaultOutputMode: OutputMode; private dryRun: boolean; private verbose: boolean; @@ -91,7 +98,10 @@ export class Shell { * @param options - Optional overrides for this execution. * @returns A structured {@link RunResult} containing outputs and exit info. */ - async run(cmd: string | string[], options?: RunOptions): Promise> { + async run< + Throw extends boolean = DefaultThrow, + Mode extends OutputMode = DefaultMode + >(cmd: string | string[], options?: RunOptions): Promise> { const args = Array.isArray(cmd) ? cmd : parseArgsStringToArgv(cmd); const [program, ...cmdArgs] = args; @@ -112,7 +122,7 @@ export class Shell { } if (this.dryRun) { - return { stdout: '', stderr: '', exitCode: 0, isError: false, isSuccess: true } as RunResult; + return { stdout: '', stderr: '', exitCode: 0, isError: false, isSuccess: true } as RunResult; } try { @@ -128,7 +138,7 @@ export class Shell { exitCode: result.exitCode, isError: result.exitCode !== 0, isSuccess: result.exitCode === 0, - } as RunResult; + } as RunResult; } catch (error: unknown) { if (error instanceof ExecaError) { if (this.throwOnError || options?.throwOnError) { @@ -147,7 +157,7 @@ export class Shell { exitCode: undefined, isError: true, isSuccess: false, - } as RunResult; + } as RunResult; } } } From 748597d90f7a9984badd6e7987d5cc39ffd010b9 Mon Sep 17 00:00:00 2001 From: Thada Wangthammang Date: Sat, 8 Nov 2025 10:41:46 +0700 Subject: [PATCH 04/30] feat: refactor Shell class to use createShell factory function and improve type safety in ShellOptions --- examples/basic.ts | 6 +++--- src/shell.ts | 21 ++++++++++++++++----- 2 files changed, 19 insertions(+), 8 deletions(-) diff --git a/examples/basic.ts b/examples/basic.ts index 107cfd5..2d887d5 100644 --- a/examples/basic.ts +++ b/examples/basic.ts @@ -1,9 +1,9 @@ -import { Shell } from "src/shell.js"; +import { createShell } from "src/shell.js"; async function main() { - const shell = new Shell({ defaultOutputMode: 'live', verbose: true }); - const result = await shell.run("echo Hello, World!"); + const shell = createShell({ defaultOutputMode: 'live', verbose: true }); + const result = await shell.run("echo Hello, World!", { outputMode: 'capture', }); console.log("Command Output:", result.stdout); } diff --git a/src/shell.ts b/src/shell.ts index 5e98eb5..dd30b16 100644 --- a/src/shell.ts +++ b/src/shell.ts @@ -12,15 +12,14 @@ import parseArgsStringToArgv from 'string-argv'; /** Output mode behavior for handling stdout/stderr */ export type OutputMode = 'capture' | 'live' | 'all'; -type DefaultOutputMode = 'capture'; type CaptureForMode = M extends 'live' ? false : true; /** Configuration options for Shell instance */ -export interface ShellOptions { +export interface ShellOptions { /** Default output mode applied to all runs unless overridden */ - defaultOutputMode?: OutputMode; + defaultOutputMode?: Mode; /** If true, print commands but skip actual execution */ dryRun?: boolean; /** If true, log every executed command */ @@ -69,7 +68,17 @@ export type RunResult = ? StrictResult> : SafeResult>; -export class Shell { +/** + * Factory function to create a new Shell instance for type safety and convenience. + */ +export function createShell< + DefaultThrow extends boolean = true, + DefaultMode extends OutputMode = OutputMode +>(options: ShellOptions = {}) { + return new Shell(options); +} + +export class Shell { private defaultOutputMode: OutputMode; private dryRun: boolean; private verbose: boolean; @@ -77,11 +86,13 @@ export class Shell void; + public static create = createShell; + /** * Create a new Shell instance. * @param options - Configuration options for default behavior. */ - constructor(options: ShellOptions = {}) { + constructor(options: ShellOptions = {}) { this.defaultOutputMode = options.defaultOutputMode ?? 'capture'; this.dryRun = options.dryRun ?? false; this.verbose = options.verbose ?? false; From 6149c5ac6f755e33e671827c3ee2e576300a6823 Mon Sep 17 00:00:00 2001 From: Thada Wangthammang Date: Sat, 8 Nov 2025 10:59:06 +0700 Subject: [PATCH 05/30] fix: update RunOptions to extend ShellExecaOptions for better compatibility with ExecaOptions --- src/shell.ts | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/src/shell.ts b/src/shell.ts index dd30b16..b4e540f 100644 --- a/src/shell.ts +++ b/src/shell.ts @@ -38,8 +38,13 @@ export interface ShellOptions void; } +/** + * We handle some properties of ExecaOptions internally, so we omit them here to avoid conflicts. + */ +export type ShellExecaOptions = Omit; + /** Options for an individual command execution */ -export interface RunOptions extends ExecaOptions { +export interface RunOptions extends ShellExecaOptions { /** Override the output behavior for this specific command */ outputMode?: Mode; /** Whether to throw error on non-zero exit */ From 9184411bf9710a0e546412e9a8f913a21c2a390a Mon Sep 17 00:00:00 2001 From: Thada Wangthammang Date: Sat, 8 Nov 2025 11:22:40 +0700 Subject: [PATCH 06/30] refactor: simplify ShellOptions and RunOptions interfaces for improved clarity and maintainability --- src/shell.ts | 48 ++++++++++++++++++++++++++++-------------------- 1 file changed, 28 insertions(+), 20 deletions(-) diff --git a/src/shell.ts b/src/shell.ts index b4e540f..daa15b1 100644 --- a/src/shell.ts +++ b/src/shell.ts @@ -17,19 +17,19 @@ type CaptureForMode = M extends 'live' ? false : true; /** Configuration options for Shell instance */ -export interface ShellOptions { +export interface ShellOptions { /** Default output mode applied to all runs unless overridden */ defaultOutputMode?: Mode; /** If true, print commands but skip actual execution */ dryRun?: boolean; /** If true, log every executed command */ verbose?: boolean; - /** If true, throw an error when a command exits with non-zero code, @default true */ - throwOnError?: ThrowOnError; /** * 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. + * + * Only applies when using `run()` method that throws on error. * * @default "simple" */ @@ -41,14 +41,12 @@ export interface ShellOptions; +export type ShellExecaOptions = Omit; /** Options for an individual command execution */ -export interface RunOptions extends ShellExecaOptions { +export interface RunOptions extends ShellExecaOptions { /** Override the output behavior for this specific command */ outputMode?: Mode; - /** Whether to throw error on non-zero exit */ - throwOnError?: ThrowOnError; } /** The structured result returned by Shell.run() */ @@ -77,17 +75,15 @@ export type RunResult = * Factory function to create a new Shell instance for type safety and convenience. */ export function createShell< - DefaultThrow extends boolean = true, DefaultMode extends OutputMode = OutputMode ->(options: ShellOptions = {}) { - return new Shell(options); +>(options: ShellOptions = {}) { + return new Shell(options); } -export class Shell { +export class Shell { private defaultOutputMode: OutputMode; private dryRun: boolean; private verbose: boolean; - private throwOnError: boolean; private throwMode: 'simple' | 'raw'; private logger?: (message: string) => void; @@ -97,11 +93,10 @@ export class Shell = {}) { + constructor(options: ShellOptions = {}) { this.defaultOutputMode = options.defaultOutputMode ?? '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; } @@ -114,10 +109,9 @@ export class Shell(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; @@ -144,7 +138,7 @@ export class Shell; } catch (error: unknown) { if (error instanceof ExecaError) { - if (this.throwOnError || options?.throwOnError) { + if (options?.throwOnError) { if (this.throwMode === 'raw') { throw error; } else { @@ -176,4 +170,18 @@ export class Shell; } } + + public run( + cmd: string | string[], + options?: RunOptions + ): Promise> { + return this.execute(cmd, { ...options, throwOnError: true }); + } + + public safeRun( + cmd: string | string[], + options?: RunOptions + ): Promise> { + return this.execute(cmd, { ...options, throwOnError: false }); + } } From 9c60fcfb42a7e5300c64ba1008d6f031567427bd Mon Sep 17 00:00:00 2001 From: Thada Wangthammang Date: Sat, 8 Nov 2025 11:26:39 +0700 Subject: [PATCH 07/30] fix: update example to use 'capture' output mode for consistency --- examples/basic.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/examples/basic.ts b/examples/basic.ts index 2d887d5..1f1b928 100644 --- a/examples/basic.ts +++ b/examples/basic.ts @@ -2,8 +2,8 @@ import { createShell } from "src/shell.js"; async function main() { - const shell = createShell({ defaultOutputMode: 'live', verbose: true }); - const result = await shell.run("echo Hello, World!", { outputMode: 'capture', }); + const shell = createShell({ defaultOutputMode: 'capture', verbose: true }); + const result = await shell.run("echo Hello, World!"); console.log("Command Output:", result.stdout); } From 9941c38768d38ff4ad8809a32aa825fee5ec7204 Mon Sep 17 00:00:00 2001 From: Thada Wangthammang Date: Sat, 8 Nov 2025 11:27:20 +0700 Subject: [PATCH 08/30] refactor: enhance documentation and examples in Shell class for clarity and usability --- src/shell.ts | 253 +++++++++++++++++++++++++++++++++++++++++++++------ 1 file changed, 223 insertions(+), 30 deletions(-) diff --git a/src/shell.ts b/src/shell.ts index daa15b1..1639e59 100644 --- a/src/shell.ts +++ b/src/shell.ts @@ -1,78 +1,171 @@ /** - * A utility class for running shell commands with flexible output modes and configuration options. + * A type-safe 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 + * @example Basic usage + * ```typescript * const shell = new Shell({ verbose: true }); - * const result = await shell.run("bash delay.sh"); - * if (result.isSuccess) console.log(result.stdout); + * const result = await shell.run('echo "Hello World"'); + * console.log(result.stdout); // "Hello World" + * ``` + * + * @example Safe execution (no throw) + * ```typescript + * const shell = new Shell(); + * const result = await shell.safeRun('might-fail'); + * if (result.isError) { + * console.error('Command failed:', result.exitCode); + * } + * ``` + * + * @example Dry-run mode + * ```typescript + * const shell = new Shell({ dryRun: true, verbose: true }); + * await shell.run('rm -rf node_modules'); // Logs but doesn't execute + * ``` */ import { execa, type Options as ExecaOptions, ExecaError } from 'execa'; import parseArgsStringToArgv from 'string-argv'; -/** 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'; +/** + * 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; -/** Configuration options for Shell instance */ +/** + * Configuration options for Shell instance. + * + * @template Mode - The default output mode type (defaults to 'capture') + */ export interface ShellOptions { - /** Default output mode applied to all runs unless overridden */ + /** + * Default output mode applied to all runs unless overridden. + * + * @default 'capture' + */ defaultOutputMode?: Mode; - /** If true, print commands but skip actual execution */ + + /** + * 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; + /** * 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()` method that throws on error. + * + * Only applies when using `run()` or `execute()` with `throwOnError: true`. * * @default "simple" */ throwMode?: 'simple' | 'raw'; - /** Optional custom logger function for command output */ + + /** + * Optional custom logger function for command output. + * If not provided, defaults to `console.log`. + * + * @default console.log + */ logger?: (message: string) => void; } /** - * We handle some properties of ExecaOptions internally, so we omit them here to avoid conflicts. + * Execa options that can be passed to Shell methods. + * We handle some properties internally (`reject`, `stdout`, `stderr`), so we omit them to avoid conflicts. + * + * All other execa options (like `cwd`, `env`, `timeout`) can be passed through. */ export type ShellExecaOptions = Omit; -/** Options for an individual command execution */ +/** + * Options for an individual command execution. + * Extends all execa options except those handled internally. + * + * @template Mode - The output mode type for this specific command + */ export interface RunOptions extends ShellExecaOptions { - /** Override the output behavior for this specific command */ + /** + * Override the output behavior for this specific command. + * If not provided, uses the default mode from Shell constructor. + */ outputMode?: Mode; } -/** The structured result returned by Shell.run() */ +/** + * 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, or null if not captured */ + /** Captured stdout output (string if captured, null if live mode) */ stdout: Capture extends true ? string : null; - /** Captured stderr output, or null if not captured */ + /** Captured stderr output (string if captured, null if live mode) */ stderr: Capture extends true ? string : null; } +/** + * 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 */ + /** Exit code returned by the executed process (undefined if command failed to start) */ exitCode: number | undefined; - /** Indicates whether the command exited with an error */ + /** True if the command exited with a non-zero code or failed to execute */ isError: boolean; - /** Indicates whether the command executed successfully */ + /** True if the command exited with code 0 */ isSuccess: boolean; } +/** + * 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 for type safety and convenience. + * 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 = createShell({ defaultOutputMode: 'live', verbose: true }); + * await shell.run('npm install'); // Output streams to console + * ``` */ export function createShell< DefaultMode extends OutputMode = OutputMode @@ -80,6 +173,21 @@ export function createShell< return new Shell(options); } +/** + * Type-safe Shell class for executing commands with configurable behavior. + * + * @template DefaultMode - The default output mode for this instance (defaults to 'capture') + * + * @example Creating a shell instance + * ```typescript + * const shell = new Shell({ verbose: true, defaultOutputMode: 'capture' }); + * ``` + * + * @example Using the static factory + * ```typescript + * const shell = Shell.create({ dryRun: true }); + * ``` + */ export class Shell { private defaultOutputMode: OutputMode; private dryRun: boolean; @@ -87,27 +195,60 @@ export class Shell { private throwMode: 'simple' | 'raw'; private logger?: (message: string) => void; + /** + * Static factory method (alias for createShell). + * Provides better type inference than using the constructor directly. + */ public static create = createShell; /** * Create a new Shell instance. - * @param options - Configuration options for default behavior. + * + * @param options - Configuration options for default behavior + * + * @example + * ```typescript + * const shell = new Shell({ + * verbose: true, + * defaultOutputMode: 'capture', + * throwMode: 'simple' + * }); + * ``` */ constructor(options: ShellOptions = {}) { this.defaultOutputMode = options.defaultOutputMode ?? 'capture'; this.dryRun = options.dryRun ?? false; this.verbose = options.verbose ?? false; - this.throwMode = options.throwMode ?? 'simple'; // default "simple" + this.throwMode = options.throwMode ?? 'simple'; this.logger = options.logger ?? console.log; } /** - * 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. + * + * @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 for this execution. - * @returns A structured {@link RunResult} containing outputs and exit info. + * @param cmd - Command to execute, as string or array of arguments + * @param options - Optional overrides including throwOnError flag + * + * @returns A result object with type-safe stdout/stderr based on output mode and throw mode + * + * @example + * ```typescript + * // Throws on error + * const result = await shell.execute('echo test', { throwOnError: true }); + * console.log(result.stdout); // No need to check isSuccess + * + * // Returns error result + * const result = await shell.execute('might-fail', { throwOnError: false }); + * if (result.isSuccess) { + * console.log(result.stdout); + * } + * ``` */ public async execute( cmd: string | string[], options?: RunOptions & { throwOnError?: Throw } @@ -171,6 +312,32 @@ export class Shell { } } + /** + * 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 run( cmd: string | string[], options?: RunOptions @@ -178,6 +345,32 @@ export class Shell { 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, isError, and isSuccess 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, isError, and isSuccess + * + * @example + * ```typescript + * const shell = new Shell(); + * const result = await shell.safeRun('lint-code'); + * + * if (result.isError) { + * console.warn('Linting failed with exit code:', result.exitCode); + * console.warn('Errors:', result.stderr); + * } else { + * console.log('Linting passed!'); + * } + * ``` + */ public safeRun( cmd: string | string[], options?: RunOptions From d02e5ef4aeb20a2c5c95237b5337d0e4839cd6d2 Mon Sep 17 00:00:00 2001 From: Thada Wangthammang Date: Sat, 8 Nov 2025 11:28:54 +0700 Subject: [PATCH 09/30] fix: update SafeResult and Shell class to remove isError flag for improved clarity in result handling --- src/shell.ts | 14 +++++--------- 1 file changed, 5 insertions(+), 9 deletions(-) diff --git a/src/shell.ts b/src/shell.ts index 1639e59..b17c7ae 100644 --- a/src/shell.ts +++ b/src/shell.ts @@ -13,7 +13,7 @@ * ```typescript * const shell = new Shell(); * const result = await shell.safeRun('might-fail'); - * if (result.isError) { + * if (!result.isSuccess) { * console.error('Command failed:', result.exitCode); * } * ``` @@ -136,8 +136,6 @@ export interface StrictResult { export interface SafeResult extends StrictResult { /** Exit code returned by the executed process (undefined if command failed to start) */ exitCode: number | undefined; - /** True if the command exited with a non-zero code or failed to execute */ - isError: boolean; /** True if the command exited with code 0 */ isSuccess: boolean; } @@ -273,7 +271,7 @@ export class Shell { } if (this.dryRun) { - return { stdout: '', stderr: '', exitCode: 0, isError: false, isSuccess: true } as RunResult; + return { stdout: '', stderr: '', exitCode: 0, isSuccess: true } as RunResult; } try { @@ -287,7 +285,6 @@ export class Shell { 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, } as RunResult; } catch (error: unknown) { @@ -306,7 +303,6 @@ export class Shell { stdout: null, stderr: null, exitCode: undefined, - isError: true, isSuccess: false, } as RunResult; } @@ -349,21 +345,21 @@ export class Shell { * 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, isError, and isSuccess flags. + * Returns a result with exitCode, and isSuccess 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, isError, and isSuccess + * @returns Result with stdout, stderr, exitCode, and isSuccess * * @example * ```typescript * const shell = new Shell(); * const result = await shell.safeRun('lint-code'); * - * if (result.isError) { + * if (!result.isSuccess) { * console.warn('Linting failed with exit code:', result.exitCode); * console.warn('Errors:', result.stderr); * } else { From 70c67659595cc144c721a4204110008b0213a19b Mon Sep 17 00:00:00 2001 From: Thada Wangthammang Date: Sat, 8 Nov 2025 11:31:04 +0700 Subject: [PATCH 10/30] fix: update RunResult and SafeResult to replace isSuccess with success for consistency --- CLAUDE.md | 2 +- src/shell.ts | 20 ++++++++++---------- 2 files changed, 11 insertions(+), 11 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index ebff12c..c885d1a 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -52,7 +52,7 @@ The `Shell` class (`src/shell.ts`) is the only export and implements: - `ShellOptions`: Constructor configuration (defaultOutputMode, dryRun, verbose, throwOnError, throwMode, logger) - `RunOptions`: Per-command overrides, extends `ExecaOptions` from execa -- `RunResult`: Structured return value (stdout, stderr, exitCode, isError, isSuccess) +- `RunResult`: Structured return value (stdout, stderr, exitCode, isSuccess) ## Development Commands diff --git a/src/shell.ts b/src/shell.ts index b17c7ae..fadaf09 100644 --- a/src/shell.ts +++ b/src/shell.ts @@ -13,7 +13,7 @@ * ```typescript * const shell = new Shell(); * const result = await shell.safeRun('might-fail'); - * if (!result.isSuccess) { + * if (!result.success) { * console.error('Command failed:', result.exitCode); * } * ``` @@ -137,7 +137,7 @@ export interface SafeResult extends StrictResult { * ```typescript * // Throws on error * const result = await shell.execute('echo test', { throwOnError: true }); - * console.log(result.stdout); // No need to check isSuccess + * console.log(result.stdout); // No need to check success * * // Returns error result * const result = await shell.execute('might-fail', { throwOnError: false }); - * if (result.isSuccess) { + * if (result.success) { * console.log(result.stdout); * } * ``` @@ -271,7 +271,7 @@ export class Shell { } if (this.dryRun) { - return { stdout: '', stderr: '', exitCode: 0, isSuccess: true } as RunResult; + return { stdout: '', stderr: '', exitCode: 0, success: true } as RunResult; } try { @@ -285,7 +285,7 @@ export class Shell { stdout: result.stdout ? String(result.stdout) : null, stderr: result.stderr ? String(result.stderr) : null, exitCode: result.exitCode, - isSuccess: result.exitCode === 0, + success: result.exitCode === 0, } as RunResult; } catch (error: unknown) { if (error instanceof ExecaError) { @@ -303,7 +303,7 @@ export class Shell { stdout: null, stderr: null, exitCode: undefined, - isSuccess: false, + success: false, } as RunResult; } } @@ -345,21 +345,21 @@ export class Shell { * 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 isSuccess flags. + * 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 isSuccess + * @returns Result with stdout, stderr, exitCode, and success * * @example * ```typescript * const shell = new Shell(); * const result = await shell.safeRun('lint-code'); * - * if (!result.isSuccess) { + * if (!result.success) { * console.warn('Linting failed with exit code:', result.exitCode); * console.warn('Errors:', result.stderr); * } else { From ad7bff261e900152cc7619b64ef4e526cac65405 Mon Sep 17 00:00:00 2001 From: Thada Wangthammang Date: Sat, 8 Nov 2025 11:37:56 +0700 Subject: [PATCH 11/30] docs: enhance CLAUDE.md and README.md with detailed Shell class method descriptions and error handling examples --- CLAUDE.md | 33 ++++++++++++++++---- README.md | 92 ++++++++++++++++++++++++++++++++++++++++++------------- 2 files changed, 97 insertions(+), 28 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index c885d1a..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, 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..a365a95 100644 --- a/README.md +++ b/README.md @@ -134,18 +134,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'; -const shell = new Shell({ - throwOnError: false // Don't throw on non-zero exit codes -}); +const shell = new Shell(); -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 @@ -173,9 +172,6 @@ interface ShellOptions { /** 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 - /** * Controls how errors are thrown when a command fails. * - "simple" → Throws a short, human-readable error message @@ -190,31 +186,42 @@ interface ShellOptions { ### `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,14 +231,31 @@ 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?)` - /** Indicates whether the command executed successfully */ - isSuccess: boolean; +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' } ``` +Inherits all options from [execa's Options](https://github.com/sindresorhus/execa#options). + ### Output Modes - **`capture`** (default): Captures stdout/stderr for programmatic access. Output is not printed to console. @@ -240,6 +264,30 @@ interface RunResult { ## Advanced Examples +### Using run() vs safeRun() + +```typescript +import { Shell } from '@thaitype/shell'; + +const shell = new Shell(); + +// 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 From 648dca431dce989b3fcbf557cd90bfb20f479916 Mon Sep 17 00:00:00 2001 From: Thada Wangthammang Date: Sat, 8 Nov 2025 11:38:03 +0700 Subject: [PATCH 12/30] fix: update Shell test cases to replace isSuccess and isError with success for consistency --- test/shell.test.ts | 110 ++++++++++++++++++++++++--------------------- 1 file changed, 59 insertions(+), 51 deletions(-) diff --git a/test/shell.test.ts b/test/shell.test.ts index 0d08419..a88c514 100644 --- a/test/shell.test.ts +++ b/test/shell.test.ts @@ -7,8 +7,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 +14,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'); }); @@ -63,16 +60,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 +75,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,14 +85,13 @@ 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 }); }); @@ -116,7 +110,7 @@ describe('Shell', () => { await shell.run('echo test'); - // dryRun alone logs, but let's check it does log + // dryRun alone logs, so it should still log expect(mockLogger).toHaveBeenCalledWith('$ echo test'); }); }); @@ -150,8 +144,8 @@ describe('Shell', () => { }); }); - 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 +179,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 }); }); }); @@ -277,28 +269,25 @@ describe('Shell', () => { }); 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 +299,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'); @@ -336,7 +325,6 @@ describe('Shell', () => { defaultOutputMode: 'capture', dryRun: false, verbose: true, - throwOnError: true, throwMode: 'simple', logger: mockLogger }); @@ -348,30 +336,30 @@ 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(); }); @@ -389,4 +377,24 @@ describe('Shell', () => { 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(); + }); + }); }); From 56f5866dffa23d453c15c6dee2495b4e4092a6dc Mon Sep 17 00:00:00 2001 From: Thada Wangthammang Date: Sat, 8 Nov 2025 11:49:23 +0700 Subject: [PATCH 13/30] feat: add standard schema validation support in Shell class and new standard-schema module --- package.json | 4 +++- pnpm-lock.yaml | 16 ++++++++++++++++ src/shell.ts | 15 +++++++++++++-- src/standard-schema.ts | 20 ++++++++++++++++++++ 4 files changed, 52 insertions(+), 3 deletions(-) create mode 100644 src/standard-schema.ts diff --git a/package.json b/package.json index 3d74d9f..2b44007 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,7 @@ "node": ">=20" }, "dependencies": { + "@standard-schema/spec": "^1.0.0", "execa": "^9.6.0", "string-argv": "^0.3.2" } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 4c836fb..538386b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -8,6 +8,9 @@ importers: .: dependencies: + '@standard-schema/spec': + specifier: ^1.0.0 + version: 1.0.0 execa: specifier: ^9.6.0 version: 9.6.0 @@ -75,6 +78,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 +618,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==} @@ -1978,6 +1987,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 +2541,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 @@ -3894,3 +3908,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 fadaf09..3e6ad17 100644 --- a/src/shell.ts +++ b/src/shell.ts @@ -24,8 +24,10 @@ * await shell.run('rm -rf node_modules'); // Logs but doesn't execute * ``` */ +import type { StandardSchemaV1 } from '@standard-schema/spec'; import { execa, type Options as ExecaOptions, ExecaError } from 'execa'; import parseArgsStringToArgv from 'string-argv'; +import { standardValidate } from './standard-schema.js'; /** * Output mode behavior for handling stdout/stderr. @@ -334,7 +336,7 @@ export class Shell { * } * ``` */ - public run( + public async run( cmd: string | string[], options?: RunOptions ): Promise> { @@ -367,10 +369,19 @@ export class Shell { * } * ``` */ - public safeRun( + 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); + return standardValidate(schema, JSON.parse(result.stdout ?? '{}')); + } } diff --git a/src/standard-schema.ts b/src/standard-schema.ts new file mode 100644 index 0000000..9619bf6 --- /dev/null +++ b/src/standard-schema.ts @@ -0,0 +1,20 @@ +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; +} \ No newline at end of file From 981abcae6364eb09a3c2dde51083aa7d70d371c3 Mon Sep 17 00:00:00 2001 From: Thada Wangthammang Date: Sat, 8 Nov 2025 11:49:42 +0700 Subject: [PATCH 14/30] feat: add example scripts for shell command execution and parsing with schema validation --- examples/{basic.ts => run.ts} | 0 examples/runParse.ts | 14 ++++++++++++++ 2 files changed, 14 insertions(+) rename examples/{basic.ts => run.ts} (100%) create mode 100644 examples/runParse.ts diff --git a/examples/basic.ts b/examples/run.ts similarity index 100% rename from examples/basic.ts rename to examples/run.ts diff --git a/examples/runParse.ts b/examples/runParse.ts new file mode 100644 index 0000000..d95436a --- /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({ verbose: true }); + const result = await shell.runParse(`echo '{ "username": "John" }'`, schema); + console.log("Command Output:", result.username); +} + +main(); \ No newline at end of file From 38091dc36d8edf18bf9098aa818fa221f85c6926 Mon Sep 17 00:00:00 2001 From: Thada Wangthammang Date: Sat, 8 Nov 2025 12:05:28 +0700 Subject: [PATCH 15/30] feat: add safeRunParse method to Shell class for enhanced command output validation --- src/shell.ts | 31 ++++++++++++++++++++++++++++++- src/standard-schema.ts | 29 +++++++++++++++++++++++++++++ 2 files changed, 59 insertions(+), 1 deletion(-) diff --git a/src/shell.ts b/src/shell.ts index 3e6ad17..b08b514 100644 --- a/src/shell.ts +++ b/src/shell.ts @@ -27,7 +27,7 @@ import type { StandardSchemaV1 } from '@standard-schema/spec'; import { execa, type Options as ExecaOptions, ExecaError } from 'execa'; import parseArgsStringToArgv from 'string-argv'; -import { standardValidate } from './standard-schema.js'; +import { standardSafeValidate, standardValidate, type StandardResult } from './standard-schema.js'; /** * Output mode behavior for handling stdout/stderr. @@ -384,4 +384,33 @@ export class Shell { const result = await this.run(cmd, options); 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 fullCommand = Array.isArray(cmd) ? cmd.join(' ') : cmd; + if (!result.stdout) { + return { + success: false, + error: [{ message: `The command produced no output to validate: ${fullCommand}` }] + } + } + if (!result.success) { + return { + success: false, + error: [{ message: `The command failed with exit code ${result.exitCode}: ${fullCommand}` }] + } + } + 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)) }] + } + } + } } diff --git a/src/standard-schema.ts b/src/standard-schema.ts index 9619bf6..b22e189 100644 --- a/src/standard-schema.ts +++ b/src/standard-schema.ts @@ -17,4 +17,33 @@ export async function standardValidate( } 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, + } } \ No newline at end of file From e8406f79db97b924bb4e1102b9c2838899740362 Mon Sep 17 00:00:00 2001 From: Thada Wangthammang Date: Sat, 8 Nov 2025 12:06:33 +0700 Subject: [PATCH 16/30] feat: add example script for safeRunParse method demonstrating command output validation --- examples/safeRunParse.ts | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) create mode 100644 examples/safeRunParse.ts diff --git a/examples/safeRunParse.ts b/examples/safeRunParse.ts new file mode 100644 index 0000000..c880604 --- /dev/null +++ b/examples/safeRunParse.ts @@ -0,0 +1,18 @@ +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 From aac5f09fa3fed0a60be94b1cbc42f0db4ccff569 Mon Sep 17 00:00:00 2001 From: Thada Wangthammang Date: Sat, 8 Nov 2025 12:06:47 +0700 Subject: [PATCH 17/30] feat: add example script demonstrating safeRun method usage with command output handling --- examples/safeRun.ts | 13 +++++++++++++ 1 file changed, 13 insertions(+) create mode 100644 examples/safeRun.ts diff --git a/examples/safeRun.ts b/examples/safeRun.ts new file mode 100644 index 0000000..fc56e7b --- /dev/null +++ b/examples/safeRun.ts @@ -0,0 +1,13 @@ +import { createShell } from "src/shell.js"; + +async function main() { + const shell = createShell({ defaultOutputMode: '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 From 13f8172bf590263980947870e5e17a323f5b7a1d Mon Sep 17 00:00:00 2001 From: Thada Wangthammang Date: Sat, 8 Nov 2025 12:12:46 +0700 Subject: [PATCH 18/30] feat: enhance README with schema validation examples for JSON output parsing --- README.md | 134 ++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 134 insertions(+) diff --git a/README.md b/README.md index a365a95..6c77e48 100644 --- a/README.md +++ b/README.md @@ -23,6 +23,7 @@ Running shell commands in Node.js often involves repetitive boilerplate and deal - **Dry-run mode**: Test your scripts without executing actual commands - **Verbose logging**: Automatically log all executed commands - **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 - **Type-safe**: Written in TypeScript with full type definitions - **ESM-first**: Modern ES modules support @@ -153,6 +154,50 @@ if (!result.success) { } ``` +### 5. Schema Validation with JSON Output + +Parse and validate JSON output from commands using Standard Schema: + +```typescript +import { Shell } from '@thaitype/shell'; +import { z } from 'zod'; + +const shell = new Shell(); + +// 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 ### `new Shell(options?)` @@ -256,6 +301,95 @@ interface RunOptions extends ExecaOptions { Inherits all options from [execa's Options](https://github.com/sindresorhus/execa#options). +### `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 { Shell } from '@thaitype/shell'; +import { z } from 'zod'; + +const shell = new Shell(); + +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 { Shell } from '@thaitype/shell'; +import { z } from 'zod'; + +const shell = new Shell(); + +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); +} +``` + ### Output Modes - **`capture`** (default): Captures stdout/stderr for programmatic access. Output is not printed to console. From 24827c5a30c2c107d4d8819bac9f0f2310a0eb9f Mon Sep 17 00:00:00 2001 From: Thada Wangthammang Date: Sat, 8 Nov 2025 12:12:53 +0700 Subject: [PATCH 19/30] feat: update Shell class documentation to emphasize createShell factory function for improved type inference --- src/shell.ts | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/src/shell.ts b/src/shell.ts index b08b514..fae59db 100644 --- a/src/shell.ts +++ b/src/shell.ts @@ -175,6 +175,7 @@ export function createShell< /** * 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') * @@ -197,7 +198,19 @@ export class Shell { /** * Static factory method (alias for createShell). - * Provides better type inference than using the constructor directly. + * + * 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({ defaultOutputMode: 'live', verbose: true }); + * await shell.run('npm install'); // Output streams to console + * ``` */ public static create = createShell; From ac3c67843f9a9413a26e202842dadd938c9ec891 Mon Sep 17 00:00:00 2001 From: Thada Wangthammang Date: Sat, 8 Nov 2025 12:16:30 +0700 Subject: [PATCH 20/30] feat: update README to recommend createShell factory function for improved type inference --- README.md | 59 +++++++++++++++++++++++++++++++++---------------------- 1 file changed, 36 insertions(+), 23 deletions(-) diff --git a/README.md b/README.md index 6c77e48..5d8917b 100644 --- a/README.md +++ b/README.md @@ -53,10 +53,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"'); @@ -71,9 +71,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 }); @@ -91,9 +91,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 }); @@ -115,9 +115,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' }); @@ -138,9 +138,9 @@ console.log('Build output was:', result2.stdout); Handle command failures without throwing exceptions using `safeRun()`: ```typescript -import { Shell } from '@thaitype/shell'; +import { createShell } from '@thaitype/shell'; -const shell = new Shell(); +const shell = createShell(); // safeRun() never throws, returns error result instead const result = await shell.safeRun('some-command-that-might-fail'); @@ -159,10 +159,10 @@ if (!result.success) { Parse and validate JSON output from commands using Standard Schema: ```typescript -import { Shell } from '@thaitype/shell'; +import { createShell } from '@thaitype/shell'; import { z } from 'zod'; -const shell = new Shell(); +const shell = createShell(); // Define a schema for package.json const packageSchema = z.object({ @@ -200,9 +200,22 @@ if (result.success) { ## 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({ defaultOutputMode: 'live' }); +``` + ### `new Shell(options?)` -Creates a new Shell instance with the specified configuration. +Alternative constructor for creating a Shell instance. #### Options @@ -326,10 +339,10 @@ Execute a command, parse its stdout as JSON, and validate it against a [Standard **Example with Zod:** ```typescript -import { Shell } from '@thaitype/shell'; +import { createShell } from '@thaitype/shell'; import { z } from 'zod'; -const shell = new Shell(); +const shell = createShell(); const packageSchema = z.object({ name: z.string(), @@ -368,10 +381,10 @@ type StandardResult = **Example with Zod:** ```typescript -import { Shell } from '@thaitype/shell'; +import { createShell } from '@thaitype/shell'; import { z } from 'zod'; -const shell = new Shell(); +const shell = createShell(); const userSchema = z.object({ username: z.string(), @@ -401,9 +414,9 @@ if (result.success) { ### Using run() vs safeRun() ```typescript -import { Shell } from '@thaitype/shell'; +import { createShell } from '@thaitype/shell'; -const shell = new Shell(); +const shell = createShell(); // run() - Throws on error (fail fast) try { @@ -425,7 +438,7 @@ if (result.success) { ### Custom Logger Integration ```typescript -import { Shell } from '@thaitype/shell'; +import { createShell } from '@thaitype/shell'; import winston from 'winston'; const logger = winston.createLogger({ @@ -434,7 +447,7 @@ const logger = winston.createLogger({ transports: [new winston.transports.Console()] }); -const shell = new Shell({ +const shell = createShell({ verbose: true, logger: (message) => logger.info(message) }); @@ -446,9 +459,9 @@ await shell.run('npm install'); ### Combining with Execa Options ```typescript -import { Shell } from '@thaitype/shell'; +import { createShell } from '@thaitype/shell'; -const shell = new Shell(); +const shell = createShell(); // Pass any execa options const result = await shell.run('node script.js', { From 3c234b152b84510e5a50c95fc409860731a56798 Mon Sep 17 00:00:00 2001 From: Thada Wangthammang Date: Sat, 8 Nov 2025 12:27:56 +0700 Subject: [PATCH 21/30] feat: enhance verbose logging in Shell class for command execution and validation outputs --- src/shell.ts | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/src/shell.ts b/src/shell.ts index fae59db..02fe777 100644 --- a/src/shell.ts +++ b/src/shell.ts @@ -395,6 +395,8 @@ export class Shell { options?: RunOptions ): Promise> { const result = await this.run(cmd, options); + const verboseOutput = this.verbose ? `\nStdout: ${result.stdout}\nStderr: ${result.stderr}` : ''; + if (this.verbose) this.logger?.('Validation Output:' + verboseOutput); return standardValidate(schema, JSON.parse(result.stdout ?? '{}')); } @@ -405,16 +407,19 @@ export class Shell { ): Promise>> { const result = await this.safeRun(cmd, options); const fullCommand = Array.isArray(cmd) ? cmd.join(' ') : cmd; + const verboseOutput = this.verbose ? `\nStdout: ${result.stdout}\nStderr: ${result.stderr}` : ''; + const verboseCommand = this.verbose ? `\nCommand: ${fullCommand}` : ''; + const verboseInfo = verboseCommand + verboseOutput; if (!result.stdout) { return { success: false, - error: [{ message: `The command produced no output to validate: ${fullCommand}` }] + 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}: ${fullCommand}` }] + error: [{ message: `The command failed with exit code ${result.exitCode}. ${verboseInfo}` }] } } try { @@ -422,7 +427,7 @@ export class Shell { } catch (e: unknown) { return { success: false, - error: [{ message: 'Unable to Parse JSON: ' + (e instanceof Error ? e.message : String(e)) }] + error: [{ message: 'Unable to Parse JSON: ' + (e instanceof Error ? e.message : String(e)) + verboseInfo }] } } } From c247329946d706182ffdb29860a976d09cd6646a Mon Sep 17 00:00:00 2001 From: Thada Wangthammang Date: Sat, 8 Nov 2025 14:06:42 +0700 Subject: [PATCH 22/30] feat: remove outdated documentation comments from Shell class --- src/shell.ts | 26 -------------------------- 1 file changed, 26 deletions(-) diff --git a/src/shell.ts b/src/shell.ts index 02fe777..6871d7e 100644 --- a/src/shell.ts +++ b/src/shell.ts @@ -1,29 +1,3 @@ -/** - * A type-safe 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 Basic usage - * ```typescript - * const shell = new Shell({ verbose: true }); - * const result = await shell.run('echo "Hello World"'); - * console.log(result.stdout); // "Hello World" - * ``` - * - * @example Safe execution (no throw) - * ```typescript - * const shell = new Shell(); - * const result = await shell.safeRun('might-fail'); - * if (!result.success) { - * console.error('Command failed:', result.exitCode); - * } - * ``` - * - * @example Dry-run mode - * ```typescript - * const shell = new Shell({ dryRun: true, verbose: true }); - * await shell.run('rm -rf node_modules'); // Logs but doesn't execute - * ``` - */ import type { StandardSchemaV1 } from '@standard-schema/spec'; import { execa, type Options as ExecaOptions, ExecaError } from 'execa'; import parseArgsStringToArgv from 'string-argv'; From fd628eebafa63a04ffd65a24ca161b8868644d2a Mon Sep 17 00:00:00 2001 From: Thada Wangthammang Date: Sat, 8 Nov 2025 14:48:25 +0700 Subject: [PATCH 23/30] feat: standardize shell options formatting and update test cases for output mode --- examples/run.ts | 5 +- examples/runParse.ts | 2 +- examples/safeRun.ts | 5 +- examples/safeRunParse.ts | 6 ++- src/shell.ts | 113 +++++++++++++++++++++++---------------- src/standard-schema.ts | 24 +++++---- test/shell.test.ts | 70 +++++++++++++++++++----- 7 files changed, 152 insertions(+), 73 deletions(-) diff --git a/examples/run.ts b/examples/run.ts index 1f1b928..9c5da87 100644 --- a/examples/run.ts +++ b/examples/run.ts @@ -2,7 +2,10 @@ import { createShell } from "src/shell.js"; async function main() { - const shell = createShell({ defaultOutputMode: 'capture', verbose: true }); + const shell = createShell({ + outputMode: 'capture', + verbose: true + }); const result = await shell.run("echo Hello, World!"); console.log("Command Output:", result.stdout); } diff --git a/examples/runParse.ts b/examples/runParse.ts index d95436a..a4e2c5c 100644 --- a/examples/runParse.ts +++ b/examples/runParse.ts @@ -6,7 +6,7 @@ const schema = z.object({ }); async function main() { - const shell = createShell({ verbose: true }); + const shell = createShell(); const result = await shell.runParse(`echo '{ "username": "John" }'`, schema); console.log("Command Output:", result.username); } diff --git a/examples/safeRun.ts b/examples/safeRun.ts index fc56e7b..25b5751 100644 --- a/examples/safeRun.ts +++ b/examples/safeRun.ts @@ -1,7 +1,10 @@ import { createShell } from "src/shell.js"; async function main() { - const shell = createShell({ defaultOutputMode: 'capture', verbose: true }); + const shell = createShell({ + outputMode: 'capture', + verbose: true, + }); const result = await shell.safeRun("echo Hello, World!"); if (result.success) { console.log("Command Output:", result.stdout); diff --git a/examples/safeRunParse.ts b/examples/safeRunParse.ts index c880604..56376d7 100644 --- a/examples/safeRunParse.ts +++ b/examples/safeRunParse.ts @@ -6,8 +6,10 @@ const schema = z.object({ }); async function main() { - const shell = createShell({ verbose: true }); - const result = await shell.safeRunParse(`echo '{ "username1": "John"'`, schema); + 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 { diff --git a/src/shell.ts b/src/shell.ts index 6871d7e..7233253 100644 --- a/src/shell.ts +++ b/src/shell.ts @@ -16,22 +16,23 @@ export type OutputMode = 'capture' | 'live' | 'all'; * 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; +type CaptureForMode = M extends 'live' ? false : true; /** - * Configuration options for Shell instance. - * - * @template Mode - The default output mode type (defaults to 'capture') + * Default options that can be overridden per command execution. */ -export interface ShellOptions { +export interface OverridableCommandOptions { + /** + * Default execa options applied to all command executions. + * Can be overridden by individual run options. + */ + execaOptions?: ShellExecaOptions; /** * Default output mode applied to all runs unless overridden. * * @default 'capture' */ - defaultOutputMode?: Mode; - + outputMode?: Mode; /** * If true, print commands but skip actual execution. * Useful for testing scripts without making real changes. @@ -39,7 +40,6 @@ export interface ShellOptions { * @default false */ dryRun?: boolean; - /** * If true, log every executed command to the logger. * Helpful for debugging and CI/CD pipelines. @@ -47,7 +47,14 @@ export interface ShellOptions { * @default false */ verbose?: boolean; +} +/** + * 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 with command, exit code, and stderr. @@ -70,25 +77,24 @@ export interface ShellOptions { /** * Execa options that can be passed to Shell methods. - * We handle some properties internally (`reject`, `stdout`, `stderr`), so we omit them to avoid conflicts. + * 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; +export type ShellExecaOptions = Omit; /** * Options for an individual command execution. - * Extends all execa options except those handled internally. + * Extends overridable command options and execa options. * * @template Mode - The output mode type for this specific command */ -export interface RunOptions extends ShellExecaOptions { - /** - * Override the output behavior for this specific command. - * If not provided, uses the default mode from Shell constructor. - */ - outputMode?: Mode; -} +export interface RunOptions + extends Omit, 'execaOptions'>, + ShellExecaOptions {} /** * Strict result returned by `run()` method (throws on error). @@ -122,8 +128,7 @@ export interface SafeResult extends StrictResult = - Throw extends true +export type RunResult = Throw extends true ? StrictResult> : SafeResult>; @@ -137,13 +142,14 @@ export type RunResult = * * @example * ```typescript - * const shell = createShell({ defaultOutputMode: 'live', verbose: true }); + * const shell = createShell({ + * outputMode: 'live', + * verbose: true + * }); * await shell.run('npm install'); // Output streams to console * ``` */ -export function createShell< - DefaultMode extends OutputMode = OutputMode ->(options: ShellOptions = {}) { +export function createShell(options: ShellOptions = {}) { return new Shell(options); } @@ -155,12 +161,17 @@ export function createShell< * * @example Creating a shell instance * ```typescript - * const shell = new Shell({ verbose: true, defaultOutputMode: 'capture' }); + * const shell = new Shell({ + * outputMode: 'capture', + * verbose: true + * }); * ``` * * @example Using the static factory * ```typescript - * const shell = Shell.create({ dryRun: true }); + * const shell = Shell.create({ + * dryRun: true + * }); * ``` */ export class Shell { @@ -172,7 +183,7 @@ export class Shell { /** * 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()`. * @@ -182,7 +193,10 @@ export class Shell { * * @example * ```typescript - * const shell = Shell.create({ defaultOutputMode: 'live', verbose: true }); + * const shell = Shell.create({ + * outputMode: 'live', + * verbose: true + * }); * await shell.run('npm install'); // Output streams to console * ``` */ @@ -196,14 +210,14 @@ export class Shell { * @example * ```typescript * const shell = new Shell({ + * outputMode: 'capture', * verbose: true, - * defaultOutputMode: 'capture', * throwMode: 'simple' * }); * ``` */ constructor(options: ShellOptions = {}) { - this.defaultOutputMode = options.defaultOutputMode ?? 'capture'; + this.defaultOutputMode = options.outputMode ?? 'capture'; this.dryRun = options.dryRun ?? false; this.verbose = options.verbose ?? false; this.throwMode = options.throwMode ?? 'simple'; @@ -238,7 +252,8 @@ export class Shell { * ``` */ public async execute( - cmd: string | string[], options?: RunOptions & { throwOnError?: Throw } + cmd: string | string[], + options?: RunOptions & { throwOnError?: Throw } ): Promise> { const args = Array.isArray(cmd) ? cmd : parseArgsStringToArgv(cmd); @@ -247,7 +262,10 @@ export class Shell { throw new Error('No command provided.'); } + // Merge command-level overrides with instance defaults const outputMode = options?.outputMode ?? this.defaultOutputMode; + const verbose = options?.verbose ?? this.verbose; + const dryRun = options?.dryRun ?? this.dryRun; const stdioMap: Record = { capture: { stdout: 'pipe', stderr: 'pipe' }, @@ -255,19 +273,22 @@ export class Shell { all: { stdout: ['pipe', 'inherit'], stderr: ['pipe', 'inherit'] }, }; - if (this.verbose || this.dryRun) { + if (verbose || dryRun) { this.logger?.(`$ ${args.join(' ')}`); } - if (this.dryRun) { + if (dryRun) { return { stdout: '', stderr: '', exitCode: 0, success: true } as RunResult; } try { + // Extract our custom properties to avoid passing them to execa + const { outputMode: _, verbose: __, dryRun: ___, ...execaOptions } = options ?? {}; + const result = await execa(program, cmdArgs, { ...stdioMap[outputMode], reject: options?.throwOnError ?? true, - ...options, + ...execaOptions, }); return { @@ -369,8 +390,9 @@ export class Shell { options?: RunOptions ): Promise> { const result = await this.run(cmd, options); - const verboseOutput = this.verbose ? `\nStdout: ${result.stdout}\nStderr: ${result.stderr}` : ''; - if (this.verbose) this.logger?.('Validation Output:' + verboseOutput); + const verbose = options?.verbose ?? this.verbose; + const verboseOutput = verbose ? `\nStdout: ${result.stdout}\nStderr: ${result.stderr}` : ''; + if (verbose) this.logger?.('Validation Output:' + verboseOutput); return standardValidate(schema, JSON.parse(result.stdout ?? '{}')); } @@ -380,29 +402,30 @@ export class Shell { 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 = this.verbose ? `\nStdout: ${result.stdout}\nStderr: ${result.stderr}` : ''; - const verboseCommand = this.verbose ? `\nCommand: ${fullCommand}` : ''; + 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}` }] - } + 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}` }] - } + 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 }] - } + 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 index b22e189..a1c4f65 100644 --- a/src/standard-schema.ts +++ b/src/standard-schema.ts @@ -1,7 +1,7 @@ import type { StandardSchemaV1 } from '@standard-schema/spec'; /** - * Validate input against a Standard Schema. + * Validate input against a Standard Schema. * @see https://github.com/standard-schema/standard-schema */ export async function standardValidate( @@ -19,13 +19,15 @@ export async function standardValidate( return result.value; } -export type StandardResult = { - success: true; - data: T; -} | { - success: false; - error: ReadonlyArray; -}; +export type StandardResult = + | { + success: true; + data: T; + } + | { + success: false; + error: ReadonlyArray; + }; export async function standardSafeValidate( schema: T, @@ -39,11 +41,11 @@ export async function standardSafeValidate( return { success: false, error: result.issues, - } + }; } return { success: true, data: result.value as StandardSchemaV1.InferOutput, - } -} \ No newline at end of file + }; +} diff --git a/test/shell.test.ts b/test/shell.test.ts index a88c514..8ff7ec5 100644 --- a/test/shell.test.ts +++ b/test/shell.test.ts @@ -40,14 +40,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 @@ -97,7 +97,11 @@ describe('Shell', () => { 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 shell = new Shell({ + dryRun: true, + verbose: true, + logger: mockLogger + }); await shell.run('echo test'); @@ -106,7 +110,11 @@ describe('Shell', () => { 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 shell = new Shell({ + dryRun: true, + verbose: false, + logger: mockLogger + }); await shell.run('echo test'); @@ -118,7 +126,10 @@ describe('Shell', () => { 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 shell = new Shell({ + verbose: true, + logger: mockLogger + }); await shell.run('echo test'); @@ -127,7 +138,10 @@ describe('Shell', () => { it('should not log commands when verbose is disabled', async () => { const mockLogger = vi.fn(); - const shell = new Shell({ verbose: false, logger: mockLogger }); + const shell = new Shell({ + verbose: false, + logger: mockLogger + }); await shell.run('echo test'); @@ -136,7 +150,10 @@ describe('Shell', () => { it('should log array commands correctly', async () => { const mockLogger = vi.fn(); - const shell = new Shell({ verbose: true, logger: mockLogger }); + const shell = new Shell({ + verbose: true, + logger: mockLogger + }); await shell.run(['echo', 'hello', 'world']); @@ -241,7 +258,10 @@ describe('Shell', () => { const logs: string[] = []; const customLogger = (msg: string) => logs.push(msg); - const shell = new Shell({ verbose: true, logger: customLogger }); + const shell = new Shell({ + verbose: true, + logger: customLogger + }); await shell.run('echo test'); expect(logs).toContain('$ echo test'); @@ -259,7 +279,11 @@ describe('Shell', () => { 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 shell = new Shell({ + verbose: true, + dryRun: true, + logger: mockLogger + }); await shell.run('echo test'); @@ -322,7 +346,7 @@ describe('Shell', () => { it('should accept all valid options', () => { const mockLogger = vi.fn(); const shell = new Shell({ - defaultOutputMode: 'capture', + outputMode: 'capture', dryRun: false, verbose: true, throwMode: 'simple', @@ -364,18 +388,40 @@ describe('Shell', () => { }); 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 mockLogger = vi.fn(); + const shell = new Shell({ + verbose: false, + logger: mockLogger + }); + + // This command should log because we override verbose to true + await shell.run('echo test', { verbose: true }); + expect(mockLogger).toHaveBeenCalledWith('$ echo test'); + }); + + 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); + }); }); describe('Edge Cases - ExecaError Handling', () => { From 9c5630febb062ed140fac670400ab193030e4b40 Mon Sep 17 00:00:00 2001 From: Thada Wangthammang Date: Sat, 8 Nov 2025 15:11:31 +0700 Subject: [PATCH 24/30] feat: implement custom logging interface for Shell class and update tests for logger integration --- src/shell.ts | 72 ++++++++++++++++++++++++++++++++++++---------- test/shell.test.ts | 62 ++++++++++++++++++++------------------- 2 files changed, 90 insertions(+), 44 deletions(-) diff --git a/src/shell.ts b/src/shell.ts index 7233253..1d7f268 100644 --- a/src/shell.ts +++ b/src/shell.ts @@ -49,6 +49,32 @@ export interface OverridableCommandOptions { verbose?: 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. * @@ -69,10 +95,8 @@ export interface ShellOptions extends Overr /** * Optional custom logger function for command output. * If not provided, defaults to `console.log`. - * - * @default console.log */ - logger?: (message: string) => void; + logger?: ShellLogger; } /** @@ -179,7 +203,7 @@ export class Shell { private dryRun: boolean; private verbose: boolean; private throwMode: 'simple' | 'raw'; - private logger?: (message: string) => void; + private logger: ShellLogger; /** * Static factory method (alias for createShell). @@ -221,7 +245,10 @@ export class Shell { this.dryRun = options.dryRun ?? false; this.verbose = options.verbose ?? false; this.throwMode = options.throwMode ?? 'simple'; - this.logger = options.logger ?? console.log; + 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)), + }; } /** @@ -273,8 +300,22 @@ export class Shell { all: { stdout: ['pipe', 'inherit'], stderr: ['pipe', 'inherit'] }, }; + // Extract our custom properties to avoid passing them to execa + const { outputMode: _, verbose: __, dryRun: ___, ...execaOptions } = options ?? {}; + + const finalExecaOptions: ExecaOptions = { + ...stdioMap[outputMode], + reject: options?.throwOnError ?? true, + ...execaOptions, + }; + + const logContext: ShellLogContext = { + command: cmd, + execaOptions: finalExecaOptions, + }; + if (verbose || dryRun) { - this.logger?.(`$ ${args.join(' ')}`); + this.logger.debug?.(`$ ${args.join(' ')}`, logContext); } if (dryRun) { @@ -282,14 +323,7 @@ export class Shell { } try { - // Extract our custom properties to avoid passing them to execa - const { outputMode: _, verbose: __, dryRun: ___, ...execaOptions } = options ?? {}; - - const result = await execa(program, cmdArgs, { - ...stdioMap[outputMode], - reject: options?.throwOnError ?? true, - ...execaOptions, - }); + const result = await execa(program, cmdArgs, finalExecaOptions); return { stdout: result.stdout ? String(result.stdout) : null, @@ -392,7 +426,15 @@ export class Shell { const result = await this.run(cmd, options); const verbose = options?.verbose ?? this.verbose; const verboseOutput = verbose ? `\nStdout: ${result.stdout}\nStderr: ${result.stderr}` : ''; - if (verbose) this.logger?.('Validation Output:' + verboseOutput); + 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 ?? '{}')); } diff --git a/test/shell.test.ts b/test/shell.test.ts index 8ff7ec5..c861173 100644 --- a/test/shell.test.ts +++ b/test/shell.test.ts @@ -96,68 +96,68 @@ describe('Shell', () => { }); it('should log commands in dry run mode when verbose', async () => { - const mockLogger = vi.fn(); + const mockDebug = vi.fn(); const shell = new Shell({ dryRun: true, verbose: true, - logger: mockLogger + 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 mockDebug = vi.fn(); const shell = new Shell({ dryRun: true, verbose: false, - logger: mockLogger + logger: { debug: mockDebug } }); await shell.run('echo test'); // dryRun alone logs, so it should still log - expect(mockLogger).toHaveBeenCalledWith('$ echo test'); + 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 mockDebug = vi.fn(); const shell = new Shell({ verbose: true, - logger: mockLogger + 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 mockDebug = vi.fn(); const shell = new Shell({ verbose: false, - logger: mockLogger + 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 mockDebug = vi.fn(); const shell = new Shell({ verbose: true, - logger: mockLogger + logger: { debug: mockDebug } }); await shell.run(['echo', 'hello', 'world']); - expect(mockLogger).toHaveBeenCalledWith('$ echo hello world'); + expect(mockDebug).toHaveBeenCalledWith('$ echo hello world', expect.any(Object)); }); }); @@ -256,39 +256,39 @@ 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 + 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 mockDebug = vi.fn(); const shell = new Shell({ verbose: true, dryRun: true, - logger: mockLogger + 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)); }); }); @@ -344,13 +344,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({ outputMode: 'capture', dryRun: false, verbose: true, throwMode: 'simple', - logger: mockLogger + logger: { + debug: mockDebug, + warn: mockWarn + } }); expect(shell).toBeInstanceOf(Shell); @@ -402,15 +406,15 @@ describe('Shell', () => { }); it('should override verbose at command level', async () => { - const mockLogger = vi.fn(); + const mockDebug = vi.fn(); const shell = new Shell({ verbose: false, - logger: mockLogger + logger: { debug: mockDebug } }); // This command should log because we override verbose to true await shell.run('echo test', { verbose: true }); - expect(mockLogger).toHaveBeenCalledWith('$ echo test'); + expect(mockDebug).toHaveBeenCalledWith('$ echo test', expect.any(Object)); }); it('should override dryRun at command level', async () => { From edfa9576b1def9d10ab7871b147af6ee99ed1ec1 Mon Sep 17 00:00:00 2001 From: Thada Wangthammang Date: Sat, 8 Nov 2025 15:12:38 +0700 Subject: [PATCH 25/30] feat: rename defaultOutputMode to outputMode in Shell class for clarity --- src/shell.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/shell.ts b/src/shell.ts index 1d7f268..2a42ede 100644 --- a/src/shell.ts +++ b/src/shell.ts @@ -199,7 +199,7 @@ export function createShell(options * ``` */ export class Shell { - private defaultOutputMode: OutputMode; + private outputMode: OutputMode; private dryRun: boolean; private verbose: boolean; private throwMode: 'simple' | 'raw'; @@ -241,7 +241,7 @@ export class Shell { * ``` */ constructor(options: ShellOptions = {}) { - this.defaultOutputMode = options.outputMode ?? 'capture'; + this.outputMode = options.outputMode ?? 'capture'; this.dryRun = options.dryRun ?? false; this.verbose = options.verbose ?? false; this.throwMode = options.throwMode ?? 'simple'; @@ -290,7 +290,7 @@ export class Shell { } // Merge command-level overrides with instance defaults - const outputMode = options?.outputMode ?? this.defaultOutputMode; + const outputMode = options?.outputMode ?? this.outputMode; const verbose = options?.verbose ?? this.verbose; const dryRun = options?.dryRun ?? this.dryRun; From c3a6d8969fd814e8ac646b84edc8c90646449d4b Mon Sep 17 00:00:00 2001 From: Thada Wangthammang Date: Sat, 8 Nov 2025 15:19:47 +0700 Subject: [PATCH 26/30] feat: add deepmerge for execaOptions in Shell class and enhance tests for merging behavior --- package.json | 1 + pnpm-lock.yaml | 9 +++++++++ src/shell.ts | 11 +++++++++-- test/shell.test.ts | 33 +++++++++++++++++++++++++++++++++ 4 files changed, 52 insertions(+), 2 deletions(-) diff --git a/package.json b/package.json index 2b44007..2884142 100644 --- a/package.json +++ b/package.json @@ -76,6 +76,7 @@ }, "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 538386b..85ee7f2 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -11,6 +11,9 @@ importers: '@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 @@ -910,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'} @@ -2865,6 +2872,8 @@ snapshots: deep-is@0.1.4: {} + deepmerge@4.3.1: {} + detect-indent@6.1.0: {} dir-glob@3.0.1: diff --git a/src/shell.ts b/src/shell.ts index 2a42ede..a9a30bf 100644 --- a/src/shell.ts +++ b/src/shell.ts @@ -1,6 +1,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'; /** @@ -204,6 +205,7 @@ export class Shell { private verbose: boolean; private throwMode: 'simple' | 'raw'; private logger: ShellLogger; + private execaOptions: ShellExecaOptions; /** * Static factory method (alias for createShell). @@ -245,6 +247,7 @@ export class Shell { this.dryRun = options.dryRun ?? false; this.verbose = options.verbose ?? false; 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)), @@ -301,12 +304,16 @@ export class Shell { }; // Extract our custom properties to avoid passing them to execa - const { outputMode: _, verbose: __, dryRun: ___, ...execaOptions } = options ?? {}; + 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, - ...execaOptions, + ...mergedExecaOptions, }; const logContext: ShellLogContext = { diff --git a/test/shell.test.ts b/test/shell.test.ts index c861173..2632082 100644 --- a/test/shell.test.ts +++ b/test/shell.test.ts @@ -426,6 +426,39 @@ describe('Shell', () => { 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', () => { From f5d9a6ea18a4104c3e08c229759caf48f1a6276e Mon Sep 17 00:00:00 2001 From: Thada Wangthammang Date: Sat, 8 Nov 2025 15:22:53 +0700 Subject: [PATCH 27/30] feat: enhance documentation for execaOptions and logger in Shell class with examples --- src/shell.ts | 145 +++++++++++++++++++++++++++++++++++++++++++++++---- 1 file changed, 135 insertions(+), 10 deletions(-) diff --git a/src/shell.ts b/src/shell.ts index a9a30bf..e93cd24 100644 --- a/src/shell.ts +++ b/src/shell.ts @@ -21,11 +21,35 @@ 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. - * Can be overridden by individual run options. + * + * 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; /** @@ -94,8 +118,31 @@ export interface ShellOptions extends Overr throwMode?: 'simple' | 'raw'; /** - * Optional custom logger function for command output. - * If not provided, defaults to `console.log`. + * 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; } @@ -113,9 +160,27 @@ export type ShellExecaOptions = Omit extends Omit, 'execaOptions'>, @@ -165,7 +230,7 @@ export type RunResult = Throw ex * @param options - Configuration options for the Shell instance * @returns A new Shell instance with the specified configuration * - * @example + * @example Basic usage * ```typescript * const shell = createShell({ * outputMode: 'live', @@ -173,6 +238,32 @@ export type RunResult = Throw ex * }); * 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); @@ -233,7 +324,7 @@ export class Shell { * * @param options - Configuration options for default behavior * - * @example + * @example Basic usage * ```typescript * const shell = new Shell({ * outputMode: 'capture', @@ -241,6 +332,21 @@ export class Shell { * 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.outputMode = options.outputMode ?? 'capture'; @@ -260,26 +366,45 @@ export class Shell { * 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 + * @param options - Optional overrides including throwOnError flag and any execa options. + * Execa options are deep merged with shell-level execaOptions. * * @returns A result object with type-safe stdout/stderr based on output mode and throw mode * - * @example + * @example Throws on error * ```typescript - * // Throws on error * const result = await shell.execute('echo test', { throwOnError: true }); * console.log(result.stdout); // No need to check success + * ``` * - * // Returns error result + * @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 + * }); + * ``` */ public async execute( cmd: string | string[], From 2a943b1517d7c21bd91e4d691f664212f1b9bf5a Mon Sep 17 00:00:00 2001 From: Thada Wangthammang Date: Sat, 8 Nov 2025 16:10:15 +0700 Subject: [PATCH 28/30] feat: add comprehensive tests for Shell class including schema validation and factory function --- test/shell.test.ts | 258 ++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 257 insertions(+), 1 deletion(-) diff --git a/test/shell.test.ts b/test/shell.test.ts index 2632082..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', () => { @@ -479,5 +480,260 @@ describe('Shell', () => { 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'); + } + }); }); }); From 1365d86ad73dd1af28b590bfb083b9925e61107f Mon Sep 17 00:00:00 2001 From: Thada Wangthammang Date: Sat, 8 Nov 2025 16:13:19 +0700 Subject: [PATCH 29/30] feat: enhance README with detailed descriptions for logger and execaOptions, and clarify outputMode usage --- README.md | 92 ++++++++++++++++++++++++++++++++++++++++++++++--------- 1 file changed, 77 insertions(+), 15 deletions(-) diff --git a/README.md b/README.md index 5d8917b..fdc64a9 100644 --- a/README.md +++ b/README.md @@ -21,10 +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 - **Schema validation**: Parse and validate JSON output with Standard Schema (Zod, Valibot, etc.) -- **Custom logger support**: Integrate with your preferred logging solution +- **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 @@ -210,7 +211,7 @@ Factory function to create a new Shell instance with better type inference. import { createShell } from '@thaitype/shell'; // Type inference automatically detects 'live' as default mode -const shell = createShell({ defaultOutputMode: 'live' }); +const shell = createShell({ outputMode: 'live' }); ``` ### `new Shell(options?)` @@ -222,13 +223,13 @@ Alternative constructor for creating a Shell instance. ```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; + verbose?: boolean; // default: false /** * Controls how errors are thrown when a command fails. @@ -237,8 +238,38 @@ 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; } ``` @@ -309,11 +340,19 @@ Low-level method with explicit `throwOnError` control. 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; + + /** 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). @@ -449,11 +488,21 @@ const logger = winston.createLogger({ 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 @@ -461,15 +510,28 @@ await shell.run('npm install'); ```typescript import { createShell } from '@thaitype/shell'; -const shell = createShell(); +// 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 From ed49ae25c97b2df5264a59d17d1dc413252cf360 Mon Sep 17 00:00:00 2001 From: Thada Wangthammang Date: Sat, 8 Nov 2025 16:14:58 +0700 Subject: [PATCH 30/30] docs(changeset): Refactor Shell Option, More Type Safety, Meaningful Option, Support Run with Standard Schema which provide type-safety command line --- .changeset/chubby-cups-sip.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/chubby-cups-sip.md 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