From 71d33bce5a386fa90cd1676eb4bec8003a9c0f2b Mon Sep 17 00:00:00 2001 From: Thada Wangthammang Date: Sun, 9 Nov 2025 20:47:19 +0700 Subject: [PATCH 01/19] feat: add examples for cleaner shell API usage and Azure command parsing --- .agent/task-1/spec.md | 119 ++++++++++++++++++++++++++++++++++++++++++ examples/azure.ts | 19 +++++++ 2 files changed, 138 insertions(+) create mode 100644 .agent/task-1/spec.md create mode 100644 examples/azure.ts diff --git a/.agent/task-1/spec.md b/.agent/task-1/spec.md new file mode 100644 index 0000000..8a9228d --- /dev/null +++ b/.agent/task-1/spec.md @@ -0,0 +1,119 @@ + +## Short form + +- export const shell = createShell({ verbose: true }); + +## cleaner shell api + +- export const $ = shell.createFluentShell(); // in the library code of @thaitype/shell +or user need some custom config, user can do like this: + +```ts +import { createShell } from '@thaitype/shell'; +export const $ = createShell({ verbose: true }).createFluentShell(); + +``` + +for clean shell api usage: + +the user can use the `$` to run shell command directly and get the result in promise. no need to use `.stdout` or `.stderr` to get the result. + +```ts +import { $ } from '@thaitype/shell'; + +const files = await $('ls -la').toLines(); +for(const file of files) { + console.log(`File: ${file}`); +} +``` + +note: `toLines()` is a helper method to split the stdout by new line and return as array of strings. +also, user can use other helper methods like `parse(zodObjectSchema)` to parse the stdout into object using zod schema. + + +const data = await $('echo test'); +await $(`mkdir ${data}`); + + +🔧 ตัวอย่างโค้ด $ แบบ Thenable Handle + +import { execa } from "execa"; + +// แค่ helper ตัวเล็ก จำลอง execa +async function runCommand(cmd: string): Promise { + const { stdout } = await execa("bash", ["-lc", cmd]); + return stdout.trim(); +} + +// พื้นฐานของ CommandHandle +type CommandHandle = PromiseLike & { + toLines(): Promise; + parse(schema: { parse(x: any): T }): Promise; +}; + +// ตัวสร้าง $ +function $(cmd: string): CommandHandle { + // ตัว Promise จริง ๆ ที่จะรันคำสั่ง + const execPromise = runCommand(cmd); + + // สร้าง handle ว่าง ๆ ขึ้นมา + const handle: Partial = {}; + + // ทำให้ await handle ทำงานได้ (thenable) + handle.then = execPromise.then.bind(execPromise); + + // เพิ่ม helper method ต่าง ๆ + handle.toLines = () => execPromise.then((s) => s.split(/\r?\n/)); + handle.parse = (schema) => + execPromise.then((s) => schema.parse(JSON.parse(s))); + + // คืน handle (พร้อม type casting) + return handle as CommandHandle; +} + + +⸻ + +💻 ตัวอย่างการใช้งานจริง + +// ✅ ใช้แบบรับ stdout ตรง ๆ +const name = await $('echo hello'); +console.log('Name:', name); // -> hello + +// ✅ ใช้ helper แปลงเป็น array ของบรรทัด +const files = await $('ls -la').toLines(); +for (const file of files) { + console.log('File:', file); +} + +// ✅ ใช้ parse() กับ Zod schema +import { z } from "zod"; + +const UserSchema = z.object({ + login: z.string(), + id: z.number(), +}); + +const user = await $('gh api /user').parse(UserSchema); +console.log('User login:', user.login); + + +⸻ + +🧠 สรุปแนวคิด + +ฟีเจอร์ ทำงานอย่างไร +await $('cmd') ใช้ .then() ของ handle → คืน stdout (string) +await $('cmd').toLines() เรียก helper ก่อน await → คืน string[] +await $('cmd').parse(schema) เรียก helper ก่อน await → คืน object +await h; await h.toLines() ❌ ไม่ได้ เพราะ handle ถูก resolve แล้ว + + +⸻ + +✅ ข้อดีของดีไซน์นี้ + • ใช้ได้ทั้งสองสไตล์ +→ await $('echo hi') และ await $('ls').toLines() + • TypeScript จับได้หมด (เพราะ PromiseLike) + • ไม่ต้องมี class / instance แยกออกมา + diff --git a/examples/azure.ts b/examples/azure.ts new file mode 100644 index 0000000..1b8a70a --- /dev/null +++ b/examples/azure.ts @@ -0,0 +1,19 @@ +import { createShell } from "src/shell.js"; +import { z } from "zod"; + +const azAccountShowResponse = z.object({ + name: z.string(), + user: z.object({ + name: z.string(), + type: z.string(), + }), +}); + +async function main() { + const shell = createShell(); + const result = await shell.runParse(`az account show`, azAccountShowResponse); + console.log("Current Active username", result.user.name); + console.log("Current Active Subscription Name", result.name); +} + +main(); \ No newline at end of file From f9a291e573edbbbfa854015d89d2f24b82ce3941 Mon Sep 17 00:00:00 2001 From: Thada Wangthammang Date: Sun, 9 Nov 2025 21:19:31 +0700 Subject: [PATCH 02/19] feat: implement fluent shell API with CommandHandle and helper methods --- .agent/task-1/pre-dev-plan.md | 159 +++++++++++++++++ examples/fluentShell.ts | 72 ++++++++ src/shell.ts | 132 ++++++++++++++ test/shell.test.ts | 314 ++++++++++++++++++++++++++++++++++ 4 files changed, 677 insertions(+) create mode 100644 .agent/task-1/pre-dev-plan.md create mode 100644 examples/fluentShell.ts diff --git a/.agent/task-1/pre-dev-plan.md b/.agent/task-1/pre-dev-plan.md new file mode 100644 index 0000000..0cbc75a --- /dev/null +++ b/.agent/task-1/pre-dev-plan.md @@ -0,0 +1,159 @@ +# Pre-Development Plan: Fluent Shell API + +## Overview +Implement a fluent shell API that provides a cleaner, more ergonomic interface for running shell commands with helper methods for common output transformations. + +## Goals +- Create `createFluentShell()` method on Shell class that returns a `$` function +- Implement `CommandHandle` as a `PromiseLike` with helper methods +- Support direct awaiting: `await $('echo test')` returns stdout as string +- Support helper methods before await: `await $('ls').toLines()` returns array of lines +- Support schema parsing: `await $('gh api /user').parse(UserSchema)` with Zod-like schemas + +## Architecture Design + +### 1. CommandHandle Type +```typescript +type CommandHandle = PromiseLike & { + toLines(): Promise; + parse(schema: { parse(x: any): T }): Promise; +}; +``` + +### 2. Fluent Shell Function Signature +```typescript +export type FluentShellFn = (command: string | string[]) => CommandHandle; +``` + +### 3. Shell Class Extension +Add method to Shell class: +```typescript +class Shell { + // ... existing methods ... + + createFluentShell(): FluentShellFn { + // Implementation + } +} +``` + +## Implementation Steps + +### Step 1: Define Types +- Create `CommandHandle` interface in `src/shell.ts` +- Create `FluentShellFn` type alias +- Ensure types are exported from `src/index.ts` + +### Step 2: Implement createFluentShell() Method +Add to Shell class: +- Return a function that accepts command (string | string[]) +- Function returns CommandHandle instance +- Leverage existing `run()` method for execution +- Handle output mode appropriately (should default to 'capture') + +### Step 3: Implement CommandHandle +Create CommandHandle by: +- Execute command via `this.run()` to get a promise +- Create an object that implements `PromiseLike` +- Bind the promise's `.then()` method to make it thenable +- Add helper methods: + - `toLines()`: Split stdout by newlines (`/\r?\n/`) + - `parse(schema)`: Parse stdout as JSON, then validate with schema + +### Step 4: Handle Edge Cases +- Empty stdout handling in `toLines()` +- JSON parse errors in `parse()` +- Propagate errors from underlying command execution +- Consider output modes (only 'capture' makes sense for fluent API) + +### Step 5: Add Tests +Create tests in `test/shell.test.ts`: +- Direct await: `await $('echo test')` returns 'test' +- toLines(): `await $('printf "a\nb\nc"').toLines()` returns ['a', 'b', 'c'] +- parse(): `await $('echo \'{"key":"value"}\'').parse(schema)` returns object +- Error propagation when command fails +- Test with both string and array command formats + +### Step 6: Add Usage Examples +Update or create example files showing: +- Basic usage: `const result = await $('ls -la')` +- Using toLines(): `const files = await $('ls').toLines()` +- Using parse() with Zod schema +- Custom shell config: `createShell({ verbose: true }).createFluentShell()` + +## Technical Considerations + +### PromiseLike Implementation +The key pattern from the spec: +```typescript +const execPromise = this.run(command); +const handle: Partial = {}; + +// Make it thenable +handle.then = execPromise.then.bind(execPromise); + +// Add helpers +handle.toLines = () => execPromise.then(s => s.split(/\r?\n/)); +handle.parse = (schema) => execPromise.then(s => schema.parse(JSON.parse(s))); + +return handle as CommandHandle; +``` + +### Output Mode Behavior +- Fluent API should use 'capture' mode by default +- 'live' mode doesn't make sense (returns null for stdout) +- Could potentially allow override via options + +### Error Handling +- Command execution errors should propagate through the promise chain +- JSON parse errors in `parse()` should also propagate +- Schema validation errors should propagate from schema.parse() + +### Type Safety +- `parse()` should infer return type `T` from schema +- Ensure TypeScript properly recognizes `CommandHandle` as both awaitable and having helper methods +- Return type of direct await should be `string` +- Return type of `toLines()` should be `Promise` +- Return type of `parse(schema)` should be `Promise` + +## Files to Modify + +1. `src/shell.ts` + - Add `CommandHandle` type + - Add `FluentShellFn` type + - Add `createFluentShell()` method to Shell class + +2. `src/index.ts` + - Export new types: `CommandHandle`, `FluentShellFn` + +3. `test/shell.test.ts` + - Add test suite for fluent API + +4. Examples (optional) + - Create or update examples showing fluent API usage + +## Success Criteria + +- ✅ `const $ = createShell().createFluentShell()` works +- ✅ `await $('echo test')` returns 'test' +- ✅ `await $('ls').toLines()` returns array of lines +- ✅ `await $('echo \'{"a":1}\'').parse(schema)` parses JSON +- ✅ All tests pass +- ✅ TypeScript compilation succeeds with no errors +- ✅ Proper error propagation from failed commands +- ✅ Clean API that matches the spec examples + +## Non-Goals (Out of Scope) + +- Modifying existing `run()`, `safeRun()`, or `execute()` methods +- Changing how the Shell class handles stdio/output modes +- Adding new command execution logic (reuse existing methods) +- Supporting chaining of helper methods (not required by spec) +- Supporting options override in fluent API (can be added later if needed) + +## Notes + +- The fluent API is a convenience layer on top of existing Shell functionality +- It should delegate to `run()` for actual execution +- Focus on developer ergonomics and type safety +- Keep implementation simple - it's essentially a wrapper with helper methods diff --git a/examples/fluentShell.ts b/examples/fluentShell.ts new file mode 100644 index 0000000..8f7b99c --- /dev/null +++ b/examples/fluentShell.ts @@ -0,0 +1,72 @@ +/** + * Example demonstrating the Fluent Shell API + * + * The fluent shell API provides a cleaner, more ergonomic way to run shell commands + * with helper methods for common output transformations. + */ + +import { createShell } from '../src/index.js'; +import { z } from 'zod'; + +async function main() { + // Create a fluent shell function + const $ = createShell({ verbose: false }).createFluentShell(); + + console.log('=== Example 1: Direct await ==='); + // Direct await - returns stdout as string + const result = await $('echo hello'); + console.log(`Result: ${result}`); + + console.log('\n=== Example 2: Using toLines() ==='); + // Split output into array of lines + const files = await $('ls -la').toLines(); + console.log('Files:'); + for (const file of files.slice(0, 5)) { + console.log(` - ${file}`); + } + + console.log('\n=== Example 3: Using parse() with Zod ==='); + // Parse JSON output with schema validation + const PackageSchema = z.object({ + name: z.string(), + version: z.string(), + type: z.string().optional(), + }); + + const pkg = await $('cat package.json').parse(PackageSchema); + console.log(`Package: ${pkg.name} v${pkg.version}`); + + console.log('\n=== Example 4: Chaining commands ==='); + // Use result of one command in another + const data = await $('echo test-dir'); + console.log(`Creating directory: ${data}`); + // Note: Using dry run to avoid actually creating the directory + const $dryRun = createShell({ dryRun: true }).createFluentShell(); + await $dryRun(`mkdir ${data}`); + + console.log('\n=== Example 5: Working with multiple lines ==='); + // Process lines individually + const lines = await $('printf "apple\\nbanana\\ncherry"').toLines(); + console.log('Fruits:'); + lines.forEach((fruit, index) => { + console.log(` ${index + 1}. ${fruit}`); + }); + + console.log('\n=== Example 6: Custom schema ==='); + // Use a custom schema object with parse method + const customSchema = { + parse: (data: any) => { + return { + ...data, + uppercase: data.name ? data.name.toUpperCase() : 'UNKNOWN', + }; + }, + }; + + const customResult = await $('echo \'{"name":"fluent-shell"}\'').parse(customSchema); + console.log(`Custom result: ${customResult.uppercase}`); + + console.log('\n✅ All examples completed successfully!'); +} + +main().catch(console.error); diff --git a/src/shell.ts b/src/shell.ts index e93cd24..30cc428 100644 --- a/src/shell.ts +++ b/src/shell.ts @@ -222,6 +222,55 @@ export type RunResult = Throw ex ? StrictResult> : SafeResult>; +/** + * A handle for a command execution that can be awaited directly or have helper methods called on it. + * + * This type implements the PromiseLike interface, allowing it to be awaited directly to get stdout, + * while also providing helper methods for common transformations. + * + * @example Direct await + * ```typescript + * const result = await $('echo hello'); // Returns 'hello' + * ``` + * + * @example Using toLines() + * ```typescript + * const lines = await $('ls -la').toLines(); // Returns array of lines + * ``` + * + * @example Using parse() + * ```typescript + * const data = await $('echo \'{"key":"value"}\'').parse(schema); + * ``` + */ +export type CommandHandle = PromiseLike & { + /** + * Split stdout by newlines and return as array of strings. + * Handles both Unix (\n) and Windows (\r\n) line endings. + * + * @returns Promise resolving to array of lines from stdout + */ + toLines(): Promise; + + /** + * Parse stdout as JSON and validate with the provided schema. + * The schema must have a `parse(data: any): T` method (compatible with Zod and other validators). + * + * @template T - The inferred output type from the schema + * @param schema - A schema object with a parse method (e.g., Zod schema) + * @returns Promise resolving to the parsed and validated data + */ + parse(schema: { parse(x: any): T }): Promise; +}; + +/** + * A function that creates CommandHandle instances for fluent command execution. + * + * @param command - Command to execute, as string or array of arguments + * @returns A CommandHandle that can be awaited or have helper methods called + */ +export type FluentShellFn = (command: string | string[]) => CommandHandle; + /** * Factory function to create a new Shell instance with type inference. * Provides better type safety and convenience compared to using `new Shell()`. @@ -602,4 +651,87 @@ export class Shell { }; } } + + /** + * Create a fluent shell function for cleaner command execution syntax. + * + * Returns a function that can be used to execute commands with a more ergonomic API. + * The returned function creates a CommandHandle that can be awaited directly or have + * helper methods called on it before awaiting. + * + * @returns A fluent shell function that accepts commands and returns CommandHandle instances + * + * @example Basic usage + * ```typescript + * const $ = createShell({ verbose: true }).createFluentShell(); + * + * // Direct await - returns stdout as string + * const result = await $('echo hello'); + * console.log(result); // 'hello' + * ``` + * + * @example Using toLines() + * ```typescript + * const $ = createShell().createFluentShell(); + * + * const files = await $('ls -la').toLines(); + * for (const file of files) { + * console.log(`File: ${file}`); + * } + * ``` + * + * @example Using parse() with Zod + * ```typescript + * import { z } from 'zod'; + * const $ = createShell().createFluentShell(); + * + * const UserSchema = z.object({ + * login: z.string(), + * id: z.number(), + * }); + * + * const user = await $('gh api /user').parse(UserSchema); + * console.log('User login:', user.login); + * ``` + * + * @example Chaining commands + * ```typescript + * const $ = createShell().createFluentShell(); + * + * const data = await $('echo test'); + * await $(`mkdir ${data}`); + * ``` + */ + public createFluentShell(): FluentShellFn { + return (command: string | string[]): CommandHandle => { + // Execute the command and get a promise for the stdout + const execPromise = this.run<'capture'>(command, { outputMode: 'capture' }).then(result => { + // For capture mode, stdout is always a string (or null for live mode, but we force capture here) + return result.stdout ?? ''; + }); + + // Create the handle object + const handle: Partial = {}; + + // Make it thenable by binding the promise's then method + handle.then = execPromise.then.bind(execPromise); + + // Add helper method to split stdout into lines + handle.toLines = () => + execPromise.then(stdout => { + if (!stdout) return []; + return stdout.split(/\r?\n/); + }); + + // Add helper method to parse JSON and validate with schema + handle.parse = (schema: { parse(x: any): T }): Promise => { + return execPromise.then(stdout => { + const parsed = JSON.parse(stdout); + return schema.parse(parsed); + }); + }; + + return handle as CommandHandle; + }; + } } diff --git a/test/shell.test.ts b/test/shell.test.ts index 007c174..1584e54 100644 --- a/test/shell.test.ts +++ b/test/shell.test.ts @@ -736,4 +736,318 @@ describe('Shell', () => { } }); }); + + describe('Fluent Shell API - createFluentShell', () => { + it('should create a fluent shell function', () => { + const shell = createShell(); + const $ = shell.createFluentShell(); + + expect(typeof $).toBe('function'); + }); + + it('should execute command and return stdout when awaited directly', async () => { + const $ = createShell().createFluentShell(); + + const result = await $('echo hello'); + + expect(result).toBe('hello'); + }); + + it('should handle command as array', async () => { + const $ = createShell().createFluentShell(); + + const result = await $(['echo', 'world']); + + expect(result).toBe('world'); + }); + + it('should return empty string for commands with no output', async () => { + const $ = createShell().createFluentShell(); + + const result = await $('true'); + + expect(result).toBe(''); + }); + + it('should work with shell configuration', async () => { + const $ = createShell({ verbose: true }).createFluentShell(); + + const result = await $('echo test'); + + expect(result).toBe('test'); + }); + + it('should propagate errors when command fails', async () => { + const $ = createShell().createFluentShell(); + + await expect($('sh -c "exit 1"')).rejects.toThrow(); + }); + }); + + describe('Fluent Shell API - toLines()', () => { + it('should split output into array of lines', async () => { + const $ = createShell().createFluentShell(); + + const lines = await $('printf "line1\\nline2\\nline3"').toLines(); + + expect(lines).toEqual(['line1', 'line2', 'line3']); + }); + + it('should handle Windows line endings', async () => { + const $ = createShell().createFluentShell(); + + const lines = await $('printf "line1\\r\\nline2\\r\\nline3"').toLines(); + + expect(lines).toEqual(['line1', 'line2', 'line3']); + }); + + it('should return empty array for commands with no output', async () => { + const $ = createShell().createFluentShell(); + + const lines = await $('true').toLines(); + + expect(lines).toEqual([]); + }); + + it('should handle single line output', async () => { + const $ = createShell().createFluentShell(); + + const lines = await $('echo single').toLines(); + + expect(lines).toEqual(['single']); + }); + + it('should handle output with trailing newline', async () => { + const $ = createShell().createFluentShell(); + + const lines = await $('printf "a\\nb\\nc\\n"').toLines(); + + expect(lines.length).toBeGreaterThan(0); + expect(lines).toContain('a'); + expect(lines).toContain('b'); + expect(lines).toContain('c'); + }); + + it('should propagate errors when command fails', async () => { + const $ = createShell().createFluentShell(); + + await expect($('sh -c "exit 1"').toLines()).rejects.toThrow(); + }); + }); + + describe('Fluent Shell API - parse()', () => { + it('should parse JSON output with Zod schema', async () => { + const $ = createShell().createFluentShell(); + const schema = z.object({ + name: z.string(), + id: z.number(), + }); + + const result = await $('echo \'{"name":"test","id":42}\'').parse(schema); + + expect(result.name).toBe('test'); + expect(result.id).toBe(42); + }); + + it('should work with nested objects', async () => { + const $ = createShell().createFluentShell(); + const schema = z.object({ + user: z.object({ + name: z.string(), + email: z.string(), + }), + }); + + const result = await $('echo \'{"user":{"name":"John","email":"john@example.com"}}\'').parse(schema); + + expect(result.user.name).toBe('John'); + expect(result.user.email).toBe('john@example.com'); + }); + + it('should work with arrays', async () => { + const $ = createShell().createFluentShell(); + const schema = z.object({ + items: z.array(z.string()), + }); + + const result = await $('echo \'{"items":["a","b","c"]}\'').parse(schema); + + expect(result.items).toEqual(['a', 'b', 'c']); + }); + + it('should throw error when JSON is invalid', async () => { + const $ = createShell().createFluentShell(); + const schema = z.object({ value: z.string() }); + + await expect( + $('echo "not valid json"').parse(schema) + ).rejects.toThrow(); + }); + + it('should throw error when schema validation fails', async () => { + const $ = createShell().createFluentShell(); + const schema = z.object({ + name: z.string(), + count: z.number(), + }); + + await expect( + $('echo \'{"name":"test","count":"not-a-number"}\'').parse(schema) + ).rejects.toThrow(); + }); + + it('should work with custom schema objects', async () => { + const $ = createShell().createFluentShell(); + + // Custom schema with parse method + const customSchema = { + parse: (data: any) => { + if (typeof data.value === 'string') { + return { value: data.value.toUpperCase() }; + } + throw new Error('Invalid schema'); + } + }; + + const result = await $('echo \'{"value":"hello"}\'').parse(customSchema); + + expect(result.value).toBe('HELLO'); + }); + + it('should propagate command execution errors', async () => { + const $ = createShell().createFluentShell(); + const schema = z.object({ value: z.string() }); + + await expect($('sh -c "exit 1"').parse(schema)).rejects.toThrow(); + }); + }); + + describe('Fluent Shell API - Chaining Commands', () => { + it('should allow chaining commands using results', async () => { + const $ = createShell().createFluentShell(); + + const data = await $('echo test'); + const result = await $(`echo ${data}`); + + expect(result).toBe('test'); + }); + + it('should work with toLines() results', async () => { + const $ = createShell().createFluentShell(); + + const lines = await $('printf "a\\nb\\nc"').toLines(); + + expect(lines).toHaveLength(3); + + // Use first line in another command + const result = await $(`echo ${lines[0]}`); + expect(result).toBe('a'); + }); + + it('should work with parse() results', async () => { + const $ = createShell().createFluentShell(); + const schema = z.object({ + dir: z.string(), + }); + + const config = await $('echo \'{"dir":"tmp"}\'').parse(schema); + const result = await $(`echo ${config.dir}`); + + expect(result).toBe('tmp'); + }); + }); + + describe('Fluent Shell API - Integration with Shell Options', () => { + it('should respect verbose mode', async () => { + const mockDebug = vi.fn(); + const shell = createShell({ + verbose: true, + logger: { debug: mockDebug } + }); + const $ = shell.createFluentShell(); + + await $('echo test'); + + expect(mockDebug).toHaveBeenCalledWith('$ echo test', expect.any(Object)); + }); + + it('should respect dry run mode', async () => { + const $ = createShell({ dryRun: true }).createFluentShell(); + + // This would fail if executed, but should succeed in dry run + const result = await $('sh -c "exit 1"'); + + expect(result).toBe(''); + }); + + it('should respect throwMode setting', async () => { + const $ = createShell({ throwMode: 'simple' }).createFluentShell(); + + try { + await $('sh -c "exit 1"'); + expect.fail('Should have thrown'); + } catch (error) { + const message = (error as Error).message; + expect(message).toContain('Command failed'); + expect(message).toContain('Exit code: 1'); + } + }); + + it('should always use capture mode', async () => { + // Even with live mode as default, fluent shell should capture + const $ = createShell({ outputMode: 'live' }).createFluentShell(); + + const result = await $('echo captured'); + + // Should still capture output despite shell default being live + expect(result).toBe('captured'); + }); + }); + + describe('Fluent Shell API - Edge Cases', () => { + it('should handle empty string output', async () => { + const $ = createShell().createFluentShell(); + + const result = await $('echo -n ""'); + + expect(result).toBe(''); + }); + + it('should handle commands with special characters', async () => { + const $ = createShell().createFluentShell(); + + const result = await $('echo "hello $world"'); + + expect(result).toContain('hello'); + }); + + it('should handle multiline output', async () => { + const $ = createShell().createFluentShell(); + + const result = await $('printf "line1\\nline2"'); + + expect(result).toContain('line1'); + expect(result).toContain('line2'); + }); + + it('should not allow awaiting twice', async () => { + const $ = createShell().createFluentShell(); + + const handle = $('echo test'); + const result1 = await handle; + const result2 = await handle; // Awaiting the same promise again + + expect(result1).toBe('test'); + expect(result2).toBe('test'); + }); + + it('should handle long output', async () => { + const $ = createShell().createFluentShell(); + + // Generate 100 lines + const result = await $('seq 1 100').toLines(); + + expect(result.length).toBeGreaterThanOrEqual(100); + }); + }); }); From 8b152fb2395e4d8a2962a763e3ec2d504dc8bc0e Mon Sep 17 00:00:00 2001 From: Thada Wangthammang Date: Sun, 9 Nov 2025 21:58:09 +0700 Subject: [PATCH 03/19] feat: add design specifications for lazy execution and non-throwable finalizer in Fluent API --- .agent/design-goal.md | 94 ++++++++++++++++++++++++++++++++++++ .agent/task-2/spec.md | 110 ++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 204 insertions(+) create mode 100644 .agent/design-goal.md create mode 100644 .agent/task-2/spec.md diff --git a/.agent/design-goal.md b/.agent/design-goal.md new file mode 100644 index 0000000..c1bcb61 --- /dev/null +++ b/.agent/design-goal.md @@ -0,0 +1,94 @@ +เยี่ยมครับ — ถ้าคุณเลือกแนวทาง .result() เป็นแกนหลักของ Fluent API +เราจะออกแบบ “ตระกูล methods” ที่สอดคล้องกันได้หลายแบบ โดยยึดหลักว่าแต่ละเมธอดคือ finalizer — ตัวปิดท้ายของ $() ที่ “รันจริง + คืนผลในรูปแบบหนึ่ง” (ไม่ใช่ chain ต่อ) + +⸻ + +🌱 ตระกูลเมธอดที่ต่อยอดได้จาก .result() + +🔹 กลุ่ม “ผลลัพธ์พื้นฐาน” (Basic outcomes) + +Method Return type แนวคิด +.result() Promise คืนผลรวมพื้นฐาน stdout/stderr/exitCode +.stdout() Promise คืนเฉพาะ stdout โดยตรง +.stderr() Promise คืนเฉพาะ stderr +.exitCode() Promise คืน exit code อย่างเดียว +.success() Promise คืนสถานะสำเร็จ (exitCode === 0) + + +⸻ + +🔹 กลุ่ม “ผลลัพธ์เชิงข้อมูล” (Structured output) + +Method Return type แนวคิด +.json() Promise แปลง stdout เป็น JSON +.parse(schema) Promise แปลง stdout ด้วย Zod/Standard Schema +.lines() Promise split stdout เป็นบรรทัด +.csv() Promise[]> parse stdout เป็น CSV +.table() Promise[]> แปลง stdout เป็น table-like data (smart detect) + + +⸻ + +🔹 กลุ่ม “ผลลัพธ์เชิงระบบ” (System & metadata) + +Method Return type แนวคิด +.timing() { durationMs: number, exitCode: number } คืนข้อมูลเวลารัน + สถานะ +.env() Promise> แปลง stdout เป็น key=value env +.files() Promise ใช้กับ ls หรือ find-like command +.pid() Promise คืน process id (ถ้ามี) + + +⸻ + +🔹 กลุ่ม “ผลลัพธ์เชิง functional / effectful” + +Method Return type แนวคิด +.effect() Promise คืนผลในรูปแบบ functional (Result) +.either() Promise> สไตล์ FP สำหรับ error-safe pipeline +.tap(fn) Promise ทำ side effect เช่น log โดยไม่เปลี่ยนผลลัพธ์ +.map(fn) Promise แปลง stdout ตามฟังก์ชัน + + +⸻ + +🔹 กลุ่ม “การประมวลผลต่อเนื่อง” (Streaming / Interactive) + +Method Return type แนวคิด +.stream() ReadableStream stream stdout แบบต่อเนื่อง +.pipeTo(target) Promise pipe stdout ไป process/file +.observe(callback) Promise รับ event ระหว่างรัน +.interactive() Promise เปิด stdin/stdout inherit เต็มรูปแบบ + + +⸻ + +🧭 แนว grouping ภายหลัง + +คุณสามารถใช้แนว grouping ตาม prefix เพื่อจัดหมวด API ให้สวยได้ภายหลัง เช่น + • Data-oriented → .json(), .csv(), .lines(), .table() + • Result-oriented → .result(), .effect(), .either() + • System-oriented → .timing(), .pid(), .exitCode() + • Stream-oriented → .stream(), .pipeTo(), .observe() + +⸻ + +✨ ตัวอย่างภาพรวมการใช้งานในอนาคต + +const users = await $`cat users.json`.parse(UserArraySchema); +const files = await $`ls -1`.lines(); +const log = await $`npm test`.result(); +const time = await $`sleep 1`.timing(); +const ok = await $`exit 0`.success(); + +await $`ls -la`.pipeTo(process.stdout); +await $`echo hi`.tap(console.log); + + +⸻ + +สรุป: + +✅ ใช้ .result() เป็น core finalizer ดีมาก เพราะเปิดทางให้คุณออกแบบ “families ของ finalizers” ต่อไปได้หลากหลาย — +โดยรักษาแนวคิดเดียวกันว่า “ทุกเมธอดคือจุดปิดของ shell expression ที่คืนค่าในรูปแบบหนึ่งของผลลัพธ์” + +นั่นทำให้ Fluent API ของคุณทั้ง อ่านลื่น, ขยายได้, และ รักษา semantics ของคำว่า “result” ได้ตรงมาก. \ No newline at end of file diff --git a/.agent/task-2/spec.md b/.agent/task-2/spec.md new file mode 100644 index 0000000..ec2ca37 --- /dev/null +++ b/.agent/task-2/spec.md @@ -0,0 +1,110 @@ +นี่คือ Design Spec สำหรับ $ ที่รองรับทั้ง tagged template และการเรียกแบบฟังก์ชัน $(…) โดยคง lazy execution และมี .result() เป็น non-throwable finalizer + +เป้าหมาย + • เรียกใช้สั้นแบบ DSL: + • await $echo hi`` → คืน stdout (throwable) + • await $exit 2.result() → คืน { success, stdout, stderr, exitCode } (non-throwable) + • รองรับสองรูปแบบอินพุต: tagged template และ function call (string หรือ string[]) + • เป็น lazy จริง: ค่อยเริ่มรันเมื่อถูก “consume” ครั้งแรก (เช่น await, .result(), .toLines(), .parse()) + +พฤติกรรมหลัก + • $ คืนค่าเป็น Thenable Command Handle (อ็อบเจ็กต์ที่ await ได้ + มีเมธอด helper) + • เส้นทาง throwable: await $(…) หรือ await $…`` → ถ้า exit code ≠ 0 ให้โยนข้อผิดพลาด + • เส้นทาง non-throwable: await $(…).result() → คืนอ็อบเจ็กต์ผลแบบปลอดภัย + • ทุกเมธอด/การ await ภายใต้ handle เดียวกัน แชร์ execution เดียว (memoize) + +รูปแบบอินพุตที่รองรับ + • Tagged template: + +$`echo ${name} and ${age}` + +ส่วน literal ใช้เป็นโครงคำสั่ง, ส่วน interpolation ทุกตัวถือเป็น “อาร์กิวเมนต์เดี่ยว” (ภายหลังค่อยเติม escaping/argv-safe) + + • Function call: + • $(string) → ตัวช่วยง่าย, เหมาะข้อความคำสั่งดิบ + • $(string[]) → คุม argv ตรง ๆ เช่น $(["bash","-lc","ls -la"]) + +สัญญา (Contract) ของผลลัพธ์ + • Thenable Command Handle: PromiseLike & Helpers + • await handle → stdout: string (throwable) + • handle.result(): Promise → non-throwable + • handle.toLines(): Promise → แปลง stdout เป็นอาเรย์บรรทัด + • handle.parse(schema): Promise → แปลง stdout ด้วยสคีมา (throw เมื่อ invalid) + • Result (non-throwable): + +type Result = { + success: boolean; // exitCode === 0 + stdout: string; + stderr: string; + exitCode?: number; // undefined เมื่อ process ไม่ได้เริ่ม/ล้มก่อน start +} + + + +การออกแบบภายใน (สำคัญต่อความ “lazy”) + • ตัว $ ไม่รันทันที; จะสร้าง executor แบบ deferred: + • start(): Promise สร้างขึ้นครั้งแรกที่ถูกเรียก แล้ว memoize + • ทุกเมธอด (then, result, toLines, parse) จะเรียก start() ก่อนใช้งาน + • การทำงานของ then: + • เรียก start() → ได้ Promise + • ถ้า result.success === false ให้สร้างและ throw ข้อผิดพลาด (รูปแบบข้อความตาม throwMode) + • ถ้า success === true ส่ง result.stdout เข้าสู่ onFulfilled + • การทำงานของ .result()/.toLines()/.parse(): + • เรียก start() แล้ว map ผล โดย ไม่ เริ่ม process ซ้ำ + +การแตกโอเวอร์โหลด (Type Signature โดยย่อ) + +// 1) tagged template +function $( + parts: TemplateStringsArray, ...vals: any[] +): CommandHandle; + +// 2) string +function $(cmd: string): CommandHandle; + +// 3) argv +function $(argv: string[]): CommandHandle; + +// Thenable handle +type CommandHandle = PromiseLike & { + result(): Promise; + toLines(): Promise; + parse(schema: { parse(x: unknown): T }): Promise; +}; + +Error Model + • เส้นทาง await $… → throwable by default + • ถ้าอยากไม่โยน ให้ใช้ .result() เสมอ + • รูปแบบข้อความข้อผิดพลาดควบคุมได้ด้วย throwMode: "simple" | "raw" ที่ระดับอินสแตนซ์ Shell/$ + • ในอนาคตเพิ่ม .result({ includeTiming: true }) ได้โดยไม่กระทบสัญญาหลัก + +ตัวอย่างการใช้งาน + +// Throwable (สั้นสุด) +const who = await $`whoami`; // 'mildr' + +// Non-throwable +const r = await $`exit 2`.result(); // { success:false, exitCode:2, stdout:'', stderr:'...' } +if (!r.success) console.warn(r.exitCode, r.stderr); + +// Helper +const files = await $`ls -la`.toLines(); +const user = await $`gh api /user`.parse(UserSchema); + +// Function call forms +const text = await $('echo hello'); +const list = await $(['bash','-lc','printf "a\nb"']).toLines(); + +กติกาพิเศษ (ภายหลังค่อยเติม) + • Escaping & argv-safe: interpolation ใน template จะถูกแปลงเป็นอาร์กิวเมนต์เดี่ยวเสมอ (กันแตกคำ) + • Pipeline/Redirection: แยกเป็นโหมด shell ชัดเจน เช่น $sh\ls | grep x`หรือ.withShell()` + • Interactive stdin: auto-inherit เมื่อ .live() หรือส่ง { stdin: 'inherit' } รายคำสั่งได้ + • Memoization: handle เดียวกัน ถูก consume หลายแบบต้องอาศัยผลจาก execution เดียว + +เหตุผลที่ดีไซน์นี้ตอบโจทย์ + • API เรียบ สั้น ใช้ได้ทั้งสองสไตล์ โดยยังคง lazy จริง + • ไม่บังคับผู้ใช้เลือกระหว่าง template vs function; ทั้งสองทางวิ่งเข้า กลไกเดียวกัน + • แยก throwable vs non-throwable แบบชัดเจนผ่าน await กับ .result() + • ขยายต่อได้ง่าย: .json(), .safeParse(), .csv(), .stream(), .timing() โดยไม่ทำลายสัญญาหลัก + +สรุป: ใช้ $ เป็น thenable handle ที่รองรับ tagged template และ $(…) พร้อม .result() เป็น non-throwable finalizer จะได้ API ที่สวย, lazy, และขยายได้ในอนาคต. \ No newline at end of file From 2e05220d55e3028b452076e6d21a3962873efaee Mon Sep 17 00:00:00 2001 From: Thada Wangthammang Date: Sun, 9 Nov 2025 22:06:16 +0700 Subject: [PATCH 04/19] feat: rename createFluentShell() to asFluent() for consistency in Fluent Shell API --- .agent/task-1/pre-dev-plan.md | 12 +++---- .agent/task-1/spec.md | 4 +-- examples/fluentShell.ts | 4 +-- src/shell.ts | 10 +++--- test/shell.test.ts | 64 +++++++++++++++++------------------ 5 files changed, 47 insertions(+), 47 deletions(-) diff --git a/.agent/task-1/pre-dev-plan.md b/.agent/task-1/pre-dev-plan.md index 0cbc75a..15eb2d0 100644 --- a/.agent/task-1/pre-dev-plan.md +++ b/.agent/task-1/pre-dev-plan.md @@ -4,7 +4,7 @@ Implement a fluent shell API that provides a cleaner, more ergonomic interface for running shell commands with helper methods for common output transformations. ## Goals -- Create `createFluentShell()` method on Shell class that returns a `$` function +- Create `asFluent()` method on Shell class that returns a `$` function - Implement `CommandHandle` as a `PromiseLike` with helper methods - Support direct awaiting: `await $('echo test')` returns stdout as string - Support helper methods before await: `await $('ls').toLines()` returns array of lines @@ -31,7 +31,7 @@ Add method to Shell class: class Shell { // ... existing methods ... - createFluentShell(): FluentShellFn { + asFluent(): FluentShellFn { // Implementation } } @@ -44,7 +44,7 @@ class Shell { - Create `FluentShellFn` type alias - Ensure types are exported from `src/index.ts` -### Step 2: Implement createFluentShell() Method +### Step 2: Implement asFluent() Method Add to Shell class: - Return a function that accepts command (string | string[]) - Function returns CommandHandle instance @@ -79,7 +79,7 @@ Update or create example files showing: - Basic usage: `const result = await $('ls -la')` - Using toLines(): `const files = await $('ls').toLines()` - Using parse() with Zod schema -- Custom shell config: `createShell({ verbose: true }).createFluentShell()` +- Custom shell config: `createShell({ verbose: true }).asFluent()` ## Technical Considerations @@ -121,7 +121,7 @@ return handle as CommandHandle; 1. `src/shell.ts` - Add `CommandHandle` type - Add `FluentShellFn` type - - Add `createFluentShell()` method to Shell class + - Add `asFluent()` method to Shell class 2. `src/index.ts` - Export new types: `CommandHandle`, `FluentShellFn` @@ -134,7 +134,7 @@ return handle as CommandHandle; ## Success Criteria -- ✅ `const $ = createShell().createFluentShell()` works +- ✅ `const $ = createShell().asFluent()` works - ✅ `await $('echo test')` returns 'test' - ✅ `await $('ls').toLines()` returns array of lines - ✅ `await $('echo \'{"a":1}\'').parse(schema)` parses JSON diff --git a/.agent/task-1/spec.md b/.agent/task-1/spec.md index 8a9228d..5e025cd 100644 --- a/.agent/task-1/spec.md +++ b/.agent/task-1/spec.md @@ -5,12 +5,12 @@ ## cleaner shell api -- export const $ = shell.createFluentShell(); // in the library code of @thaitype/shell +- export const $ = shell.asFluent(); // in the library code of @thaitype/shell or user need some custom config, user can do like this: ```ts import { createShell } from '@thaitype/shell'; -export const $ = createShell({ verbose: true }).createFluentShell(); +export const $ = createShell({ verbose: true }).asFluent(); ``` diff --git a/examples/fluentShell.ts b/examples/fluentShell.ts index 8f7b99c..e6e5e19 100644 --- a/examples/fluentShell.ts +++ b/examples/fluentShell.ts @@ -10,7 +10,7 @@ import { z } from 'zod'; async function main() { // Create a fluent shell function - const $ = createShell({ verbose: false }).createFluentShell(); + const $ = createShell({ verbose: false }).asFluent(); console.log('=== Example 1: Direct await ==='); // Direct await - returns stdout as string @@ -41,7 +41,7 @@ async function main() { const data = await $('echo test-dir'); console.log(`Creating directory: ${data}`); // Note: Using dry run to avoid actually creating the directory - const $dryRun = createShell({ dryRun: true }).createFluentShell(); + const $dryRun = createShell({ dryRun: true }).asFluent(); await $dryRun(`mkdir ${data}`); console.log('\n=== Example 5: Working with multiple lines ==='); diff --git a/src/shell.ts b/src/shell.ts index 30cc428..a14fb00 100644 --- a/src/shell.ts +++ b/src/shell.ts @@ -663,7 +663,7 @@ export class Shell { * * @example Basic usage * ```typescript - * const $ = createShell({ verbose: true }).createFluentShell(); + * const $ = createShell({ verbose: true }).asFluent(); * * // Direct await - returns stdout as string * const result = await $('echo hello'); @@ -672,7 +672,7 @@ export class Shell { * * @example Using toLines() * ```typescript - * const $ = createShell().createFluentShell(); + * const $ = createShell().asFluent(); * * const files = await $('ls -la').toLines(); * for (const file of files) { @@ -683,7 +683,7 @@ export class Shell { * @example Using parse() with Zod * ```typescript * import { z } from 'zod'; - * const $ = createShell().createFluentShell(); + * const $ = createShell().asFluent(); * * const UserSchema = z.object({ * login: z.string(), @@ -696,13 +696,13 @@ export class Shell { * * @example Chaining commands * ```typescript - * const $ = createShell().createFluentShell(); + * const $ = createShell().asFluent(); * * const data = await $('echo test'); * await $(`mkdir ${data}`); * ``` */ - public createFluentShell(): FluentShellFn { + public asFluent(): FluentShellFn { return (command: string | string[]): CommandHandle => { // Execute the command and get a promise for the stdout const execPromise = this.run<'capture'>(command, { outputMode: 'capture' }).then(result => { diff --git a/test/shell.test.ts b/test/shell.test.ts index 1584e54..9414c3b 100644 --- a/test/shell.test.ts +++ b/test/shell.test.ts @@ -737,16 +737,16 @@ describe('Shell', () => { }); }); - describe('Fluent Shell API - createFluentShell', () => { + describe('Fluent Shell API - asFluent', () => { it('should create a fluent shell function', () => { const shell = createShell(); - const $ = shell.createFluentShell(); + const $ = shell.asFluent(); expect(typeof $).toBe('function'); }); it('should execute command and return stdout when awaited directly', async () => { - const $ = createShell().createFluentShell(); + const $ = createShell().asFluent(); const result = await $('echo hello'); @@ -754,7 +754,7 @@ describe('Shell', () => { }); it('should handle command as array', async () => { - const $ = createShell().createFluentShell(); + const $ = createShell().asFluent(); const result = await $(['echo', 'world']); @@ -762,7 +762,7 @@ describe('Shell', () => { }); it('should return empty string for commands with no output', async () => { - const $ = createShell().createFluentShell(); + const $ = createShell().asFluent(); const result = await $('true'); @@ -770,7 +770,7 @@ describe('Shell', () => { }); it('should work with shell configuration', async () => { - const $ = createShell({ verbose: true }).createFluentShell(); + const $ = createShell({ verbose: true }).asFluent(); const result = await $('echo test'); @@ -778,7 +778,7 @@ describe('Shell', () => { }); it('should propagate errors when command fails', async () => { - const $ = createShell().createFluentShell(); + const $ = createShell().asFluent(); await expect($('sh -c "exit 1"')).rejects.toThrow(); }); @@ -786,7 +786,7 @@ describe('Shell', () => { describe('Fluent Shell API - toLines()', () => { it('should split output into array of lines', async () => { - const $ = createShell().createFluentShell(); + const $ = createShell().asFluent(); const lines = await $('printf "line1\\nline2\\nline3"').toLines(); @@ -794,7 +794,7 @@ describe('Shell', () => { }); it('should handle Windows line endings', async () => { - const $ = createShell().createFluentShell(); + const $ = createShell().asFluent(); const lines = await $('printf "line1\\r\\nline2\\r\\nline3"').toLines(); @@ -802,7 +802,7 @@ describe('Shell', () => { }); it('should return empty array for commands with no output', async () => { - const $ = createShell().createFluentShell(); + const $ = createShell().asFluent(); const lines = await $('true').toLines(); @@ -810,7 +810,7 @@ describe('Shell', () => { }); it('should handle single line output', async () => { - const $ = createShell().createFluentShell(); + const $ = createShell().asFluent(); const lines = await $('echo single').toLines(); @@ -818,7 +818,7 @@ describe('Shell', () => { }); it('should handle output with trailing newline', async () => { - const $ = createShell().createFluentShell(); + const $ = createShell().asFluent(); const lines = await $('printf "a\\nb\\nc\\n"').toLines(); @@ -829,7 +829,7 @@ describe('Shell', () => { }); it('should propagate errors when command fails', async () => { - const $ = createShell().createFluentShell(); + const $ = createShell().asFluent(); await expect($('sh -c "exit 1"').toLines()).rejects.toThrow(); }); @@ -837,7 +837,7 @@ describe('Shell', () => { describe('Fluent Shell API - parse()', () => { it('should parse JSON output with Zod schema', async () => { - const $ = createShell().createFluentShell(); + const $ = createShell().asFluent(); const schema = z.object({ name: z.string(), id: z.number(), @@ -850,7 +850,7 @@ describe('Shell', () => { }); it('should work with nested objects', async () => { - const $ = createShell().createFluentShell(); + const $ = createShell().asFluent(); const schema = z.object({ user: z.object({ name: z.string(), @@ -865,7 +865,7 @@ describe('Shell', () => { }); it('should work with arrays', async () => { - const $ = createShell().createFluentShell(); + const $ = createShell().asFluent(); const schema = z.object({ items: z.array(z.string()), }); @@ -876,7 +876,7 @@ describe('Shell', () => { }); it('should throw error when JSON is invalid', async () => { - const $ = createShell().createFluentShell(); + const $ = createShell().asFluent(); const schema = z.object({ value: z.string() }); await expect( @@ -885,7 +885,7 @@ describe('Shell', () => { }); it('should throw error when schema validation fails', async () => { - const $ = createShell().createFluentShell(); + const $ = createShell().asFluent(); const schema = z.object({ name: z.string(), count: z.number(), @@ -897,7 +897,7 @@ describe('Shell', () => { }); it('should work with custom schema objects', async () => { - const $ = createShell().createFluentShell(); + const $ = createShell().asFluent(); // Custom schema with parse method const customSchema = { @@ -915,7 +915,7 @@ describe('Shell', () => { }); it('should propagate command execution errors', async () => { - const $ = createShell().createFluentShell(); + const $ = createShell().asFluent(); const schema = z.object({ value: z.string() }); await expect($('sh -c "exit 1"').parse(schema)).rejects.toThrow(); @@ -924,7 +924,7 @@ describe('Shell', () => { describe('Fluent Shell API - Chaining Commands', () => { it('should allow chaining commands using results', async () => { - const $ = createShell().createFluentShell(); + const $ = createShell().asFluent(); const data = await $('echo test'); const result = await $(`echo ${data}`); @@ -933,7 +933,7 @@ describe('Shell', () => { }); it('should work with toLines() results', async () => { - const $ = createShell().createFluentShell(); + const $ = createShell().asFluent(); const lines = await $('printf "a\\nb\\nc"').toLines(); @@ -945,7 +945,7 @@ describe('Shell', () => { }); it('should work with parse() results', async () => { - const $ = createShell().createFluentShell(); + const $ = createShell().asFluent(); const schema = z.object({ dir: z.string(), }); @@ -964,7 +964,7 @@ describe('Shell', () => { verbose: true, logger: { debug: mockDebug } }); - const $ = shell.createFluentShell(); + const $ = shell.asFluent(); await $('echo test'); @@ -972,7 +972,7 @@ describe('Shell', () => { }); it('should respect dry run mode', async () => { - const $ = createShell({ dryRun: true }).createFluentShell(); + const $ = createShell({ dryRun: true }).asFluent(); // This would fail if executed, but should succeed in dry run const result = await $('sh -c "exit 1"'); @@ -981,7 +981,7 @@ describe('Shell', () => { }); it('should respect throwMode setting', async () => { - const $ = createShell({ throwMode: 'simple' }).createFluentShell(); + const $ = createShell({ throwMode: 'simple' }).asFluent(); try { await $('sh -c "exit 1"'); @@ -995,7 +995,7 @@ describe('Shell', () => { it('should always use capture mode', async () => { // Even with live mode as default, fluent shell should capture - const $ = createShell({ outputMode: 'live' }).createFluentShell(); + const $ = createShell({ outputMode: 'live' }).asFluent(); const result = await $('echo captured'); @@ -1006,7 +1006,7 @@ describe('Shell', () => { describe('Fluent Shell API - Edge Cases', () => { it('should handle empty string output', async () => { - const $ = createShell().createFluentShell(); + const $ = createShell().asFluent(); const result = await $('echo -n ""'); @@ -1014,7 +1014,7 @@ describe('Shell', () => { }); it('should handle commands with special characters', async () => { - const $ = createShell().createFluentShell(); + const $ = createShell().asFluent(); const result = await $('echo "hello $world"'); @@ -1022,7 +1022,7 @@ describe('Shell', () => { }); it('should handle multiline output', async () => { - const $ = createShell().createFluentShell(); + const $ = createShell().asFluent(); const result = await $('printf "line1\\nline2"'); @@ -1031,7 +1031,7 @@ describe('Shell', () => { }); it('should not allow awaiting twice', async () => { - const $ = createShell().createFluentShell(); + const $ = createShell().asFluent(); const handle = $('echo test'); const result1 = await handle; @@ -1042,7 +1042,7 @@ describe('Shell', () => { }); it('should handle long output', async () => { - const $ = createShell().createFluentShell(); + const $ = createShell().asFluent(); // Generate 100 lines const result = await $('seq 1 100').toLines(); From bc1d65775e142473b0d73c7b51503df4a1e5008f Mon Sep 17 00:00:00 2001 From: Thada Wangthammang Date: Sun, 9 Nov 2025 22:30:13 +0700 Subject: [PATCH 05/19] docs: enhance asFluent() to support tagged templates, lazy execution, and memoization --- .agent/task-2/pre-dev-plan.md | 489 ++++++++++++++++++++++++++++++++++ 1 file changed, 489 insertions(+) create mode 100644 .agent/task-2/pre-dev-plan.md diff --git a/.agent/task-2/pre-dev-plan.md b/.agent/task-2/pre-dev-plan.md new file mode 100644 index 0000000..9cb4fa9 --- /dev/null +++ b/.agent/task-2/pre-dev-plan.md @@ -0,0 +1,489 @@ +# Pre-Development Plan: Enhance asFluent() with Tagged Templates and Lazy Execution + +## Overview +Enhance the existing `asFluent()` method to support tagged template literals, lazy execution, and memoization. The returned function (commonly named `$`) will support: +- Tagged template literals: `` $`echo hello` `` +- Function calls: `$('echo hello')` and `$(['echo', 'hello'])` +- Lazy execution: doesn't run until consumed +- Memoization: one handle, one execution +- `.result()` method for non-throwable execution + +## Before and After + +### Before (Task-1): +```typescript +const shell = createShell({ verbose: true }); +const $ = shell.asFluent(); + +// Only function calls supported +const result = await $('echo hello'); // ✅ Works +const lines = await $('ls').toLines(); // ✅ Works + +// Tagged templates NOT supported +await $`echo hello`; // ❌ Doesn't work +``` + +### After (Task-2): +```typescript +const shell = createShell({ verbose: true }); +const $ = shell.asFluent(); + +// Function calls still work (backward compatible) +const result = await $('echo hello'); // ✅ Still works +const lines = await $('ls').toLines(); // ✅ Still works + +// Tagged templates NOW supported +const output = await $`echo hello`; // ✅ Now works! +const files = await $`ls -la`.toLines(); // ✅ Now works! + +// Interpolation in tagged templates +const name = 'world'; +const msg = await $`echo hello ${name}`; // ✅ New feature! + +// Non-throwable execution with .result() +const r = await $`exit 1`.result(); // ✅ New feature! +if (!r.success) { + console.error(r.exitCode, r.stderr); +} + +// Lazy execution + memoization +const handle = $`echo test`; +const a = await handle; // Executes once +const b = await handle.result(); // Reuses same execution +``` + +## Goals +- **Enhance `asFluent()`** to return a function that supports: + - Tagged template: `` await $`echo ${name}` `` + - Function calls: `await $('echo hello')` and `await $(['echo', 'hello'])` +- **Add lazy execution**: only starts when first "consumed" (await, .result(), .toLines(), .parse()) +- **Add memoization**: one handle, one execution, shared by all consumers +- **Add `.result()` method**: non-throwable execution path +- **Maintain backward compatibility**: existing function call syntax still works + +## Key Enhancements from Current asFluent() + +### Current `asFluent()` behavior (from task-1): +- Returns a function: `(command: string | string[]) => CommandHandle` +- Executes immediately when called +- Each method call creates new execution +- No memoization +- Only supports function calls, not tagged templates + +### Enhanced behavior (task-2): +- Returns a function: `DollarFunction` (supports tagged templates + function calls) +- Deferred execution (lazy) +- Execution starts only when consumed (await, .result(), .toLines(), .parse()) +- One execution shared by all methods (memoized) +- Supports tagged templates in addition to function calls +- Has `.result()` for non-throwable execution + +## Architecture Design + +### 1. New Types + +```typescript +/** + * Result type for non-throwable execution + */ +export type CommandResult = { + success: boolean; // exitCode === 0 + stdout: string; + stderr: string; + exitCode: number | undefined; // undefined when process failed to start +}; + +/** + * Command handle with lazy execution and memoization + * Supports both throwable (await) and non-throwable (.result()) patterns + */ +export type LazyCommandHandle = PromiseLike & { + /** + * Non-throwable execution - returns result object with success flag + */ + result(): Promise; + + /** + * Split stdout into array of lines + */ + toLines(): Promise; + + /** + * Parse stdout as JSON and validate with schema + */ + parse(schema: { parse(x: unknown): T }): Promise; +}; + +/** + * Overloaded $ function signatures + */ +export interface DollarFunction { + // Tagged template + (parts: TemplateStringsArray, ...values: any[]): LazyCommandHandle; + + // String command + (command: string): LazyCommandHandle; + + // Argv array + (command: string[]): LazyCommandHandle; +} +``` + +### 2. Shell Class Modification + +Modify existing `asFluent()` method in Shell class: +```typescript +class Shell { + // ... existing methods ... + + /** + * Create a fluent shell function with tagged template support and lazy execution. + * The returned function can be used with tagged templates or regular function calls. + * + * @example Tagged template + * ```typescript + * const $ = shell.asFluent(); + * const files = await $`ls -la`.toLines(); + * ``` + * + * @example Function call + * ```typescript + * const $ = shell.asFluent(); + * const result = await $('echo hello'); + * ``` + */ + asFluent(): DollarFunction { + // NEW implementation with lazy execution and tagged template support + } +} +``` + +**Note**: This replaces the current `createFluentShell()` implementation from task-1. + +### 3. Lazy Execution Implementation Pattern + +```typescript +function createLazyHandle(shell: Shell, command: string | string[]): LazyCommandHandle { + // Memoized execution promise + let executionPromise: Promise | null = null; + + // Lazy executor - only runs once, then memoizes + const start = (): Promise => { + if (executionPromise === null) { + executionPromise = shell.safeRun(command, { outputMode: 'capture' }) + .then(result => ({ + success: result.success, + stdout: result.stdout ?? '', + stderr: result.stderr ?? '', + exitCode: result.exitCode, + })); + } + return executionPromise; + }; + + const handle: Partial = {}; + + // Throwable path: await handle + handle.then = (onFulfilled, onRejected) => { + return start().then(result => { + if (!result.success) { + // Throw error based on throwMode + throw new Error(`Command failed with exit code ${result.exitCode}`); + } + return result.stdout; + }).then(onFulfilled, onRejected); + }; + + // Non-throwable path: handle.result() + handle.result = () => start(); + + // Helper methods + handle.toLines = () => start().then(result => { + if (!result.success) { + throw new Error(`Command failed with exit code ${result.exitCode}`); + } + if (!result.stdout) return []; + return result.stdout.split(/\r?\n/); + }); + + handle.parse = (schema: { parse(x: unknown): T }): Promise => { + return start().then(result => { + if (!result.success) { + throw new Error(`Command failed with exit code ${result.exitCode}`); + } + const parsed = JSON.parse(result.stdout); + return schema.parse(parsed); + }); + }; + + return handle as LazyCommandHandle; +} +``` + +### 4. Tagged Template Processing + +```typescript +function processTaggedTemplate( + parts: TemplateStringsArray, + values: any[] +): string { + // Interleave parts and values + // For now: simple string interpolation + // Later: proper argv-safe escaping for interpolated values + let result = parts[0]; + for (let i = 0; i < values.length; i++) { + result += String(values[i]) + parts[i + 1]; + } + return result; +} +``` + +## Implementation Steps + +### Step 1: Add New Types to src/shell.ts +- Define `CommandResult` type (for `.result()` method) +- Define `LazyCommandHandle` type (extends `CommandHandle` with `.result()`) +- Define `DollarFunction` interface with overloads for tagged templates +- Keep existing `CommandHandle` and `FluentShellFn` types for backward compatibility +- Export all new types + +### Step 2: Implement Lazy Execution Core +- Create `createLazyHandle()` helper function +- Implement memoized `start()` function that runs command only once +- Implement `then` method for throwable path (replaces immediate execution) +- Implement `result()` method for non-throwable path (new feature) +- Update `toLines()` and `parse()` to use lazy execution + +### Step 3: Implement Tagged Template Processing +- Create `processTaggedTemplate()` helper function +- Simple string interpolation for now (concatenate parts and values) +- Note: argv-safe escaping to be added later (future enhancement) + +### Step 4: Replace asFluent() Implementation in Shell Class +- Remove current `createFluentShell()` implementation +- Implement new `asFluent()` method that: + - Returns `DollarFunction` (supports tagged templates + function calls) + - Detects tagged template vs function call + - Processes input and delegates to `createLazyHandle()` + - Maintains backward compatibility with function call syntax + +### Step 5: Export from src/index.ts +- Export new types: `CommandResult`, `LazyCommandHandle`, `DollarFunction` +- Keep existing exports for backward compatibility + +### Step 6: Update Existing Tests +- Modify `test/shell.test.ts`: + - Keep existing "Fluent Shell API - asFluent" tests + - Update tests to verify lazy execution behavior + - Add tests for new `.result()` method + +### Step 7: Add New Tests for Tagged Templates and Lazy Execution +Create additional test cases in `test/shell.test.ts`: +- **Basic Functionality**: + - Tagged template: `` await $`echo test` `` + - String call: `await $('echo test')` + - Array call: `await $(['echo', 'test'])` + +- **Lazy Execution**: + - Verify execution doesn't start immediately + - Verify execution starts on first consume + +- **Memoization**: + - Multiple awaits share same execution + - `.result()` and `await` share same execution + - `.toLines()` and `.parse()` share same execution + +- **Throwable Path**: + - `await $`exit 1`` throws error + - Error includes exit code and stderr + - Respects throwMode setting + +- **Non-throwable Path**: + - `.result()` doesn't throw + - Returns CommandResult with success: false + - Includes stdout, stderr, exitCode + +- **Helper Methods**: + - `.toLines()` splits output correctly + - `.parse()` parses JSON with schema + - Both helpers throw on command failure + +- **Template Interpolation**: + - `` $`echo ${value}` `` includes interpolated values + - Multiple interpolations work + - Edge cases: empty strings, numbers, etc. + +### Step 8: Add Usage Examples +Update `examples/fluentShell.ts` or create `examples/lazyFluent.ts`: +- Tagged template basic usage +- Function call usage (existing examples still work) +- `.result()` for non-throwable execution (new) +- `.toLines()` and `.parse()` with templates +- Demonstrating lazy + memoization behavior +- Show that `asFluent()` now supports both styles + +## Technical Considerations + +### Lazy Execution Details +- Execution promise is `null` initially +- First consumer (await, .result(), etc.) triggers start() +- start() creates promise and memoizes it +- Subsequent consumers reuse memoized promise +- No race conditions: all consumers wait on same promise + +### Memoization Guarantee +```typescript +const handle = $`echo test`; +const a = await handle; // Executes once +const b = await handle; // Reuses same execution +const c = await handle.result(); // Still same execution +``` + +### Tagged Template vs Function Call Detection +```typescript +public asFluent(): DollarFunction { + return ((firstArg: any, ...rest: any[]) => { + // Check if it's a tagged template call + if ( + Array.isArray(firstArg) && + 'raw' in firstArg && + Array.isArray(firstArg.raw) + ) { + // Tagged template: firstArg is TemplateStringsArray + const command = processTaggedTemplate(firstArg, rest); + return createLazyHandle(this, command); + } + + // Function call with string or array + return createLazyHandle(this, firstArg); + }) as DollarFunction; +} +``` + +### Error Handling Strategy +- **Throwable path** (`await $...`): + - Checks result.success in `then` callback + - Throws error if success === false + - Error format based on throwMode (simple vs raw) + +- **Non-throwable path** (`.result()`): + - Always returns CommandResult + - Never throws (unless Shell itself throws) + - User checks result.success manually + +- **Helper methods** (`.toLines()`, `.parse()`): + - Throw if command failed (exit code ≠ 0) + - Also throw on parsing errors + - Consistent with throwable semantics + +### Type Safety +- Overloaded signatures provide proper type inference +- `await $...` returns `string` +- `.result()` returns `Promise` +- `.toLines()` returns `Promise` +- `.parse(schema)` returns `Promise` where T inferred from schema + +## Files to Modify + +1. **src/shell.ts** + - Add `CommandResult` type + - Add `LazyCommandHandle` type (extends existing `CommandHandle`) + - Add `DollarFunction` interface + - **Replace** `createFluentShell()` method with enhanced `asFluent()` method + - Add helper functions: `createLazyHandle()`, `processTaggedTemplate()` + +2. **src/index.ts** + - Export new types: `CommandResult`, `LazyCommandHandle`, `DollarFunction` + +3. **test/shell.test.ts** + - **Update** existing "Fluent Shell API - asFluent" test suite + - Add new test cases for tagged templates + - Add new test cases for lazy execution and memoization + - Add new test cases for `.result()` method + +4. **examples/fluentShell.ts** or **examples/lazyFluent.ts** + - Update existing examples or create new file + - Demonstrate tagged template usage + - Demonstrate lazy execution behavior + - Demonstrate memoization guarantee + - Show `.result()` for non-throwable execution + +## Success Criteria + +- ✅ `const $ = shell.asFluent()` works +- ✅ `` const result = await $`echo test` `` works and returns 'test' +- ✅ `await $('echo test')` works (backward compatibility) +- ✅ `await $(['echo', 'test'])` works (backward compatibility) +- ✅ `` const r = await $`exit 1`.result() `` doesn't throw, r.success === false +- ✅ `` await $`exit 1` `` throws error +- ✅ `` await $`echo test`.toLines() `` returns array +- ✅ Tagged template with interpolation: `` $`echo ${value}` `` works +- ✅ Lazy execution verified (doesn't run until consumed) +- ✅ Memoization verified (multiple consumers share one execution) +- ✅ All existing tests still pass (backward compatibility) +- ✅ New tests for tagged templates and lazy execution pass +- ✅ TypeScript compilation succeeds +- ✅ Type inference works correctly for all overloads +- ✅ Respects Shell options (verbose, dryRun, throwMode) + +## Non-Goals (Out of Scope for Initial Implementation) + +- Argv-safe escaping for template interpolations (mark as TODO) +- Pipeline/redirection support (future: `` $sh`ls | grep x` ``) +- Interactive stdin auto-inherit (future: `.live()` or `.withShell()`) +- Additional methods like `.json()`, `.safeParse()`, `.csv()`, `.stream()`, `.timing()` +- Performance optimizations for template parsing +- Support for nested templates + +## Future Enhancements (Document as TODOs) + +1. **Argv-safe interpolation**: + ```typescript + // Each ${value} should be treated as single argv element + $`git commit -m ${message}` // message shouldn't word-split + ``` + +2. **Shell mode for pipelines**: + ```typescript + $sh`ls | grep .ts` + // or + $`ls -la`.withShell().pipe($`grep .ts`) + ``` + +3. **Interactive stdin**: + ```typescript + await $`vim myfile.txt`.live() + ``` + +4. **Additional helper methods**: + - `.json()` - auto-parse JSON without schema + - `.text()` - alias for direct await + - `.lines()` - alias for toLines() + - `.stream()` - return Node.js stream + - `.timing()` - include execution timing in result + +5. **Enhanced result options**: + ```typescript + await $`...`.result({ + includeTiming: true, + includeCommand: true + }) + ``` + +## Notes + +- The `asFluent()` method returns a function (`DollarFunction`), not a handle directly +- This function can be used with both tagged template and function call syntax +- Usage pattern: `const $ = shell.asFluent(); await $`echo test`` +- Or with createShell: `const $ = createShell().asFluent(); await $`ls`` +- The returned function can be named anything (commonly `$`, but could be `sh`, `cmd`, etc.) +- Consider adding a top-level export for convenience: + ```typescript + export const $ = createShell().asFluent(); + ``` +- The lazy execution is truly lazy - handle can be created without execution +- Multiple consumption of same handle is safe and efficient (memoized) +- The `.result()` method is the primary way to avoid exceptions +- **Implementation note**: This enhances the existing `asFluent()` method from task-1 +- **Backward compatibility**: All existing function call syntax continues to work +- **New behavior**: Adds tagged template support, lazy execution, and `.result()` method From ed124f229a36e874a2fc3f3ba0aec0d4c4991472 Mon Sep 17 00:00:00 2001 From: Thada Wangthammang Date: Sun, 9 Nov 2025 22:36:57 +0700 Subject: [PATCH 06/19] feat: add examples and tests for Fluent Shell API with tagged templates and lazy execution --- examples/taggedTemplates.ts | 134 ++++++++++++++ src/shell.ts | 340 +++++++++++++++++++++++++++++------- test/shell.test.ts | 311 +++++++++++++++++++++++++++++++++ 3 files changed, 726 insertions(+), 59 deletions(-) create mode 100644 examples/taggedTemplates.ts diff --git a/examples/taggedTemplates.ts b/examples/taggedTemplates.ts new file mode 100644 index 0000000..3c0cec3 --- /dev/null +++ b/examples/taggedTemplates.ts @@ -0,0 +1,134 @@ +/** + * Example demonstrating Tagged Template Literals with Lazy Execution + * + * Task-2 features: + * - Tagged template support: $`echo hello` + * - Function call support (backward compatible): $('echo hello') + * - Lazy execution: command doesn't run until consumed + * - Memoization: multiple consumptions share one execution + * - .result() method for non-throwable execution + */ + +import { createShell } from '../src/index.js'; +import { z } from 'zod'; + +async function main() { + console.log('=== Tagged Template Literals + Lazy Execution ===\n'); + + // Create fluent shell function + const $ = createShell({ verbose: false }).asFluent(); + + console.log('--- Example 1: Tagged Template Basic ---'); + // Tagged template - new in task-2! + const greeting = await $`echo hello`; + console.log(`Result: ${greeting}\n`); + + console.log('--- Example 2: Tagged Template with Interpolation ---'); + // Interpolate values into template + const name = 'world'; + const msg = await $`echo hello ${name}`; + console.log(`Result: ${msg}\n`); + + console.log('--- Example 3: Multiple Interpolations ---'); + // Multiple interpolated values + const a = 'foo'; + const b = 'bar'; + const c = 'baz'; + const result = await $`echo ${a} ${b} ${c}`; + console.log(`Result: ${result}\n`); + + console.log('--- Example 4: Function Call (Backward Compatible) ---'); + // Function call syntax still works + const funcResult = await $('echo "function call works"'); + console.log(`Result: ${funcResult}\n`); + + console.log('--- Example 5: Array Command ---'); + // Array syntax for precise argv control + const arrayResult = await $(['echo', 'array', 'syntax']); + console.log(`Result: ${arrayResult}\n`); + + console.log('--- Example 6: Non-throwable Execution with .result() ---'); + // Use .result() for non-throwable execution + const successResult = await $`echo success`.result(); + console.log(`Success: ${successResult.success}`); + console.log(`Stdout: ${successResult.stdout}`); + console.log(`Exit code: ${successResult.exitCode}\n`); + + // Failed command with .result() - doesn't throw! + const failResult = await $`sh -c "exit 42"`.result(); + console.log(`Success: ${failResult.success}`); + console.log(`Exit code: ${failResult.exitCode}`); + console.log(`Note: .result() never throws, even on failure!\n`); + + console.log('--- Example 7: Lazy Execution ---'); + // Command doesn't execute until consumed + console.log('Creating handle (no execution yet)...'); + const lazyHandle = $`echo "I am lazy"`; + console.log('Handle created, but command has not run yet!'); + console.log('Now consuming handle (execution starts)...'); + const lazyResult = await lazyHandle; + console.log(`Result: ${lazyResult}\n`); + + console.log('--- Example 8: Memoization ---'); + // Multiple consumptions share one execution + const memoHandle = $`echo "executed once"`; + console.log('First consumption (executes):'); + const memo1 = await memoHandle; + console.log('Second consumption (reuses):'); + const memo2 = await memoHandle; + console.log('Third consumption with .result() (still reuses):'); + const memo3 = await memoHandle.result(); + console.log(`All three got same result: ${memo1 === memo2 && memo2 === memo3.stdout}\n`); + + console.log('--- Example 9: Tagged Template with .toLines() ---'); + // Combine tagged template with helper methods + const lines = await $`printf "apple\nbanana\ncherry"`.toLines(); + console.log('Fruits:'); + lines.forEach((fruit, i) => { + console.log(` ${i + 1}. ${fruit}`); + }); + console.log(); + + console.log('--- Example 10: Tagged Template with .parse() ---'); + // Parse JSON output with Zod schema + const PackageSchema = z.object({ + name: z.string(), + version: z.string(), + }); + + const pkg = await $`cat package.json`.parse(PackageSchema); + console.log(`Package: ${pkg.name} v${pkg.version}\n`); + + console.log('--- Example 11: Error Handling Comparison ---'); + // Throwable vs non-throwable + try { + console.log('Attempting throwable execution...'); + await $`sh -c "exit 1"`; + } catch (error) { + console.log(`Caught error (throwable): ${(error as Error).message.split('\n')[0]}`); + } + + const safeResult = await $`sh -c "exit 1"`.result(); + console.log(`Non-throwable result: success=${safeResult.success}, exitCode=${safeResult.exitCode}\n`); + + console.log('--- Example 12: Chaining with Variables ---'); + // Use result of one command in another + const dir = await $`echo test-dir`; + console.log(`Got directory name: ${dir}`); + + const $dryRun = createShell({ dryRun: true }).asFluent(); + await $dryRun`mkdir ${dir}`; + console.log(`Would create directory: ${dir} (dry run)\n`); + + console.log('--- Example 13: Complex Template ---'); + // More complex template with multiple parts + const user = 'alice'; + const age = 30; + const city = 'wonderland'; + const complexMsg = await $`echo "User: ${user}, Age: ${age}, City: ${city}"`; + console.log(`Complex: ${complexMsg}\n`); + + console.log('✅ All examples completed successfully!'); +} + +main().catch(console.error); diff --git a/src/shell.ts b/src/shell.ts index a14fb00..cf35419 100644 --- a/src/shell.ts +++ b/src/shell.ts @@ -271,6 +271,144 @@ export type CommandHandle = PromiseLike & { */ export type FluentShellFn = (command: string | string[]) => CommandHandle; +/** + * Result type for non-throwable command execution. + * Returned by the `.result()` method on LazyCommandHandle. + * + * @example + * ```typescript + * const $ = createShell().asFluent(); + * const r = await $`exit 1`.result(); + * if (!r.success) { + * console.error(`Command failed with exit code ${r.exitCode}`); + * console.error(`Stderr: ${r.stderr}`); + * } + * ``` + */ +export type CommandResult = { + /** True if the command exited with code 0 */ + success: boolean; + /** Captured stdout output */ + stdout: string; + /** Captured stderr output */ + stderr: string; + /** Exit code (undefined if process failed to start) */ + exitCode: number | undefined; +}; + +/** + * Command handle with lazy execution and memoization. + * Extends CommandHandle with `.result()` method for non-throwable execution. + * + * Key features: + * - **Lazy execution**: Command doesn't run until first consumption (await, .result(), .toLines(), .parse()) + * - **Memoization**: Multiple consumptions share the same execution + * - **Throwable path**: `await handle` throws on non-zero exit code + * - **Non-throwable path**: `await handle.result()` returns result with success flag + * + * @example Throwable execution + * ```typescript + * const $ = createShell().asFluent(); + * try { + * const output = await $`exit 1`; // Throws error + * } catch (error) { + * console.error(error); + * } + * ``` + * + * @example Non-throwable execution + * ```typescript + * const $ = createShell().asFluent(); + * const r = await $`exit 1`.result(); // Doesn't throw + * if (!r.success) { + * console.error(r.exitCode, r.stderr); + * } + * ``` + * + * @example Memoization + * ```typescript + * const $ = createShell().asFluent(); + * const handle = $`echo test`; + * const a = await handle; // Executes once + * const b = await handle; // Reuses same execution + * const c = await handle.result(); // Still same execution + * ``` + */ +export type LazyCommandHandle = PromiseLike & { + /** + * Non-throwable execution - returns result object with success flag. + * This method never throws, even if the command fails. + * + * @returns Promise resolving to CommandResult with success, stdout, stderr, and exitCode + */ + result(): Promise; + + /** + * Split stdout by newlines and return as array of strings. + * Handles both Unix (\n) and Windows (\r\n) line endings. + * Throws if command fails. + * + * @returns Promise resolving to array of lines from stdout + */ + toLines(): Promise; + + /** + * Parse stdout as JSON and validate with the provided schema. + * The schema must have a `parse(data: any): T` method (compatible with Zod and other validators). + * Throws if command fails or if JSON parsing/validation fails. + * + * @template T - The inferred output type from the schema + * @param schema - A schema object with a parse method (e.g., Zod schema) + * @returns Promise resolving to the parsed and validated data + */ + parse(schema: { parse(x: any): T }): Promise; +}; + +/** + * Function that supports both tagged templates and function calls for command execution. + * Returned by `shell.asFluent()`. + * + * Supports three call signatures: + * 1. Tagged template: `` $`echo hello` `` + * 2. String command: `$('echo hello')` + * 3. Argv array: `$(['echo', 'hello'])` + * + * @example Tagged template with interpolation + * ```typescript + * const $ = createShell().asFluent(); + * const name = 'world'; + * const result = await $`echo hello ${name}`; + * ``` + * + * @example Function call with string + * ```typescript + * const $ = createShell().asFluent(); + * const result = await $('echo hello'); + * ``` + * + * @example Function call with array + * ```typescript + * const $ = createShell().asFluent(); + * const result = await $(['echo', 'hello']); + * ``` + */ +export interface DollarFunction { + /** + * Tagged template call - interpolates values into command string + */ + (parts: TemplateStringsArray, ...values: any[]): LazyCommandHandle; + + /** + * String command call + */ + (command: string): LazyCommandHandle; + + /** + * Argv array call + */ + (command: string[]): LazyCommandHandle; +} + /** * Factory function to create a new Shell instance with type inference. * Provides better type safety and convenience compared to using `new Shell()`. @@ -653,85 +791,169 @@ export class Shell { } /** - * Create a fluent shell function for cleaner command execution syntax. + * Process tagged template literal into a command string. + * Interpolates values between template string parts. * - * Returns a function that can be used to execute commands with a more ergonomic API. - * The returned function creates a CommandHandle that can be awaited directly or have - * helper methods called on it before awaiting. + * @param parts - Template string parts (TemplateStringsArray) + * @param values - Interpolated values + * @returns Concatenated command string * - * @returns A fluent shell function that accepts commands and returns CommandHandle instances + * @internal + */ + private processTaggedTemplate(parts: TemplateStringsArray, values: any[]): string { + // Interleave parts and values + // TODO: Implement argv-safe escaping for interpolated values + let result = parts[0]; + for (let i = 0; i < values.length; i++) { + result += String(values[i]) + parts[i + 1]; + } + return result; + } + + /** + * Create a lazy command handle with memoized execution. + * The command doesn't execute until first consumption (await, .result(), .toLines(), .parse()). + * Multiple consumptions share the same execution result. * - * @example Basic usage - * ```typescript - * const $ = createShell({ verbose: true }).asFluent(); + * @param command - Command to execute (string or array) + * @returns LazyCommandHandle with deferred execution + * + * @internal + */ + private createLazyHandle(command: string | string[]): LazyCommandHandle { + // Memoized execution promise - null until first consumption + let executionPromise: Promise | null = null; + + // Lazy executor - runs command once and memoizes result + const start = (): Promise => { + if (executionPromise === null) { + executionPromise = this.safeRun(command, { outputMode: 'capture' }).then(result => ({ + success: result.success, + stdout: result.stdout ?? '', + stderr: result.stderr ?? '', + exitCode: result.exitCode, + })); + } + return executionPromise; + }; + + const handle: Partial = {}; + + // Throwable path: await handle + // Checks success and throws error if command failed + handle.then = (onFulfilled, onRejected) => { + return start() + .then(result => { + if (!result.success) { + // Throw error based on throwMode setting + if (this.throwMode === 'simple') { + const args = Array.isArray(command) ? command : parseArgsStringToArgv(command); + throw new Error(`Command failed: ${args.join(' ')}\nExit code: ${result.exitCode}\n${result.stderr}`); + } else { + // For 'raw' mode, we'd need the original ExecaError + // Since we're using safeRun, we'll throw a simplified error + throw new Error(`Command failed with exit code ${result.exitCode}: ${result.stderr}`); + } + } + return result.stdout; + }) + .then(onFulfilled, onRejected); + }; + + // Non-throwable path: handle.result() + // Returns CommandResult with success flag, never throws + handle.result = () => start(); + + // Helper method: split stdout into lines + // Throws if command failed + handle.toLines = () => + start().then(result => { + if (!result.success) { + const args = Array.isArray(command) ? command : parseArgsStringToArgv(command); + throw new Error(`Command failed: ${args.join(' ')}\nExit code: ${result.exitCode}\n${result.stderr}`); + } + if (!result.stdout) return []; + return result.stdout.split(/\r?\n/); + }); + + // Helper method: parse JSON and validate with schema + // Throws if command failed or if JSON parsing/validation fails + handle.parse = (schema: { parse(x: unknown): T }): Promise => { + return start().then(result => { + if (!result.success) { + const args = Array.isArray(command) ? command : parseArgsStringToArgv(command); + throw new Error(`Command failed: ${args.join(' ')}\nExit code: ${result.exitCode}\n${result.stderr}`); + } + const parsed = JSON.parse(result.stdout); + return schema.parse(parsed); + }); + }; + + return handle as LazyCommandHandle; + } + + /** + * Create a fluent shell function with tagged template and lazy execution support. * - * // Direct await - returns stdout as string - * const result = await $('echo hello'); - * console.log(result); // 'hello' + * Returns a function that supports: + * - Tagged templates: `` $`echo hello` `` + * - Function calls: `$('echo hello')` or `$(['echo', 'hello'])` + * - Lazy execution: command doesn't run until consumed + * - Memoization: multiple consumptions share one execution + * - Non-throwable path: `.result()` returns result with success flag + * + * @returns DollarFunction that supports tagged templates and function calls + * + * @example Tagged template + * ```typescript + * const $ = createShell().asFluent(); + * const name = 'world'; + * const result = await $`echo hello ${name}`; // 'hello world' * ``` * - * @example Using toLines() + * @example Function call * ```typescript * const $ = createShell().asFluent(); + * const result = await $('echo hello'); // 'hello' + * ``` * - * const files = await $('ls -la').toLines(); - * for (const file of files) { - * console.log(`File: ${file}`); + * @example Non-throwable execution + * ```typescript + * const $ = createShell().asFluent(); + * const r = await $`exit 1`.result(); + * if (!r.success) { + * console.error(`Failed with exit code ${r.exitCode}`); * } * ``` * - * @example Using parse() with Zod + * @example Lazy + memoization * ```typescript - * import { z } from 'zod'; * const $ = createShell().asFluent(); - * - * const UserSchema = z.object({ - * login: z.string(), - * id: z.number(), - * }); - * - * const user = await $('gh api /user').parse(UserSchema); - * console.log('User login:', user.login); + * const handle = $`echo test`; + * const a = await handle; // Executes once + * const b = await handle; // Reuses execution + * const c = await handle.result(); // Still same execution * ``` * - * @example Chaining commands + * @example Helper methods * ```typescript * const $ = createShell().asFluent(); - * - * const data = await $('echo test'); - * await $(`mkdir ${data}`); + * const lines = await $`ls -la`.toLines(); + * const data = await $`cat package.json`.parse(schema); * ``` */ - public asFluent(): FluentShellFn { - return (command: string | string[]): CommandHandle => { - // Execute the command and get a promise for the stdout - const execPromise = this.run<'capture'>(command, { outputMode: 'capture' }).then(result => { - // For capture mode, stdout is always a string (or null for live mode, but we force capture here) - return result.stdout ?? ''; - }); - - // Create the handle object - const handle: Partial = {}; - - // Make it thenable by binding the promise's then method - handle.then = execPromise.then.bind(execPromise); - - // Add helper method to split stdout into lines - handle.toLines = () => - execPromise.then(stdout => { - if (!stdout) return []; - return stdout.split(/\r?\n/); - }); - - // Add helper method to parse JSON and validate with schema - handle.parse = (schema: { parse(x: any): T }): Promise => { - return execPromise.then(stdout => { - const parsed = JSON.parse(stdout); - return schema.parse(parsed); - }); - }; + public asFluent(): DollarFunction { + return ((firstArg: any, ...rest: any[]): LazyCommandHandle => { + // Detect if it's a tagged template call + // Tagged templates pass TemplateStringsArray as first argument + if (Array.isArray(firstArg) && 'raw' in firstArg && Array.isArray((firstArg as any).raw)) { + // Tagged template: process interpolation + const command = this.processTaggedTemplate(firstArg as TemplateStringsArray, rest); + return this.createLazyHandle(command); + } - return handle as CommandHandle; - }; + // Function call: string or array + return this.createLazyHandle(firstArg); + }) as DollarFunction; } } diff --git a/test/shell.test.ts b/test/shell.test.ts index 9414c3b..63804b2 100644 --- a/test/shell.test.ts +++ b/test/shell.test.ts @@ -1050,4 +1050,315 @@ describe('Shell', () => { expect(result.length).toBeGreaterThanOrEqual(100); }); }); + + describe('Fluent Shell API - Tagged Templates', () => { + it('should support tagged template basic usage', async () => { + const $ = createShell().asFluent(); + + const result = await $`echo hello`; + + expect(result).toBe('hello'); + }); + + it('should support tagged template with single interpolation', async () => { + const $ = createShell().asFluent(); + const name = 'world'; + + const result = await $`echo hello ${name}`; + + expect(result).toBe('hello world'); + }); + + it('should support tagged template with multiple interpolations', async () => { + const $ = createShell().asFluent(); + const a = 'foo'; + const b = 'bar'; + const c = 'baz'; + + const result = await $`echo ${a} ${b} ${c}`; + + expect(result).toBe('foo bar baz'); + }); + + it('should handle tagged template with number interpolation', async () => { + const $ = createShell().asFluent(); + const num = 42; + + const result = await $`echo ${num}`; + + expect(result).toBe('42'); + }); + + it('should handle tagged template with empty interpolation', async () => { + const $ = createShell().asFluent(); + const empty = ''; + + const result = await $`echo test${empty}value`; + + expect(result).toBe('testvalue'); + }); + + it('should work with tagged template and toLines()', async () => { + const $ = createShell().asFluent(); + + const lines = await $`printf "a\nb\nc"`.toLines(); + + expect(lines).toEqual(['a', 'b', 'c']); + }); + + it('should work with tagged template and parse()', async () => { + const $ = createShell().asFluent(); + const schema = z.object({ + value: z.string(), + }); + + const result = await $`echo '{"value":"test"}'`.parse(schema); + + expect(result.value).toBe('test'); + }); + + it('should throw error for failing tagged template command', async () => { + const $ = createShell().asFluent(); + + await expect($`sh -c "exit 1"`).rejects.toThrow(); + }); + }); + + describe('Fluent Shell API - .result() Non-throwable Execution', () => { + it('should return success result for successful command', async () => { + const $ = createShell().asFluent(); + + const result = await $`echo test`.result(); + + expect(result.success).toBe(true); + expect(result.stdout).toBe('test'); + expect(result.stderr).toBe(''); + expect(result.exitCode).toBe(0); + }); + + it('should return failure result without throwing', async () => { + const $ = createShell().asFluent(); + + const result = await $`sh -c "exit 1"`.result(); + + expect(result.success).toBe(false); + expect(result.exitCode).toBe(1); + }); + + it('should work with function call syntax', async () => { + const $ = createShell().asFluent(); + + const result = await $('sh -c "exit 42"').result(); + + expect(result.success).toBe(false); + expect(result.exitCode).toBe(42); + }); + + it('should capture stderr in result', async () => { + const $ = createShell().asFluent(); + + const result = await $`sh -c "echo error >&2; exit 1"`.result(); + + expect(result.success).toBe(false); + expect(result.stderr).toBe('error'); + }); + + it('should work with array command syntax', async () => { + const $ = createShell().asFluent(); + + const result = await $(['echo', 'test']).result(); + + expect(result.success).toBe(true); + expect(result.stdout).toBe('test'); + }); + + it('should include both stdout and stderr on success', async () => { + const $ = createShell().asFluent(); + + const result = await $`sh -c "echo out; echo err >&2"`.result(); + + expect(result.success).toBe(true); + expect(result.stdout).toBe('out'); + expect(result.stderr).toBe('err'); + }); + }); + + describe('Fluent Shell API - Lazy Execution', () => { + it('should not execute immediately when handle is created', async () => { + const mockDebug = vi.fn(); + const $ = createShell({ verbose: true, logger: { debug: mockDebug } }).asFluent(); + + // Create handle - should NOT execute yet + const handle = $`echo test`; + + // No execution yet + expect(mockDebug).not.toHaveBeenCalled(); + + // Consume the handle - NOW it executes + await handle; + + // Now it executed + expect(mockDebug).toHaveBeenCalledWith('$ echo test', expect.any(Object)); + }); + + it('should execute on first await', async () => { + const mockDebug = vi.fn(); + const $ = createShell({ verbose: true, logger: { debug: mockDebug } }).asFluent(); + + const handle = $`echo test`; + expect(mockDebug).not.toHaveBeenCalled(); + + const result = await handle; + + expect(result).toBe('test'); + expect(mockDebug).toHaveBeenCalledTimes(1); + }); + + it('should execute on first .result() call', async () => { + const mockDebug = vi.fn(); + const $ = createShell({ verbose: true, logger: { debug: mockDebug } }).asFluent(); + + const handle = $`echo test`; + expect(mockDebug).not.toHaveBeenCalled(); + + const result = await handle.result(); + + expect(result.success).toBe(true); + expect(result.stdout).toBe('test'); + expect(mockDebug).toHaveBeenCalledTimes(1); + }); + + it('should execute on first .toLines() call', async () => { + const mockDebug = vi.fn(); + const $ = createShell({ verbose: true, logger: { debug: mockDebug } }).asFluent(); + + const handle = $`echo test`; + expect(mockDebug).not.toHaveBeenCalled(); + + const result = await handle.toLines(); + + expect(result).toEqual(['test']); + expect(mockDebug).toHaveBeenCalledTimes(1); + }); + + it('should execute on first .parse() call', async () => { + const mockDebug = vi.fn(); + const $ = createShell({ verbose: true, logger: { debug: mockDebug } }).asFluent(); + const schema = z.object({ value: z.string() }); + + const handle = $`echo '{"value":"test"}'`; + expect(mockDebug).not.toHaveBeenCalled(); + + const result = await handle.parse(schema); + + expect(result.value).toBe('test'); + expect(mockDebug).toHaveBeenCalledTimes(1); + }); + }); + + describe('Fluent Shell API - Memoization', () => { + it('should reuse execution when awaited multiple times', async () => { + const mockDebug = vi.fn(); + const $ = createShell({ verbose: true, logger: { debug: mockDebug } }).asFluent(); + + const handle = $`echo test`; + + const result1 = await handle; + const result2 = await handle; + const result3 = await handle; + + expect(result1).toBe('test'); + expect(result2).toBe('test'); + expect(result3).toBe('test'); + + // Should only execute once + expect(mockDebug).toHaveBeenCalledTimes(1); + }); + + it('should share execution between await and .result()', async () => { + const mockDebug = vi.fn(); + const $ = createShell({ verbose: true, logger: { debug: mockDebug } }).asFluent(); + + const handle = $`echo test`; + + const result1 = await handle; + const result2 = await handle.result(); + + expect(result1).toBe('test'); + expect(result2.stdout).toBe('test'); + expect(result2.success).toBe(true); + + // Should only execute once + expect(mockDebug).toHaveBeenCalledTimes(1); + }); + + it('should share execution between .result() and .toLines()', async () => { + const mockDebug = vi.fn(); + const $ = createShell({ verbose: true, logger: { debug: mockDebug } }).asFluent(); + + const handle = $`printf "a\nb"`; + + const result1 = await handle.result(); + const result2 = await handle.toLines(); + + expect(result1.stdout).toBe('a\nb'); + expect(result2).toEqual(['a', 'b']); + + // Should only execute once + expect(mockDebug).toHaveBeenCalledTimes(1); + }); + + it('should share execution between all methods', async () => { + const mockDebug = vi.fn(); + const $ = createShell({ verbose: true, logger: { debug: mockDebug } }).asFluent(); + + const handle = $`echo '{"value":"test"}'`; + const schema = z.object({ value: z.string() }); + + const result1 = await handle; + const result2 = await handle.result(); + const result3 = await handle.parse(schema); + + expect(result1).toBe('{"value":"test"}'); + expect(result2.stdout).toBe('{"value":"test"}'); + expect(result3.value).toBe('test'); + + // Should only execute once + expect(mockDebug).toHaveBeenCalledTimes(1); + }); + + it('should handle errors consistently across multiple consumptions', async () => { + const $ = createShell().asFluent(); + + const handle = $`sh -c "exit 1"`; + + // First consumption throws + await expect(handle).rejects.toThrow(); + + // Second consumption also throws (same error) + await expect(handle).rejects.toThrow(); + + // .result() doesn't throw but shows failure + const result = await handle.result(); + expect(result.success).toBe(false); + expect(result.exitCode).toBe(1); + }); + + it('should memoize with tagged template interpolation', async () => { + const mockDebug = vi.fn(); + const $ = createShell({ verbose: true, logger: { debug: mockDebug } }).asFluent(); + const name = 'world'; + + const handle = $`echo hello ${name}`; + + const result1 = await handle; + const result2 = await handle; + + expect(result1).toBe('hello world'); + expect(result2).toBe('hello world'); + + // Should only execute once + expect(mockDebug).toHaveBeenCalledTimes(1); + }); + }); }); From 86f87a4e6f6610c2f2b164724953718eb467c18e Mon Sep 17 00:00:00 2001 From: Thada Wangthammang Date: Sun, 9 Nov 2025 22:54:21 +0700 Subject: [PATCH 07/19] feat: update schema validation examples to use Standard Schema V1 with Zod integration --- examples/fluentShell.ts | 22 ++++++++++------------ src/shell.ts | 19 ++++++++++--------- test/shell.test.ts | 18 ------------------ 3 files changed, 20 insertions(+), 39 deletions(-) diff --git a/examples/fluentShell.ts b/examples/fluentShell.ts index e6e5e19..19e9a0a 100644 --- a/examples/fluentShell.ts +++ b/examples/fluentShell.ts @@ -52,19 +52,17 @@ async function main() { console.log(` ${index + 1}. ${fruit}`); }); - console.log('\n=== Example 6: Custom schema ==='); - // Use a custom schema object with parse method - const customSchema = { - parse: (data: any) => { - return { - ...data, - uppercase: data.name ? data.name.toUpperCase() : 'UNKNOWN', - }; - }, - }; + console.log('\n=== Example 6: Complex schema validation ==='); + // Use Zod schema with transformations + const TransformSchema = z.object({ + name: z.string(), + }).transform(data => ({ + ...data, + uppercase: data.name.toUpperCase(), + })); - const customResult = await $('echo \'{"name":"fluent-shell"}\'').parse(customSchema); - console.log(`Custom result: ${customResult.uppercase}`); + const transformResult = await $('echo \'{"name":"fluent-shell"}\'').parse(TransformSchema); + console.log(`Transformed result: ${transformResult.uppercase}`); console.log('\n✅ All examples completed successfully!'); } diff --git a/src/shell.ts b/src/shell.ts index cf35419..d43b190 100644 --- a/src/shell.ts +++ b/src/shell.ts @@ -253,14 +253,16 @@ export type CommandHandle = PromiseLike & { toLines(): Promise; /** - * Parse stdout as JSON and validate with the provided schema. - * The schema must have a `parse(data: any): T` method (compatible with Zod and other validators). + * Parse stdout as JSON and validate with the provided Standard Schema V1. + * The schema must implement the Standard Schema V1 interface (e.g., Zod schemas). * - * @template T - The inferred output type from the schema - * @param schema - A schema object with a parse method (e.g., Zod schema) + * @template T - A Standard Schema V1 schema type + * @param schema - A Standard Schema V1 compatible schema * @returns Promise resolving to the parsed and validated data + * + * @see https://github.com/standard-schema/standard-schema */ - parse(schema: { parse(x: any): T }): Promise; + parse(schema: T): Promise>; }; /** @@ -361,7 +363,7 @@ export type LazyCommandHandle = PromiseLike & { * @param schema - A schema object with a parse method (e.g., Zod schema) * @returns Promise resolving to the parsed and validated data */ - parse(schema: { parse(x: any): T }): Promise; + parse(schema: T): Promise>; }; /** @@ -878,14 +880,13 @@ export class Shell { // Helper method: parse JSON and validate with schema // Throws if command failed or if JSON parsing/validation fails - handle.parse = (schema: { parse(x: unknown): T }): Promise => { + handle.parse = (schema: T): Promise> => { return start().then(result => { if (!result.success) { const args = Array.isArray(command) ? command : parseArgsStringToArgv(command); throw new Error(`Command failed: ${args.join(' ')}\nExit code: ${result.exitCode}\n${result.stderr}`); } - const parsed = JSON.parse(result.stdout); - return schema.parse(parsed); + return standardValidate(schema, JSON.parse(result.stdout ?? '{}')); }); }; diff --git a/test/shell.test.ts b/test/shell.test.ts index 63804b2..f83b512 100644 --- a/test/shell.test.ts +++ b/test/shell.test.ts @@ -896,24 +896,6 @@ describe('Shell', () => { ).rejects.toThrow(); }); - it('should work with custom schema objects', async () => { - const $ = createShell().asFluent(); - - // Custom schema with parse method - const customSchema = { - parse: (data: any) => { - if (typeof data.value === 'string') { - return { value: data.value.toUpperCase() }; - } - throw new Error('Invalid schema'); - } - }; - - const result = await $('echo \'{"value":"hello"}\'').parse(customSchema); - - expect(result.value).toBe('HELLO'); - }); - it('should propagate command execution errors', async () => { const $ = createShell().asFluent(); const schema = z.object({ value: z.string() }); From 953d7a5a113c5ced5af4932ea2bf41d733912a9b Mon Sep 17 00:00:00 2001 From: Thada Wangthammang Date: Sun, 9 Nov 2025 23:10:32 +0700 Subject: [PATCH 08/19] feat: implement safeParse() for non-throwable JSON parsing and schema validation in Fluent Shell API --- examples/taggedTemplates.ts | 46 ++++++++- src/shell.ts | 78 +++++++++++++++ test/shell.test.ts | 188 ++++++++++++++++++++++++++++++++++++ 3 files changed, 309 insertions(+), 3 deletions(-) diff --git a/examples/taggedTemplates.ts b/examples/taggedTemplates.ts index 3c0cec3..5482a4a 100644 --- a/examples/taggedTemplates.ts +++ b/examples/taggedTemplates.ts @@ -99,7 +99,47 @@ async function main() { const pkg = await $`cat package.json`.parse(PackageSchema); console.log(`Package: ${pkg.name} v${pkg.version}\n`); - console.log('--- Example 11: Error Handling Comparison ---'); + console.log('--- Example 11: safeParse() - Non-throwable Parsing ---'); + // safeParse() never throws, returns StandardResult instead + const ConfigSchema = z.object({ + name: z.string(), + version: z.string(), + }); + + // Success case + const parseResult1 = await $`echo '{"name":"app","version":"1.0.0"}'`.safeParse(ConfigSchema); + if (parseResult1.success) { + console.log(`✅ Parse success: ${parseResult1.data.name} v${parseResult1.data.version}`); + } else { + console.log(`❌ Parse failed: ${parseResult1.error[0].message}`); + } + + // Command failure case (doesn't throw!) + const parseResult2 = await $`sh -c "exit 1"`.safeParse(ConfigSchema); + if (parseResult2.success) { + console.log(`✅ Parse success: ${parseResult2.data}`); + } else { + console.log(`❌ Command failed (non-throwing): ${parseResult2.error[0].message.split('\n')[0]}`); + } + + // Invalid JSON case (doesn't throw!) + const parseResult3 = await $`echo "not json"`.safeParse(ConfigSchema); + if (parseResult3.success) { + console.log(`✅ Parse success: ${parseResult3.data}`); + } else { + console.log(`❌ JSON invalid (non-throwing): ${parseResult3.error[0].message.split('\n')[0]}`); + } + + // Schema validation failure (doesn't throw!) + const parseResult4 = await $`echo '{"name":"app"}'`.safeParse(ConfigSchema); // missing 'version' + if (parseResult4.success) { + console.log(`✅ Parse success: ${parseResult4.data}`); + } else { + console.log(`❌ Validation failed (non-throwing): Schema validation error`); + } + console.log(); + + console.log('--- Example 12: Error Handling Comparison ---'); // Throwable vs non-throwable try { console.log('Attempting throwable execution...'); @@ -111,7 +151,7 @@ async function main() { const safeResult = await $`sh -c "exit 1"`.result(); console.log(`Non-throwable result: success=${safeResult.success}, exitCode=${safeResult.exitCode}\n`); - console.log('--- Example 12: Chaining with Variables ---'); + console.log('--- Example 13: Chaining with Variables ---'); // Use result of one command in another const dir = await $`echo test-dir`; console.log(`Got directory name: ${dir}`); @@ -120,7 +160,7 @@ async function main() { await $dryRun`mkdir ${dir}`; console.log(`Would create directory: ${dir} (dry run)\n`); - console.log('--- Example 13: Complex Template ---'); + console.log('--- Example 14: Complex Template ---'); // More complex template with multiple parts const user = 'alice'; const age = 30; diff --git a/src/shell.ts b/src/shell.ts index d43b190..2e587aa 100644 --- a/src/shell.ts +++ b/src/shell.ts @@ -364,6 +364,33 @@ export type LazyCommandHandle = PromiseLike & { * @returns Promise resolving to the parsed and validated data */ parse(schema: T): Promise>; + + /** + * Parse stdout as JSON and validate with schema (non-throwable). + * Returns StandardResult instead of throwing on failure. + * + * This method never throws, even if: + * - The command fails + * - There's no stdout + * - JSON parsing fails + * - Schema validation fails + * + * @template T - A Standard Schema V1 schema type + * @param schema - A Standard Schema V1 compatible schema + * @returns Promise resolving to StandardResult with either data or error + * + * @example + * ```typescript + * const $ = createShell().asFluent(); + * const result = await $`cat config.json`.safeParse(ConfigSchema); + * if (result.success) { + * console.log('Config:', result.data); + * } else { + * console.error('Validation failed:', result.error); + * } + * ``` + */ + safeParse(schema: T): Promise>>; }; /** @@ -890,6 +917,57 @@ export class Shell { }); }; + // Helper method: parse JSON and validate with schema (non-throwable) + // Returns StandardResult instead of throwing + handle.safeParse = ( + schema: T + ): Promise>> => { + return start().then(result => { + const args = Array.isArray(command) ? command : parseArgsStringToArgv(command); + const commandStr = args.join(' '); + + // Check if command failed + if (!result.success) { + return { + success: false, + error: [ + { + message: `Command failed: ${commandStr}\nExit code: ${result.exitCode}\n${result.stderr}`, + }, + ], + }; + } + + // Check if there's no output + if (!result.stdout) { + return { + success: false, + error: [ + { + message: `The command produced no output to validate: ${commandStr}`, + }, + ], + }; + } + + // Try to parse JSON + try { + const parsed = JSON.parse(result.stdout); + // Use standardSafeValidate for schema validation (non-throwing) + return standardSafeValidate(schema, parsed); + } catch (e: unknown) { + return { + success: false, + error: [ + { + message: `Unable to parse JSON: ${e instanceof Error ? e.message : String(e)}\nCommand: ${commandStr}`, + }, + ], + }; + } + }); + }; + return handle as LazyCommandHandle; } diff --git a/test/shell.test.ts b/test/shell.test.ts index f83b512..277e537 100644 --- a/test/shell.test.ts +++ b/test/shell.test.ts @@ -1343,4 +1343,192 @@ describe('Shell', () => { expect(mockDebug).toHaveBeenCalledTimes(1); }); }); + + describe('Fluent Shell API - safeParse() Non-throwable', () => { + it('should return success result for valid JSON and schema', async () => { + const $ = createShell().asFluent(); + const schema = z.object({ + name: z.string(), + version: z.string(), + }); + + const result = await $`echo '{"name":"test","version":"1.0.0"}'`.safeParse(schema); + + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.name).toBe('test'); + expect(result.data.version).toBe('1.0.0'); + } + }); + + it('should return error when command fails', async () => { + const $ = createShell().asFluent(); + const schema = z.object({ value: z.string() }); + + const result = await $`sh -c "exit 1"`.safeParse(schema); + + expect(result.success).toBe(false); + if (!result.success) { + expect(result.error[0].message).toContain('Command failed'); + expect(result.error[0].message).toContain('Exit code: 1'); + } + }); + + it('should return error when no output', async () => { + const $ = createShell().asFluent(); + const schema = z.object({ value: z.string() }); + + const result = await $`true`.safeParse(schema); + + expect(result.success).toBe(false); + if (!result.success) { + expect(result.error[0].message).toContain('produced no output'); + } + }); + + it('should return error when JSON is invalid', async () => { + const $ = createShell().asFluent(); + const schema = z.object({ value: z.string() }); + + const result = await $`echo "not valid json"`.safeParse(schema); + + expect(result.success).toBe(false); + if (!result.success) { + expect(result.error[0].message).toContain('Unable to parse JSON'); + } + }); + + it('should return error when schema validation fails', async () => { + const $ = createShell().asFluent(); + const schema = z.object({ + name: z.string(), + count: z.number(), + }); + + const result = await $`echo '{"name":"test","count":"not-a-number"}'`.safeParse(schema); + + expect(result.success).toBe(false); + if (!result.success) { + expect(result.error.length).toBeGreaterThan(0); + } + }); + + it('should work with function call syntax', async () => { + const $ = createShell().asFluent(); + const schema = z.object({ value: z.string() }); + + const result = await $('echo \'{"value":"hello"}\'').safeParse(schema); + + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.value).toBe('hello'); + } + }); + + it('should work with array command syntax', async () => { + const $ = createShell().asFluent(); + const schema = z.object({ value: z.string() }); + + const result = await $(['echo', '{"value":"world"}']).safeParse(schema); + + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.value).toBe('world'); + } + }); + + it('should work with nested objects', async () => { + const $ = createShell().asFluent(); + const schema = z.object({ + user: z.object({ + name: z.string(), + email: z.string(), + }), + }); + + const result = await $`echo '{"user":{"name":"Alice","email":"alice@example.com"}}'`.safeParse(schema); + + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.user.name).toBe('Alice'); + expect(result.data.user.email).toBe('alice@example.com'); + } + }); + + it('should work with arrays', async () => { + const $ = createShell().asFluent(); + const schema = z.object({ + items: z.array(z.string()), + }); + + const result = await $`echo '{"items":["a","b","c"]}'`.safeParse(schema); + + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.items).toEqual(['a', 'b', 'c']); + } + }); + + it('should never throw on any error', async () => { + const $ = createShell().asFluent(); + const schema = z.object({ value: z.string() }); + + // Command failure - should not throw + const result1 = await $`sh -c "exit 1"`.safeParse(schema); + expect(result1.success).toBe(false); + + // Invalid JSON - should not throw + const result2 = await $`echo "{{{"`.safeParse(schema); + expect(result2.success).toBe(false); + + // Schema validation failure - should not throw + const result3 = await $`echo '{"value":123}'`.safeParse(schema); + expect(result3.success).toBe(false); + + // All should have returned without throwing + expect(true).toBe(true); + }); + + it('should be memoized with other methods', async () => { + const mockDebug = vi.fn(); + const $ = createShell({ verbose: true, logger: { debug: mockDebug } }).asFluent(); + const schema = z.object({ value: z.string() }); + + const handle = $`echo '{"value":"test"}'`; + + // First call with safeParse + const result1 = await handle.safeParse(schema); + expect(result1.success).toBe(true); + + // Second call with parse (should reuse execution) + const result2 = await handle.parse(schema); + expect(result2.value).toBe('test'); + + // Third call with result (should still reuse) + const result3 = await handle.result(); + expect(result3.success).toBe(true); + + // Should only execute once + expect(mockDebug).toHaveBeenCalledTimes(1); + }); + + it('should work with Zod transformations', async () => { + const $ = createShell().asFluent(); + const schema = z + .object({ + name: z.string(), + }) + .transform(data => ({ + ...data, + uppercase: data.name.toUpperCase(), + })); + + const result = await $`echo '{"name":"hello"}'`.safeParse(schema); + + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.uppercase).toBe('HELLO'); + } + }); + }); }); From ad066a5aa38ac38c8d544f3cc3a444b237bf3633 Mon Sep 17 00:00:00 2001 From: Thada Wangthammang Date: Sun, 9 Nov 2025 23:35:06 +0700 Subject: [PATCH 09/19] refactor: replace CommandResult with RunResult in LazyCommandHandle for non-throwable execution --- src/shell.ts | 32 ++++---------------------------- 1 file changed, 4 insertions(+), 28 deletions(-) diff --git a/src/shell.ts b/src/shell.ts index 2e587aa..c657c2e 100644 --- a/src/shell.ts +++ b/src/shell.ts @@ -273,30 +273,6 @@ export type CommandHandle = PromiseLike & { */ export type FluentShellFn = (command: string | string[]) => CommandHandle; -/** - * Result type for non-throwable command execution. - * Returned by the `.result()` method on LazyCommandHandle. - * - * @example - * ```typescript - * const $ = createShell().asFluent(); - * const r = await $`exit 1`.result(); - * if (!r.success) { - * console.error(`Command failed with exit code ${r.exitCode}`); - * console.error(`Stderr: ${r.stderr}`); - * } - * ``` - */ -export type CommandResult = { - /** True if the command exited with code 0 */ - success: boolean; - /** Captured stdout output */ - stdout: string; - /** Captured stderr output */ - stderr: string; - /** Exit code (undefined if process failed to start) */ - exitCode: number | undefined; -}; /** * Command handle with lazy execution and memoization. @@ -341,9 +317,9 @@ export type LazyCommandHandle = PromiseLike & { * Non-throwable execution - returns result object with success flag. * This method never throws, even if the command fails. * - * @returns Promise resolving to CommandResult with success, stdout, stderr, and exitCode + * @returns Promise resolving to RunResult with success, stdout, stderr, and exitCode */ - result(): Promise; + result(): Promise>; /** * Split stdout by newlines and return as array of strings. @@ -851,10 +827,10 @@ export class Shell { */ private createLazyHandle(command: string | string[]): LazyCommandHandle { // Memoized execution promise - null until first consumption - let executionPromise: Promise | null = null; + let executionPromise: Promise> | null = null; // Lazy executor - runs command once and memoizes result - const start = (): Promise => { + const start = (): Promise> => { if (executionPromise === null) { executionPromise = this.safeRun(command, { outputMode: 'capture' }).then(result => ({ success: result.success, From 1be54482956673e51a7109f847c245cac5d2cbe9 Mon Sep 17 00:00:00 2001 From: Thada Wangthammang Date: Sun, 9 Nov 2025 23:35:50 +0700 Subject: [PATCH 10/19] refactor: rename StandardResult to ValidationResult for consistency in schema validation --- README.md | 2 +- examples/taggedTemplates.ts | 2 +- src/shell.ts | 14 +++++++------- src/standard-schema.ts | 4 ++-- 4 files changed, 11 insertions(+), 11 deletions(-) diff --git a/README.md b/README.md index fdc64a9..a0eec1d 100644 --- a/README.md +++ b/README.md @@ -412,7 +412,7 @@ Execute a command, parse its stdout as JSON, and validate it against a Standard #### Returns ```typescript -type StandardResult = +type ValidationResult = | { success: true; data: T } | { success: false; error: Array<{ message: string }> }; ``` diff --git a/examples/taggedTemplates.ts b/examples/taggedTemplates.ts index 5482a4a..35f98e2 100644 --- a/examples/taggedTemplates.ts +++ b/examples/taggedTemplates.ts @@ -100,7 +100,7 @@ async function main() { console.log(`Package: ${pkg.name} v${pkg.version}\n`); console.log('--- Example 11: safeParse() - Non-throwable Parsing ---'); - // safeParse() never throws, returns StandardResult instead + // safeParse() never throws, returns ValidationResult instead const ConfigSchema = z.object({ name: z.string(), version: z.string(), diff --git a/src/shell.ts b/src/shell.ts index c657c2e..33b53a5 100644 --- a/src/shell.ts +++ b/src/shell.ts @@ -2,7 +2,7 @@ import type { StandardSchemaV1 } from '@standard-schema/spec'; import { execa, type Options as ExecaOptions, ExecaError } from 'execa'; import parseArgsStringToArgv from 'string-argv'; import deepmerge from 'deepmerge'; -import { standardSafeValidate, standardValidate, type StandardResult } from './standard-schema.js'; +import { standardSafeValidate, standardValidate, type ValidationResult } from './standard-schema.js'; /** * Output mode behavior for handling stdout/stderr. @@ -343,7 +343,7 @@ export type LazyCommandHandle = PromiseLike & { /** * Parse stdout as JSON and validate with schema (non-throwable). - * Returns StandardResult instead of throwing on failure. + * Returns ValidationResult instead of throwing on failure. * * This method never throws, even if: * - The command fails @@ -353,7 +353,7 @@ export type LazyCommandHandle = PromiseLike & { * * @template T - A Standard Schema V1 schema type * @param schema - A Standard Schema V1 compatible schema - * @returns Promise resolving to StandardResult with either data or error + * @returns Promise resolving to ValidationResult with either data or error * * @example * ```typescript @@ -366,7 +366,7 @@ export type LazyCommandHandle = PromiseLike & { * } * ``` */ - safeParse(schema: T): Promise>>; + safeParse(schema: T): Promise>>; }; /** @@ -766,7 +766,7 @@ export class Shell { cmd: string | string[], schema: T, options?: RunOptions - ): Promise>> { + ): Promise>> { const result = await this.safeRun(cmd, options); const verbose = options?.verbose ?? this.verbose; const fullCommand = Array.isArray(cmd) ? cmd.join(' ') : cmd; @@ -894,10 +894,10 @@ export class Shell { }; // Helper method: parse JSON and validate with schema (non-throwable) - // Returns StandardResult instead of throwing + // Returns ValidationResult instead of throwing handle.safeParse = ( schema: T - ): Promise>> => { + ): Promise>> => { return start().then(result => { const args = Array.isArray(command) ? command : parseArgsStringToArgv(command); const commandStr = args.join(' '); diff --git a/src/standard-schema.ts b/src/standard-schema.ts index a1c4f65..cbedfd8 100644 --- a/src/standard-schema.ts +++ b/src/standard-schema.ts @@ -19,7 +19,7 @@ export async function standardValidate( return result.value; } -export type StandardResult = +export type ValidationResult = | { success: true; data: T; @@ -32,7 +32,7 @@ export type StandardResult = export async function standardSafeValidate( schema: T, input: StandardSchemaV1.InferInput -): Promise>> { +): Promise>> { let result = schema['~standard'].validate(input); if (result instanceof Promise) result = await result; From be56ce51e870832917f325890ff933675328f17b Mon Sep 17 00:00:00 2001 From: Thada Wangthammang Date: Sun, 9 Nov 2025 23:37:02 +0700 Subject: [PATCH 11/19] refactor: rename RunResult to ExecutionResult for clarity in command execution --- src/shell.ts | 22 +++++++++++----------- 1 file changed, 11 insertions(+), 11 deletions(-) diff --git a/src/shell.ts b/src/shell.ts index 33b53a5..d83550c 100644 --- a/src/shell.ts +++ b/src/shell.ts @@ -218,7 +218,7 @@ export interface SafeResult extends StrictResult = Throw extends true +export type ExecutionResult = Throw extends true ? StrictResult> : SafeResult>; @@ -317,9 +317,9 @@ export type LazyCommandHandle = PromiseLike & { * Non-throwable execution - returns result object with success flag. * This method never throws, even if the command fails. * - * @returns Promise resolving to RunResult with success, stdout, stderr, and exitCode + * @returns Promise resolving to ExecutionResult with success, stdout, stderr, and exitCode */ - result(): Promise>; + result(): Promise>; /** * Split stdout by newlines and return as array of strings. @@ -601,7 +601,7 @@ export class Shell { public async execute( cmd: string | string[], options?: RunOptions & { throwOnError?: Throw } - ): Promise> { + ): Promise> { const args = Array.isArray(cmd) ? cmd : parseArgsStringToArgv(cmd); const [program, ...cmdArgs] = args; @@ -643,7 +643,7 @@ export class Shell { } if (dryRun) { - return { stdout: '', stderr: '', exitCode: 0, success: true } as RunResult; + return { stdout: '', stderr: '', exitCode: 0, success: true } as ExecutionResult; } try { @@ -654,7 +654,7 @@ export class Shell { stderr: result.stderr ? String(result.stderr) : null, exitCode: result.exitCode, success: result.exitCode === 0, - } as RunResult; + } as ExecutionResult; } catch (error: unknown) { if (error instanceof ExecaError) { if (options?.throwOnError) { @@ -672,7 +672,7 @@ export class Shell { stderr: null, exitCode: undefined, success: false, - } as RunResult; + } as ExecutionResult; } } @@ -705,7 +705,7 @@ export class Shell { public async run( cmd: string | string[], options?: RunOptions - ): Promise> { + ): Promise> { return this.execute(cmd, { ...options, throwOnError: true }); } @@ -738,7 +738,7 @@ export class Shell { public async safeRun( cmd: string | string[], options?: RunOptions - ): Promise> { + ): Promise> { return this.execute(cmd, { ...options, throwOnError: false }); } @@ -827,10 +827,10 @@ export class Shell { */ private createLazyHandle(command: string | string[]): LazyCommandHandle { // Memoized execution promise - null until first consumption - let executionPromise: Promise> | null = null; + let executionPromise: Promise> | null = null; // Lazy executor - runs command once and memoizes result - const start = (): Promise> => { + const start = (): Promise> => { if (executionPromise === null) { executionPromise = this.safeRun(command, { outputMode: 'capture' }).then(result => ({ success: result.success, From c7b591c0c86057504d8223e44152a53bb4b40980 Mon Sep 17 00:00:00 2001 From: Thada Wangthammang Date: Sun, 9 Nov 2025 23:58:20 +0700 Subject: [PATCH 12/19] docs: add FluentShell design spec with output mode policies and type definitions --- .agent/task-3/pre-dev-plan.md | 579 ++++++++++++++++++++++++++++++++++ .agent/task-3/spec.md | 198 ++++++++++++ 2 files changed, 777 insertions(+) create mode 100644 .agent/task-3/pre-dev-plan.md create mode 100644 .agent/task-3/spec.md diff --git a/.agent/task-3/pre-dev-plan.md b/.agent/task-3/pre-dev-plan.md new file mode 100644 index 0000000..2da10f4 --- /dev/null +++ b/.agent/task-3/pre-dev-plan.md @@ -0,0 +1,579 @@ +# FluentShell API Enhancement - Pre-Development Plan + +## Overview + +Enhance FluentShell (`asFluent()` / `$`) to align with `ShellOptions` while maintaining safety and type correctness. The fluent API will support `capture` and `all` modes but explicitly reject `live` mode since fluent operations require stdout for chaining, parsing, and memoization. + +## Design Goals + +1. **Consistency**: FluentShell should respect `ShellOptions` configuration +2. **Safety**: Prevent `live` mode usage which breaks fluent operations +3. **Flexibility**: Allow per-command `outputMode` overrides (except `live`) +4. **Type Safety**: Enforce constraints at compile-time where possible +5. **Backward Compatibility**: Maintain existing tagged template and function call patterns + +--- + +## Current State Analysis + +### Existing Implementation (`src/shell.ts`) + +**Current `DollarFunction` signature** (lines ~424-439): +```typescript +export interface DollarFunction { + (parts: TemplateStringsArray, ...values: any[]): LazyCommandHandle; + (command: string): LazyCommandHandle; + (command: string[]): LazyCommandHandle; +} +``` + +**Current `createLazyHandle()` implementation** (lines ~828-972): +- Hardcodes `outputMode: 'capture'` at line 859 +- No options parameter - always uses capture mode +- Returns `Promise>` + +**Current `asFluent()` implementation** (lines ~1024-1037): +- No validation of shell's `outputMode` +- Always creates lazy handles with capture mode +- No per-command options support + +--- + +## Required Changes + +### 1. Type Definitions + +#### 1.1 Add `FluentOutputMode` type +**Location**: After `OutputMode` type definition (after line 14) + +```typescript +/** + * Output modes supported by FluentShell. + * Excludes 'live' mode since fluent operations require stdout for chaining and parsing. + */ +export type FluentOutputMode = Exclude; +``` + +#### 1.2 Add `FluentRunOptions` type +**Location**: After `RunOptions` definition (after line 187) + +```typescript +/** + * Options for fluent shell command execution. + * Restricts outputMode to exclude 'live' mode. + * + * @template Mode - The output mode for this command (capture or all only) + */ +export type FluentRunOptions = + Omit, 'outputMode'> & { + outputMode?: Mode; + }; +``` + +#### 1.3 Update `DollarFunction` interface +**Location**: Replace existing definition (lines ~424-439) + +```typescript +/** + * Function that supports both tagged templates and function calls for command execution. + * Returned by `shell.asFluent()`. + * + * Supports three call signatures: + * 1. Tagged template: `` $`echo hello` `` + * 2. String command: `$('echo hello')` or `$('echo hello', { outputMode: 'all' })` + * 3. Argv array: `$(['echo', 'hello'])` or `$(['echo', 'hello'], { outputMode: 'all' })` + * + * Note: FluentShell does not support 'live' mode. Use 'capture' or 'all' only. + * + * @example Tagged template + * ```typescript + * const $ = createShell().asFluent(); + * const name = 'world'; + * const result = await $`echo hello ${name}`; + * ``` + * + * @example Function call with options + * ```typescript + * const $ = createShell().asFluent(); + * const result = await $('echo hello', { outputMode: 'all' }); + * ``` + * + * @example Array call with options + * ```typescript + * const $ = createShell().asFluent(); + * const result = await $(['echo', 'hello'], { outputMode: 'all' }); + * ``` + */ +export interface DollarFunction { + /** + * Tagged template call - interpolates values into command string + */ + (parts: TemplateStringsArray, ...values: any[]): LazyCommandHandle; + + /** + * String or array command call with optional fluent options + */ + (command: string | string[], options?: FluentRunOptions): LazyCommandHandle; +} +``` + +--- + +### 2. Helper Method - `assertFluentMode()` + +**Location**: Add as private method in `Shell` class (after `processTaggedTemplate`, around line 840) + +```typescript +/** + * Validates that the output mode is compatible with FluentShell. + * Throws an error if 'live' mode is used. + * + * @param mode - The output mode to validate + * @throws {Error} If mode is 'live' + * + * @internal + */ +private assertFluentMode(mode: OutputMode): asserts mode is FluentOutputMode { + if (mode === 'live') { + throw new Error( + "FluentShell does not support outputMode: 'live'. " + + "Use 'capture' or 'all', or call shell.run(..., { outputMode: 'live' }) instead." + ); + } +} +``` + +--- + +### 3. Update `createLazyHandle()` Method + +**Location**: Modify existing method (lines ~828-972) + +**Changes**: +1. Add `options` parameter with type `FluentRunOptions` +2. Remove hardcoded `outputMode: 'capture'` at line 859 +3. Pass options through to `safeRun()` +4. Update return type to use `FluentOutputMode` +5. Update promise type to `Promise>` + +**New signature**: +```typescript +/** + * Create a lazy command handle with memoized execution. + * The command doesn't execute until first consumption (await, .result(), .toLines(), .parse()). + * Multiple consumptions share the same execution result. + * + * @param command - Command to execute (string or array) + * @param options - Fluent execution options (validated to not use 'live' mode) + * @returns LazyCommandHandle with deferred execution + * + * @internal + */ +private createLazyHandle( + command: string | string[], + options: FluentRunOptions +): LazyCommandHandle { + let executionPromise: Promise> | null = null; + + const start = (): Promise> => { + if (executionPromise === null) { + // Pass options directly to safeRun (outputMode already validated) + executionPromise = this.safeRun(command, options as RunOptions); + } + return executionPromise; + }; + + // ... rest of implementation (handle.then, handle.result, etc.) +} +``` + +**Key change at line 859**: +- **Before**: `executionPromise = this.safeRun(command, { outputMode: 'capture' }).then(result => ({...}))` +- **After**: `executionPromise = this.safeRun(command, options as RunOptions)` +- **Remove** the `.then()` transformation (lines 859-864) since we're no longer forcing capture mode + +--- + +### 4. Update `asFluent()` Method + +**Location**: Modify existing method (lines ~1024-1037) + +**Changes**: +1. Add validation to reject `live` mode at shell level +2. Handle options parameter in function call path +3. Determine effective output mode (options → shell default) +4. Validate effective mode before creating lazy handle +5. Pass options to `createLazyHandle()` + +**New implementation**: +```typescript +/** + * Create a fluent shell function with tagged template and lazy execution support. + * + * Returns a function that supports: + * - Tagged templates: `` $`echo hello` `` + * - Function calls: `$('echo hello')` or `$(['echo', 'hello'], { outputMode: 'all' })` + * - Lazy execution: command doesn't run until consumed + * - Memoization: multiple consumptions share one execution + * - Non-throwable path: `.result()` returns result with success flag + * + * Note: FluentShell requires stdout for chaining, so 'live' mode is not supported. + * If the shell instance has `outputMode: 'live'`, this method will throw an error. + * + * @returns DollarFunction that supports tagged templates and function calls + * + * @throws {Error} If shell instance has `outputMode: 'live'` + * + * @example Tagged template with shell default mode + * ```typescript + * const shell = createShell({ outputMode: 'capture' }); + * const $ = shell.asFluent(); + * const result = await $`echo hello`; // Uses 'capture' mode + * ``` + * + * @example Function call with mode override + * ```typescript + * const shell = createShell({ outputMode: 'capture' }); + * const $ = shell.asFluent(); + * const result = await $('echo hello', { outputMode: 'all' }); // Uses 'all' mode + * ``` + * + * @example Error case - live mode not supported + * ```typescript + * const shell = createShell({ outputMode: 'live' }); + * shell.asFluent(); // ❌ Throws error + * ``` + */ +public asFluent(): DollarFunction { + // Validate shell-level outputMode + this.assertFluentMode(this.outputMode); + + return ((firstArg: any, ...rest: any[]): LazyCommandHandle => { + // Detect if it's a tagged template call + if (Array.isArray(firstArg) && 'raw' in firstArg && Array.isArray((firstArg as any).raw)) { + // Tagged template: process interpolation, use shell default mode + const command = this.processTaggedTemplate(firstArg as TemplateStringsArray, rest); + const mode = this.outputMode; + + // Mode already validated at asFluent() level, but assert for type narrowing + this.assertFluentMode(mode); + + return this.createLazyHandle(command, { outputMode: mode } as FluentRunOptions); + } + + // Function call: string or array with optional options + const command = firstArg as string | string[]; + const options = rest[0] as FluentRunOptions | undefined; + + // Determine effective output mode (options override shell default) + const effectiveMode = (options?.outputMode ?? this.outputMode) as OutputMode; + + // Validate effective mode + this.assertFluentMode(effectiveMode); + + // Create lazy handle with effective options + const effectiveOptions: FluentRunOptions = { + ...(options ?? {}), + outputMode: effectiveMode, + }; + + return this.createLazyHandle(command, effectiveOptions); + }) as DollarFunction; +} +``` + +--- + +### 5. Update `LazyCommandHandle` Return Types + +**Location**: `LazyCommandHandle` type definition (lines ~315-394) + +**Changes**: +- Update `result()` return type to support both `capture` and `all` modes +- Currently uses `RunResult`, should use `RunResult` + +**Note**: This may require making `LazyCommandHandle` generic or keeping it flexible enough to handle both modes. Consider type implications carefully. + +**Option A - Keep simple (recommended)**: +```typescript +result(): Promise>; +``` + +**Option B - Make generic**: +```typescript +export type LazyCommandHandle = PromiseLike & { + result(): Promise>; + // ... other methods +} +``` + +Recommendation: Use **Option A** for simplicity. The nullability difference between modes is handled at runtime. + +--- + +## Implementation Order + +### Phase 1: Type Definitions +1. Add `FluentOutputMode` type +2. Add `FluentRunOptions` type +3. Update `DollarFunction` interface + +### Phase 2: Validation Logic +4. Add `assertFluentMode()` helper method + +### Phase 3: Core Implementation +5. Update `createLazyHandle()` signature and implementation +6. Update `asFluent()` implementation + +### Phase 4: Type Alignment +7. Update `LazyCommandHandle.result()` return type (if needed) +8. Fix any type issues in the lazy handle implementation + +### Phase 5: Testing & Documentation +9. Add comprehensive tests (see test cases below) +10. Update JSDoc comments for all modified code +11. Verify type safety with `pnpm check-types` +12. Run full test suite with `pnpm test:ci` + +--- + +## Test Cases + +### Test File Location: `test/shell.test.ts` + +Add new test suite: "Fluent Shell API - OutputMode Support" + +#### Test 1: Shell with capture mode + tagged template +```typescript +test('should work with capture mode (default)', async () => { + const shell = createShell({ outputMode: 'capture' }); + const $ = shell.asFluent(); + const result = await $`echo hello`; + expect(result).toBe('hello'); +}); +``` + +#### Test 2: Shell with all mode + function call +```typescript +test('should work with all mode', async () => { + const shell = createShell({ outputMode: 'all' }); + const $ = shell.asFluent(); + const result = await $(['echo', 'world']).result(); + expect(result.success).toBe(true); + expect(result.stdout).toBe('world'); +}); +``` + +#### Test 3: Shell with live mode should reject asFluent() +```typescript +test('should throw when shell has live mode', () => { + const shell = createShell({ outputMode: 'live' }); + expect(() => shell.asFluent()).toThrow( + "FluentShell does not support outputMode: 'live'" + ); +}); +``` + +#### Test 4: Override to live mode should throw +```typescript +test('should throw when overriding to live mode', async () => { + const shell = createShell({ outputMode: 'capture' }); + const $ = shell.asFluent(); + await expect(async () => { + await $(['echo', 'x'], { outputMode: 'live' as any }); + }).rejects.toThrow("FluentShell does not support outputMode: 'live'"); +}); +``` + +#### Test 5: Override to all mode should work +```typescript +test('should allow overriding to all mode', async () => { + const shell = createShell({ outputMode: 'capture' }); + const $ = shell.asFluent(); + const result = await $(['echo', 'test'], { outputMode: 'all' }).result(); + expect(result.success).toBe(true); + expect(result.stdout).toBe('test'); +}); +``` + +#### Test 6: Inherit mode from ShellOptions +```typescript +test('should inherit outputMode from ShellOptions', async () => { + const shell = createShell({ outputMode: 'all' }); + const $ = shell.asFluent(); + const result = await $('echo inherited').result(); + expect(result.success).toBe(true); + expect(result.stdout).toBe('inherited'); +}); +``` + +#### Test 7: Helper methods work in all mode +```typescript +test('should support toLines() in all mode', async () => { + const shell = createShell({ outputMode: 'all' }); + const $ = shell.asFluent(); + const lines = await $`echo -e "line1\nline2"`.toLines(); + expect(lines).toEqual(['line1', 'line2']); +}); + +test('should support parse() in all mode', async () => { + const shell = createShell({ outputMode: 'all' }); + const $ = shell.asFluent(); + const schema = z.object({ value: z.string() }); + const data = await $`echo '{"value":"test"}'`.parse(schema); + expect(data.value).toBe('test'); +}); +``` + +#### Test 8: Memoization works with options +```typescript +test('should memoize execution with options', async () => { + const shell = createShell({ outputMode: 'capture' }); + const $ = shell.asFluent(); + const handle = $(['echo', 'memo'], { outputMode: 'all' }); + + const result1 = await handle.result(); + const result2 = await handle.result(); + + expect(result1).toBe(result2); // Same object reference +}); +``` + +#### Test 9: Error message validation +```typescript +test('should provide clear error message for live mode', () => { + const shell = createShell({ outputMode: 'live' }); + expect(() => shell.asFluent()).toThrow( + "FluentShell does not support outputMode: 'live'. " + + "Use 'capture' or 'all', or call shell.run(..., { outputMode: 'live' }) instead." + ); +}); +``` + +--- + +## Breaking Changes + +### User-Facing Changes + +1. **None for existing usage**: All current code using `$` without options continues to work +2. **New capability**: Users can now pass options to `$` function calls +3. **New restriction**: Shells with `outputMode: 'live'` cannot call `.asFluent()` + +### Internal Changes + +1. `DollarFunction` signature expanded (backward compatible) +2. `createLazyHandle()` signature changed (private method) +3. `asFluent()` implementation changed (behavior preserved for valid cases) + +--- + +## Type Safety Considerations + +### Compile-Time Safety + +1. `FluentOutputMode` excludes `'live'` at type level +2. `FluentRunOptions` enforces valid modes in type signature +3. `assertFluentMode()` provides type assertion for narrowing + +### Runtime Safety + +1. Validation at `asFluent()` level (shell configuration) +2. Validation at per-command level (options override) +3. Clear error messages guide users to alternatives + +### Edge Cases + +1. **Type casting workaround**: User forces `{ outputMode: 'live' as any }` + - Caught by runtime validation in `assertFluentMode()` + - Throws with helpful error message + +2. **Null/undefined stdout in 'all' mode**: + - `RunResult` = `SafeResult` with `stdout: string | null` + - Existing code handles this correctly + - Helper methods like `.parse()` check for null stdout + +--- + +## Documentation Updates + +### Files to Update + +1. **README.md**: Add examples of fluent API with `all` mode and options +2. **CLAUDE.md**: Document the new FluentShell behavior and restrictions +3. **JSDoc comments**: Already included in code examples above + +### Key Points to Document + +1. FluentShell supports `capture` and `all` modes only +2. `live` mode incompatible with fluent operations (why: no stdout for chaining) +3. Per-command mode override via options parameter +4. Error handling and error messages +5. Type safety guarantees + +--- + +## Checklist + +### Before Implementation +- [ ] Review spec.md and ensure understanding +- [ ] Review current implementation in `src/shell.ts` +- [ ] Plan test cases +- [ ] Consider edge cases and error scenarios + +### During Implementation +- [ ] Add `FluentOutputMode` type +- [ ] Add `FluentRunOptions` type +- [ ] Update `DollarFunction` interface +- [ ] Add `assertFluentMode()` method +- [ ] Update `createLazyHandle()` signature and implementation +- [ ] Update `asFluent()` implementation +- [ ] Update `LazyCommandHandle` return types if needed +- [ ] Add JSDoc comments +- [ ] Fix any TypeScript errors + +### After Implementation +- [ ] Run `pnpm check-types` (must pass) +- [ ] Run `pnpm test:ci` (must pass) +- [ ] Add new test suite for FluentShell outputMode support +- [ ] Verify all test cases pass +- [ ] Check code coverage (maintain >97%) +- [ ] Manual testing with various scenarios +- [ ] Update documentation (README, CLAUDE.md) +- [ ] Review error messages for clarity + +--- + +## Success Criteria + +1. ✅ All existing tests pass without modification +2. ✅ New test suite covers all specified test cases +3. ✅ TypeScript compilation succeeds with no errors +4. ✅ Code coverage remains above 97% +5. ✅ Error messages are clear and actionable +6. ✅ API is backward compatible for existing usage +7. ✅ Type safety prevents invalid mode usage at compile time +8. ✅ Runtime validation catches edge cases (type casting workarounds) +9. ✅ Documentation clearly explains new capabilities and restrictions + +--- + +## Estimated Complexity + +- **Type Definitions**: Low complexity, straightforward exclusion and extension +- **Validation Logic**: Low complexity, simple assertion function +- **Core Implementation**: Medium complexity, requires careful handling of options merging +- **Testing**: Medium complexity, comprehensive coverage of modes and edge cases +- **Documentation**: Low complexity, mostly examples and explanations + +**Total Estimated Effort**: 2-3 hours for implementation and testing + +--- + +## References + +- **Spec Document**: `.agent/task-3/spec.md` +- **Current Implementation**: `src/shell.ts` lines 509-1039 (Shell class) +- **Type Definitions**: `src/shell.ts` lines 1-223 +- **Test File**: `test/shell.test.ts` +- **CLAUDE.md**: Project documentation for AI assistants diff --git a/.agent/task-3/spec.md b/.agent/task-3/spec.md new file mode 100644 index 0000000..d3e95a8 --- /dev/null +++ b/.agent/task-3/spec.md @@ -0,0 +1,198 @@ + + +🧩 FluentShell Design Spec (v2) + +1. วัตถุประสงค์ + • ทำให้ FluentShell (asFluent() / $) สอดคล้องกับ ShellOptions + • จำกัดการใช้งานให้รองรับเฉพาะ capture และ all mode +(เนื่องจาก FluentShell ต้องมี stdout สำหรับการ parse และ memoize) + • อนุญาตให้ $ override outputMode ต่อคำสั่งได้ แต่ห้ามใช้ 'live' + • คงรูปแบบการเรียกทั้ง “tagged template” และ “function call” ไว้เหมือนเดิม + +⸻ + +2. OutputMode Policy + +Mode FluentShell เหตุผล +'capture' ✅ อนุญาต มี stdout สำหรับ parse และ memoize +'all' ✅ อนุญาต แสดงผลสด + ยัง capture stdout ได้ +'live' ❌ ห้ามใช้ ไม่มี stdout → chain ต่อไม่ได้ + + +⸻ + +3. Type Definitions + +/** Mode ที่ FluentShell อนุญาตให้ใช้ */ +type FluentOutputMode = Exclude; + +/** RunOptions ที่จำกัดไม่ให้ใช้ live mode */ +type FluentRunOptions = + Omit, 'outputMode'> & { + outputMode?: M; + }; + +/** + * FluentShell function type + * รองรับทั้ง template call และ function call + */ +export interface DollarFunction { + /** Tagged template call — `` $`echo ${name}` `` */ + (parts: TemplateStringsArray, ...values: any[]): LazyCommandHandle; + + /** Function call — $('echo hi') หรือ $(['echo', 'hi'], { ...options }) */ + (command: string | string[], options?: FluentRunOptions): LazyCommandHandle; +} + + +⸻ + +4. พฤติกรรมการเลือก outputMode + +ลำดับการตัดสินใจ (effective mode) + 1. ถ้าเรียก $([...], options) → ใช้ options.outputMode + 2. ถ้าไม่มีใน options → ใช้ this.outputMode จาก ShellOptions + 3. ถ้า mode ที่ได้คือ 'live' → throw Error + +⸻ + +5. พฤติกรรม asFluent() + +กติกา + • ถ้า Shell ถูกสร้างด้วย outputMode: 'live' → throw ทันทีเมื่อเรียก asFluent() + • ภายใน $ ต้องตรวจอีกชั้น เผื่อผู้ใช้ส่ง { outputMode: 'live' } ใน options + +ตัวอย่างโค้ด (pseudo-implementation) + +public asFluent(): DollarFunction { + if (this.outputMode === 'live') { + throw new Error( + "FluentShell does not support `outputMode: 'live'`. " + + "Use `shell.run(..., { outputMode: 'live' })` instead." + ); + } + + const $impl = (first: any, ...rest: any[]): LazyCommandHandle => { + // ✅ Tagged template + if (Array.isArray(first) && 'raw' in first) { + const command = this.processTaggedTemplate(first as TemplateStringsArray, rest); + const mode = this.outputMode; + this.assertFluentMode(mode); + return this.createLazyHandle(command, { outputMode: mode }); + } + + // ✅ Function call + const command = first as string | string[]; + const maybeOptions = rest[0] as FluentRunOptions | undefined; + const mode = (maybeOptions?.outputMode ?? this.outputMode) as OutputMode; + this.assertFluentMode(mode); + return this.createLazyHandle(command, { ...(maybeOptions ?? {}), outputMode: mode }); + }; + + return $impl as DollarFunction; +} + +private assertFluentMode(mode: OutputMode) { + if (mode === 'live') { + throw new Error( + "FluentShell does not support `outputMode: 'live'`. " + + "Use 'capture' or 'all', or call `shell.run(..., { outputMode: 'live' })`." + ); + } +} + + +⸻ + +6. createLazyHandle() Behavior + • รับ RunOptions (ที่ผ่าน assert แล้วว่าไม่ใช่ live) + • ใช้โหมดที่ได้จริง (effectiveOptions.outputMode) + • ไม่บังคับ capture ภายในอีกต่อไป +(ใช้ค่าที่ resolve แล้วจาก ShellOptions หรือ override) + +private createLazyHandle( + command: string | string[], + effectiveOptions: RunOptions +): LazyCommandHandle { + let executionPromise: Promise> | null = null; + + const start = (): Promise> => { + if (!executionPromise) { + executionPromise = this.safeRun(command, effectiveOptions); + } + return executionPromise; + }; + + // ... other fluent methods: await handle / result / toLines / parse / safeParse + return handle as LazyCommandHandle; +} + + +⸻ + +7. ตัวอย่างการใช้งาน + +✅ ใช้ค่าจาก ShellOptions (capture mode) + +const shell = createShell({ outputMode: 'capture' }); +const $ = shell.asFluent(); + +const text = await $`echo hello`; +console.log(text); // 'hello' + +✅ ใช้ all mode (แสดงผล + capture) + +const shell = createShell({ outputMode: 'all' }); +const $ = shell.asFluent(); + +const r = await $(['echo', 'world'], { outputMode: 'all' }).result(); +console.log(r.stdout); // 'world' + +❌ พยายามใช้ live mode + +const shell = createShell({ outputMode: 'live' }); +shell.asFluent(); // ❌ throw Error: "FluentShell does not support live mode" + +const $ = createShell({ outputMode: 'capture' }).asFluent(); +await $(["echo", "x"], { outputMode: "live" }); // ❌ throw Error + + +⸻ + +8. ข้อความ error มาตรฐาน + +FluentShell does not support outputMode: 'live'. +Use 'capture' or 'all', or call shell.run(..., { outputMode: 'live' }) instead. + +⸻ + +9. Test Cases ที่ควรมี + +Case Input Expected +Shell capture mode + $ template $ ใช้งานได้ คืน stdout ✅ +Shell all mode + $ string call $(['echo','x']) → แสดงผล + capture stdout ✅ +Shell live mode + .asFluent() throw ✅ +$([...], { outputMode: 'live' }) throw ✅ +$([...], { outputMode: 'all' }) ทำงานได้, แสดงผล + capture ✅ +$([...]) → inherit จาก ShellOptions ถูกต้องตาม mode ✅ +.toLines(), .parse() ใน all mode ทำงานได้ ✅ +.result() memoize ได้ ✅ + + +⸻ + +🔧 สรุปการเปลี่ยนแปลงหลักจากสเปคเดิม + +เดิม ใหม่ +บังคับ capture ในทุก $ ยึดค่าจาก ShellOptions +ไม่มี override ต่อคำสั่ง เพิ่ม override ผ่าน $([...], options) +ไม่ตรวจ live mode ตรวจสองชั้น (ใน asFluent() และในแต่ละ $) +Signature ของ $ มี overload แยก no-opt รวมเป็น `(command: string + + +⸻ + +ผลลัพธ์สุดท้าย: +FluentShell จะทำงาน ปลอดภัย, predictable, type-safe, +รองรับทั้งโหมด capture และ all, +และมี DX ที่ดีด้วย error message ชัดเจนสำหรับ live mode. \ No newline at end of file From c950eeaaea58de35ebfffc793ad0525ef5140da6 Mon Sep 17 00:00:00 2001 From: Thada Wangthammang Date: Mon, 10 Nov 2025 00:10:50 +0700 Subject: [PATCH 13/19] test: enhance FluentShell output mode tests and ensure 'live' mode is rejected --- src/shell.ts | 133 ++++++++++++++++++++++++++-------- test/shell.test.ts | 174 ++++++++++++++++++++++++++++++++++++++++++--- 2 files changed, 269 insertions(+), 38 deletions(-) diff --git a/src/shell.ts b/src/shell.ts index d83550c..9209315 100644 --- a/src/shell.ts +++ b/src/shell.ts @@ -13,6 +13,12 @@ import { standardSafeValidate, standardValidate, type ValidationResult } from '. */ export type OutputMode = 'capture' | 'live' | 'all'; +/** + * Output modes supported by FluentShell. + * Excludes 'live' mode since fluent operations require stdout for chaining and parsing. + */ +export type FluentOutputMode = Exclude; + /** * Type utility to determine if an output mode captures output. * Returns false for 'live' mode, true for 'capture' and 'all'. @@ -186,6 +192,19 @@ export interface RunOptions extends Omit, 'execaOptions'>, ShellExecaOptions {} +/** + * Options for fluent shell command execution. + * Restricts outputMode to exclude 'live' mode. + * + * @template Mode - The output mode for this command (capture or all only) + */ +export type FluentRunOptions = Omit< + RunOptions, + 'outputMode' +> & { + outputMode?: Mode; +}; + /** * Strict result returned by `run()` method (throws on error). * Only includes stdout/stderr, as the command either succeeds or throws. @@ -319,7 +338,7 @@ export type LazyCommandHandle = PromiseLike & { * * @returns Promise resolving to ExecutionResult with success, stdout, stderr, and exitCode */ - result(): Promise>; + result(): Promise>; /** * Split stdout by newlines and return as array of strings. @@ -375,8 +394,10 @@ export type LazyCommandHandle = PromiseLike & { * * Supports three call signatures: * 1. Tagged template: `` $`echo hello` `` - * 2. String command: `$('echo hello')` - * 3. Argv array: `$(['echo', 'hello'])` + * 2. String command: `$('echo hello')` or `$('echo hello', { outputMode: 'all' })` + * 3. Argv array: `$(['echo', 'hello'])` or `$(['echo', 'hello'], { outputMode: 'all' })` + * + * Note: FluentShell does not support 'live' mode. Use 'capture' or 'all' only. * * @example Tagged template with interpolation * ```typescript @@ -385,16 +406,16 @@ export type LazyCommandHandle = PromiseLike & { * const result = await $`echo hello ${name}`; * ``` * - * @example Function call with string + * @example Function call with options * ```typescript * const $ = createShell().asFluent(); - * const result = await $('echo hello'); + * const result = await $('echo hello', { outputMode: 'all' }); * ``` * - * @example Function call with array + * @example Array call with options * ```typescript * const $ = createShell().asFluent(); - * const result = await $(['echo', 'hello']); + * const result = await $(['echo', 'hello'], { outputMode: 'all' }); * ``` */ export interface DollarFunction { @@ -404,14 +425,9 @@ export interface DollarFunction { (parts: TemplateStringsArray, ...values: any[]): LazyCommandHandle; /** - * String command call + * String or array command call with optional fluent options */ - (command: string): LazyCommandHandle; - - /** - * Argv array call - */ - (command: string[]): LazyCommandHandle; + (command: string | string[], options?: FluentRunOptions): LazyCommandHandle; } /** @@ -815,24 +831,46 @@ export class Shell { return result; } + /** + * Validates that the output mode is compatible with FluentShell. + * Throws an error if 'live' mode is used. + * + * @param mode - The output mode to validate + * @throws {Error} If mode is 'live' + * + * @internal + */ + private assertFluentMode(mode: OutputMode): asserts mode is FluentOutputMode { + if (mode === 'live') { + throw new Error( + "FluentShell does not support outputMode: 'live'. " + + "Use 'capture' or 'all', or call shell.run(..., { outputMode: 'live' }) instead." + ); + } + } + /** * Create a lazy command handle with memoized execution. * The command doesn't execute until first consumption (await, .result(), .toLines(), .parse()). * Multiple consumptions share the same execution result. * * @param command - Command to execute (string or array) + * @param options - Fluent execution options (validated to not use 'live' mode) * @returns LazyCommandHandle with deferred execution * * @internal */ - private createLazyHandle(command: string | string[]): LazyCommandHandle { + private createLazyHandle( + command: string | string[], + options: FluentRunOptions + ): LazyCommandHandle { // Memoized execution promise - null until first consumption - let executionPromise: Promise> | null = null; + let executionPromise: Promise> | null = null; // Lazy executor - runs command once and memoizes result - const start = (): Promise> => { + const start = (): Promise> => { if (executionPromise === null) { - executionPromise = this.safeRun(command, { outputMode: 'capture' }).then(result => ({ + executionPromise = this.safeRun(command, options as RunOptions).then(result => ({ success: result.success, stdout: result.stdout ?? '', stderr: result.stderr ?? '', @@ -952,24 +990,36 @@ export class Shell { * * Returns a function that supports: * - Tagged templates: `` $`echo hello` `` - * - Function calls: `$('echo hello')` or `$(['echo', 'hello'])` + * - Function calls: `$('echo hello')` or `$(['echo', 'hello'], { outputMode: 'all' })` * - Lazy execution: command doesn't run until consumed * - Memoization: multiple consumptions share one execution * - Non-throwable path: `.result()` returns result with success flag * + * Note: FluentShell requires stdout for chaining, so 'live' mode is not supported. + * If the shell instance has `outputMode: 'live'`, this method will throw an error. + * * @returns DollarFunction that supports tagged templates and function calls * - * @example Tagged template + * @throws {Error} If shell instance has `outputMode: 'live'` + * + * @example Tagged template with shell default mode * ```typescript - * const $ = createShell().asFluent(); - * const name = 'world'; - * const result = await $`echo hello ${name}`; // 'hello world' + * const shell = createShell({ outputMode: 'capture' }); + * const $ = shell.asFluent(); + * const result = await $`echo hello`; // Uses 'capture' mode * ``` * - * @example Function call + * @example Function call with mode override * ```typescript - * const $ = createShell().asFluent(); - * const result = await $('echo hello'); // 'hello' + * const shell = createShell({ outputMode: 'capture' }); + * const $ = shell.asFluent(); + * const result = await $('echo hello', { outputMode: 'all' }); // Uses 'all' mode + * ``` + * + * @example Error case - live mode not supported + * ```typescript + * const shell = createShell({ outputMode: 'live' }); + * shell.asFluent(); // ❌ Throws error * ``` * * @example Non-throwable execution @@ -998,17 +1048,40 @@ export class Shell { * ``` */ public asFluent(): DollarFunction { + // Validate shell-level outputMode + this.assertFluentMode(this.outputMode); + return ((firstArg: any, ...rest: any[]): LazyCommandHandle => { // Detect if it's a tagged template call // Tagged templates pass TemplateStringsArray as first argument if (Array.isArray(firstArg) && 'raw' in firstArg && Array.isArray((firstArg as any).raw)) { - // Tagged template: process interpolation + // Tagged template: process interpolation, use shell default mode const command = this.processTaggedTemplate(firstArg as TemplateStringsArray, rest); - return this.createLazyHandle(command); + const mode = this.outputMode; + + // Mode already validated at asFluent() level, but assert for type narrowing + this.assertFluentMode(mode); + + return this.createLazyHandle(command, { outputMode: mode } as FluentRunOptions); } - // Function call: string or array - return this.createLazyHandle(firstArg); + // Function call: string or array with optional options + const command = firstArg as string | string[]; + const options = rest[0] as FluentRunOptions | undefined; + + // Determine effective output mode (options override shell default) + const effectiveMode = (options?.outputMode ?? this.outputMode) as OutputMode; + + // Validate effective mode + this.assertFluentMode(effectiveMode); + + // Create lazy handle with effective options + const effectiveOptions: FluentRunOptions = { + ...(options ?? {}), + outputMode: effectiveMode, + }; + + return this.createLazyHandle(command, effectiveOptions); }) as DollarFunction; } } diff --git a/test/shell.test.ts b/test/shell.test.ts index 277e537..64aaf4d 100644 --- a/test/shell.test.ts +++ b/test/shell.test.ts @@ -975,14 +975,11 @@ describe('Shell', () => { } }); - it('should always use capture mode', async () => { - // Even with live mode as default, fluent shell should capture - const $ = createShell({ outputMode: 'live' }).asFluent(); - - const result = await $('echo captured'); - - // Should still capture output despite shell default being live - expect(result).toBe('captured'); + it('should reject live mode at shell level', () => { + // Fluent shell should throw when shell has live mode + expect(() => createShell({ outputMode: 'live' }).asFluent()).toThrow( + "FluentShell does not support outputMode: 'live'" + ); }); }); @@ -1531,4 +1528,165 @@ describe('Shell', () => { } }); }); + + describe('Fluent Shell API - OutputMode Support', () => { + it('should work with capture mode (default)', async () => { + const shell = createShell({ outputMode: 'capture' }); + const $ = shell.asFluent(); + + const result = await $`echo hello`; + + expect(result).toBe('hello'); + }); + + it('should work with all mode from shell options', async () => { + const shell = createShell({ outputMode: 'all' }); + const $ = shell.asFluent(); + + const result = await $(['echo', 'world']).result(); + + expect(result.success).toBe(true); + expect(result.stdout).toBe('world'); + }); + + it('should throw when shell has live mode', () => { + const shell = createShell({ outputMode: 'live' }); + + expect(() => shell.asFluent()).toThrow( + "FluentShell does not support outputMode: 'live'. Use 'capture' or 'all', or call shell.run(..., { outputMode: 'live' }) instead." + ); + }); + + it('should throw when overriding to live mode', async () => { + const shell = createShell({ outputMode: 'capture' }); + const $ = shell.asFluent(); + + await expect(async () => { + await $(['echo', 'x'], { outputMode: 'live' as any }); + }).rejects.toThrow("FluentShell does not support outputMode: 'live'"); + }); + + it('should allow overriding to all mode', async () => { + const shell = createShell({ outputMode: 'capture' }); + const $ = shell.asFluent(); + + const result = await $(['echo', 'test'], { outputMode: 'all' }).result(); + + expect(result.success).toBe(true); + expect(result.stdout).toBe('test'); + }); + + it('should inherit outputMode from ShellOptions', async () => { + const shell = createShell({ outputMode: 'all' }); + const $ = shell.asFluent(); + + const result = await $('echo inherited').result(); + + expect(result.success).toBe(true); + expect(result.stdout).toBe('inherited'); + }); + + it('should support toLines() in all mode', async () => { + const shell = createShell({ outputMode: 'all' }); + const $ = shell.asFluent(); + + const lines = await $`printf "line1\\nline2"`.toLines(); + + expect(lines).toEqual(['line1', 'line2']); + }); + + it('should support parse() in all mode', async () => { + const shell = createShell({ outputMode: 'all' }); + const $ = shell.asFluent(); + const schema = z.object({ value: z.string() }); + + const data = await $`echo '{"value":"test"}'`.parse(schema); + + expect(data.value).toBe('test'); + }); + + it('should memoize execution with options', async () => { + const shell = createShell({ outputMode: 'capture' }); + const $ = shell.asFluent(); + const handle = $(['echo', 'memo'], { outputMode: 'all' }); + + const result1 = await handle.result(); + const result2 = await handle.result(); + + expect(result1.stdout).toBe('memo'); + expect(result2.stdout).toBe('memo'); + // Results should be the same object (memoized) + expect(result1).toBe(result2); + }); + + it('should provide clear error message for live mode at shell level', () => { + const shell = createShell({ outputMode: 'live' }); + + expect(() => shell.asFluent()).toThrow( + "FluentShell does not support outputMode: 'live'. Use 'capture' or 'all', or call shell.run(..., { outputMode: 'live' }) instead." + ); + }); + + it('should allow capture mode override on shell with all mode', async () => { + const shell = createShell({ outputMode: 'all' }); + const $ = shell.asFluent(); + + const result = await $(['echo', 'override'], { outputMode: 'capture' }).result(); + + expect(result.success).toBe(true); + expect(result.stdout).toBe('override'); + }); + + it('should work with tagged templates in all mode', async () => { + const shell = createShell({ outputMode: 'all' }); + const $ = shell.asFluent(); + const name = 'world'; + + const result = await $`echo hello ${name}`; + + expect(result).toBe('hello world'); + }); + + it('should handle options merging with outputMode override', async () => { + const shell = createShell({ outputMode: 'capture', verbose: true }); + const $ = shell.asFluent(); + + const result = await $(['echo', 'test'], { outputMode: 'all', dryRun: true }).result(); + + // In dry run mode, should return success without executing + expect(result.success).toBe(true); + expect(result.stdout).toBe(''); + }); + + it('should support safeParse() in all mode', async () => { + const shell = createShell({ outputMode: 'all' }); + const $ = shell.asFluent(); + const schema = z.object({ key: z.string() }); + + const result = await $`echo '{"key":"value"}'`.safeParse(schema); + + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.key).toBe('value'); + } + }); + + it('should work with function call without options', async () => { + const shell = createShell({ outputMode: 'capture' }); + const $ = shell.asFluent(); + + const result = await $('echo no-options'); + + expect(result).toBe('no-options'); + }); + + it('should work with array call without options', async () => { + const shell = createShell({ outputMode: 'capture' }); + const $ = shell.asFluent(); + + const result = await $(['echo', 'array-no-options']); + + expect(result).toBe('array-no-options'); + }); + }); }); From 730f599517478f4e5970b3532518669713b9717f Mon Sep 17 00:00:00 2001 From: Thada Wangthammang Date: Mon, 10 Nov 2025 00:21:10 +0700 Subject: [PATCH 14/19] docs: update README.md to enhance clarity and modernize the API description --- README.md | 756 +++++++++++++++++++++++++++++++++--------------------- 1 file changed, 457 insertions(+), 299 deletions(-) diff --git a/README.md b/README.md index a0eec1d..3723cbb 100644 --- a/README.md +++ b/README.md @@ -1,49 +1,52 @@ # @thaitype/shell -[![CI](https://github.com/thaitype/shell/actions/workflows/main.yml/badge.svg)](https://github.com/thaitype/shell/actions/workflows/main.yml) [![codecov](https://codecov.io/gh/thaitype/shell/graph/badge.svg?token=TUE7DJ6NKX)](https://codecov.io/gh/thaitype/shell) [![NPM Version](https://img.shields.io/npm/v/@thaitype/shell)](https://www.npmjs.com/package/@thaitype/shell) [![npm downloads](https://img.shields.io/npm/dt/@thaitype/shell)](https://www.npmjs.com/@thaitype/shell) +[![CI](https://github.com/thaitype/shell/actions/workflows/main.yml/badge.svg)](https://github.com/thaitype/shell/actions/workflows/main.yml) [![codecov](https://codecov.io/gh/thaitype/shell/graph/badge.svg?token=TUE7DJ6NKX)](https://codecov.io/gh/thaitype/shell) [![NPM Version](https://img.shields.io/npm/v/@thaitype/shell)](https://www.npmjs.com/package/@thaitype/shell) [![npm downloads](https://img.shields.io/npm/dt/@thaitype/shell)](https://www.npmjs.com/@thaitype/shell) -A lightweight, type-safe wrapper around [execa](https://github.com/sindresorhus/execa) for running shell commands with flexible output modes and better developer experience. +A lightweight, type-safe wrapper around [execa](https://github.com/sindresorhus/execa) for running shell commands with an elegant fluent API and flexible output modes. ## Why @thaitype/shell? -Running shell commands in Node.js often involves repetitive boilerplate and dealing with low-level stdio configuration. While `execa` is a powerful library, common tasks like logging commands, handling dry-run mode, or switching between capturing and streaming output require manual setup each time. +Running shell commands in Node.js often involves repetitive boilerplate and dealing with low-level stdio configuration. `@thaitype/shell` provides a modern, fluent API that makes shell scripting in TypeScript/JavaScript feel natural and enjoyable. -`@thaitype/shell` solves this by providing: +**Modern Fluent API:** +```typescript +import { createShell } from '@thaitype/shell'; -- **Simplified API** - Run commands with a single, intuitive interface -- **Flexible output modes** - Easily switch between capturing, streaming, or both -- **Built-in dry-run** - Test scripts without actually executing commands -- **Automatic logging** - Optional verbose mode for debugging -- **Smart error handling** - Choose between simple error messages or full error objects -- **Type safety** - Full TypeScript support with comprehensive type definitions +const $ = createShell().asFluent(); -## Features +// Simple and elegant +const output = await $`echo hello world`; -- **Multiple output modes**: Capture output, stream live, or do both simultaneously -- **Dry-run mode**: Test your scripts without executing actual commands -- **Verbose logging**: Automatically log all executed commands with contextual information -- **Flexible error handling**: Choose to throw on errors or handle them gracefully -- **Schema validation**: Parse and validate JSON output with Standard Schema (Zod, Valibot, etc.) -- **Custom logger support**: Integrate with your preferred logging solution (debug/warn methods with context) -- **Deep merge options**: Shell-level defaults are deep merged with command-level options -- **Type-safe**: Written in TypeScript with full type definitions -- **ESM-first**: Modern ES modules support -- **Zero configuration**: Sensible defaults that work out of the box +// Chain operations +const lines = await $`ls -la`.toLines(); -## Compatibility +// Parse JSON with validation +const pkg = await $`cat package.json`.parse(schema); -This package is **ESM only** and requires: +// Handle errors gracefully +const result = await $`some-command`.result(); +if (!result.success) { + console.error('Failed:', result.stderr); +} +``` -- **Node.js** >= 20 -- **ESM** module system (not CommonJS) +**Key Features:** -Following the same philosophy as [execa](https://github.com/sindresorhus/execa), this package is pure ESM. Please [read this](https://gist.github.com/sindresorhus/a39789f98801d908bbc7ff3ecc99d99c) if you need help migrating from CommonJS. +- **Fluent API** - Elegant tagged template syntax and chainable methods +- **Type-safe** - Full TypeScript support with automatic type inference +- **Flexible output modes** - Capture, stream live, or both simultaneously +- **Schema validation** - Built-in JSON parsing with Standard Schema (Zod, Valibot, etc.) +- **Smart error handling** - Choose between throwing or non-throwing APIs +- **Lazy execution** - Commands don't run until consumed +- **Memoization** - Multiple consumptions share the same execution +- **Dry-run mode** - Test scripts without executing commands +- **Verbose logging** - Debug with automatic command logging ## Installation ```bash npm install @thaitype/shell -# or +# or pnpm add @thaitype/shell # or yarn add @thaitype/shell @@ -51,331 +54,415 @@ yarn add @thaitype/shell bun add @thaitype/shell ``` -## Basic Usage - -```typescript -import { createShell } from '@thaitype/shell'; - -// Create a shell instance -const shell = createShell(); +## Compatibility -// Run a command -const result = await shell.run('echo "Hello World"'); +This package is **ESM only** and requires: -console.log(result.stdout); // "Hello World" -``` +- **Node.js** >= 20 +- **ESM** module system (not CommonJS) -## Example Usage +Following the same philosophy as [execa](https://github.com/sindresorhus/execa), this package is pure ESM. Please [read this](https://gist.github.com/sindresorhus/a39789f98801d908bbc7ff3ecc99d99c) if you need help migrating from CommonJS. -### 1. Verbose Mode for Debugging +## Quick Start -Perfect for build scripts and CI/CD pipelines where you want to see what's being executed: +### Basic Usage - Fluent API ```typescript import { createShell } from '@thaitype/shell'; -const shell = createShell({ - verbose: true // Logs every command before execution -}); +// Create a fluent shell function +const $ = createShell().asFluent(); + +// Execute and get output +const output = await $`echo "Hello World"`; +console.log(output); // "Hello World" -await shell.run('npm install'); -// Output: $ npm install -// (then shows npm install output) +// Use function call syntax +const result = await $('ls -la'); +console.log(result); -await shell.run('npm run build'); -// Output: $ npm run build -// (then shows build output) +// Array syntax for precise arguments +const files = await $(['echo', 'file with spaces.txt']); ``` -### 2. Dry-Run Mode for Testing +## Fluent API Guide -Test your automation scripts without actually executing commands: +The fluent API provides an elegant, modern way to run shell commands with powerful features like lazy execution, memoization, and chainable operations. -```typescript -import { createShell } from '@thaitype/shell'; +### Tagged Templates -const shell = createShell({ - dryRun: true, // Commands are logged but not executed - verbose: true -}); +Use backticks for natural command syntax with interpolation: -// These commands will be logged but not executed -await shell.run('rm -rf node_modules'); -// Output: $ rm -rf node_modules -// (nothing is actually deleted) +```typescript +const $ = createShell().asFluent(); -await shell.run('git push origin main'); -// Output: $ git push origin main -// (nothing is actually pushed) +const name = 'world'; +const greeting = await $`echo hello ${name}`; +console.log(greeting); // "hello world" -console.log('Dry run complete - no actual changes made!'); +// Works with any shell command +const files = await $`ls -la /tmp`; +const branch = await $`git rev-parse --abbrev-ref HEAD`; ``` -### 3. Different Output Modes +### Function Call Syntax -Control how command output is handled: +For dynamic commands or when you need to pass options: ```typescript -import { createShell } from '@thaitype/shell'; +const $ = createShell().asFluent(); -const shell = createShell(); +// String command +const output = await $('echo hello'); -// Capture mode (default): Capture output for programmatic use -const result1 = await shell.run('ls -la', { outputMode: 'capture' }); -console.log('Files:', result1.stdout); - -// Live mode: Stream output to console in real-time -await shell.run('npm test', { outputMode: 'live' }); -// Output appears in real-time as the command runs +// Array command (recommended for arguments with spaces) +const result = await $(['echo', 'hello world']); -// All mode: Both capture AND stream simultaneously -const result2 = await shell.run('npm run build', { outputMode: 'all' }); -// Output streams to console AND is captured in result2.stdout -console.log('Build output was:', result2.stdout); +// With options +const output = await $('npm run build', { outputMode: 'all' }); ``` -### 4. Graceful Error Handling +### Non-Throwable Execution with `.result()` -Handle command failures without throwing exceptions using `safeRun()`: +Handle failures gracefully without try-catch: ```typescript -import { createShell } from '@thaitype/shell'; +const $ = createShell().asFluent(); -const shell = createShell(); - -// safeRun() never throws, returns error result instead -const result = await shell.safeRun('some-command-that-might-fail'); +const result = await $`some-command-that-might-fail`.result(); if (!result.success) { - console.error('Command failed with exit code:', result.exitCode); - console.error('Error output:', result.stderr); - // Handle the error gracefully + console.error(`Command failed with exit code ${result.exitCode}`); + console.error(`Error: ${result.stderr}`); } else { - console.log('Success:', result.stdout); + console.log(`Output: ${result.stdout}`); } ``` -### 5. Schema Validation with JSON Output +### Working with Lines - `.toLines()` -Parse and validate JSON output from commands using Standard Schema: +Split output into an array of lines: + +```typescript +const $ = createShell().asFluent(); + +// Get directory listing as lines +const files = await $`ls -1 /tmp`.toLines(); +files.forEach(file => console.log(`File: ${file}`)); + +// Read and process file lines +const lines = await $`cat /etc/hosts`.toLines(); +const nonEmpty = lines.filter(line => line.trim() !== ''); +``` + +### JSON Parsing with Validation - `.parse()` + +Parse and validate JSON output with Standard Schema: ```typescript import { createShell } from '@thaitype/shell'; import { z } from 'zod'; -const shell = createShell(); +const $ = createShell().asFluent(); -// Define a schema for package.json +// Define schema const packageSchema = z.object({ name: z.string(), version: z.string(), dependencies: z.record(z.string()).optional(), }); -// Parse and validate - throws if invalid -const pkg = await shell.runParse('cat package.json', packageSchema); +// Parse and validate (throws on error) +const pkg = await $`cat package.json`.parse(packageSchema); console.log(`Package: ${pkg.name}@${pkg.version}`); -// Safe parse - returns result object -const apiSchema = z.object({ +// API response example +const userSchema = z.object({ + id: z.number(), + username: z.string(), + email: z.string().email(), +}); + +const user = await $`curl -s https://api.example.com/user/1`.parse(userSchema); +console.log(`User: ${user.username} (${user.email})`); +``` + +### Non-Throwable Parsing - `.safeParse()` + +Parse JSON without throwing exceptions: + +```typescript +const $ = createShell().asFluent(); + +const schema = z.object({ status: z.string(), - data: z.array(z.object({ - id: z.number(), - name: z.string(), - })), + data: z.array(z.any()), }); -const result = await shell.safeRunParse( - 'curl -s https://api.example.com/users', - apiSchema -); +const result = await $`curl -s https://api.example.com/data`.safeParse(schema); if (result.success) { - result.data.data.forEach(user => { - console.log(`User: ${user.name} (${user.id})`); - }); + console.log('Data:', result.data.data); } else { - console.error('API validation failed:', result.error); + console.error('Validation failed:', result.error); + // Handle error gracefully - could be: + // - Command failed + // - Invalid JSON + // - Schema validation failed } ``` -## API +### Lazy Execution and Memoization -### `createShell(options?)` (Recommended) +Commands don't execute until consumed, and multiple consumptions share execution: -Factory function to create a new Shell instance with better type inference. +```typescript +const $ = createShell().asFluent(); + +// Create handle - command hasn't run yet +const handle = $`echo expensive operation`; + +// First consumption - executes command +const output1 = await handle; + +// Second consumption - reuses first execution +const output2 = await handle; -**Recommended:** Use `createShell()` instead of `new Shell()` for better developer experience and automatic type inference of the default output mode. +// Works across different methods too +const result = await handle.result(); // Still same execution! + +// All three share the same memoized result +console.log(output1 === output2); // true +``` + +### Output Modes + +Control how command output is handled: ```typescript -import { createShell } from '@thaitype/shell'; +const shell = createShell({ outputMode: 'capture' }); // Default +const $ = shell.asFluent(); + +// Capture mode: Output is captured for programmatic use +const output = await $`npm run build`; +console.log(output); -// Type inference automatically detects 'live' as default mode -const shell = createShell({ outputMode: 'live' }); +// All mode: Both capture AND stream to console +const shell2 = createShell({ outputMode: 'all' }); +const $2 = shell2.asFluent(); + +const result = await $2`npm test`.result(); +// Test output appears in real-time on console +// AND is available in result.stdout + +// Override mode per command +const output2 = await $(['npm', 'install'], { outputMode: 'all' }); ``` -### `new Shell(options?)` +**Important:** Fluent API does not support `'live'` mode (streaming only, no capture) because fluent operations require stdout for chaining, parsing, and memoization. Use the traditional Shell API if you need live-only mode. -Alternative constructor for creating a Shell instance. +## Example Use Cases -#### Options +### 1. Build Script with Progress ```typescript -interface ShellOptions { - /** Default output mode applied to all runs unless overridden */ - outputMode?: OutputMode; // 'capture' | 'live' | 'all', default: 'capture' - - /** If true, print commands but skip actual execution */ - dryRun?: boolean; // default: false - - /** If true, log every executed command */ - verbose?: boolean; // default: false - - /** - * Controls how errors are thrown when a command fails. - * - "simple" → Throws a short, human-readable error message - * - "raw" → Throws the full ExecaError object with complete details - */ - throwMode?: 'simple' | 'raw'; // default: 'simple' - - /** - * Optional custom logger for command output and diagnostics. - * Provides two logging methods: - * - debug(message, context) - Called for verbose command logging - * - warn(message, context) - Called for warnings - * - * The context parameter includes the command and final execa options. - */ - logger?: ShellLogger; +import { createShell } from '@thaitype/shell'; - /** - * Default execa options applied to all command executions. - * When command-level execaOptions are provided, they are deep merged - * with shell-level options. Command-level options override shell-level. - */ - execaOptions?: ExecaOptions; -} +const shell = createShell({ + outputMode: 'all', // Show output + capture + verbose: true // Log commands +}); -interface ShellLogger { - /** Called for verbose command logging. Defaults to console.debug */ - debug?(message: string, context: ShellLogContext): void; +const $ = shell.asFluent(); - /** Called for warnings. Defaults to console.warn */ - warn?(message: string, context: ShellLogContext): void; -} +console.log('🏗️ Building project...'); -interface ShellLogContext { - /** The command being executed */ - command: string | string[]; +// Clean +await $`rm -rf dist`; - /** Execa options used for the command execution */ - execaOptions: ExecaOptions; +// Build +const buildResult = await $`npm run build`.result(); +if (!buildResult.success) { + console.error('❌ Build failed!'); + process.exit(1); } -``` -### `shell.run(command, options?)` +// Test +await $`npm test`; -Executes a shell command that **throws on error**. Recommended for most use cases where you want to fail fast. +console.log('✅ Build complete!'); +``` -#### Parameters +### 2. Git Workflow Helper -- `command: string | string[]` - The command to execute. Can be a string (with automatic parsing) or an array of arguments. -- `options?: RunOptions` - Optional execution options. +```typescript +import { createShell } from '@thaitype/shell'; -#### Returns +const $ = createShell().asFluent(); -```typescript -interface StrictResult { - /** Captured stdout output, or null if not captured */ - stdout: string | null; +// Get current branch +const branch = await $`git rev-parse --abbrev-ref HEAD`; +console.log(`Current branch: ${branch}`); - /** Captured stderr output, or null if not captured */ - stderr: string | null; +// Check for uncommitted changes +const status = await $`git status --porcelain`.result(); +if (status.stdout.trim() !== '') { + console.log('⚠️ You have uncommitted changes'); } + +// Get recent commits as lines +const commits = await $`git log --oneline -5`.toLines(); +console.log('Recent commits:'); +commits.forEach(commit => console.log(` ${commit}`)); ``` -**Throws**: Error when command exits with non-zero code (format depends on `throwMode`). +### 3. System Information Gathering -### `shell.safeRun(command, options?)` +```typescript +import { createShell } from '@thaitype/shell'; +import { z } from 'zod'; -Executes a shell command that **never throws**. Returns error result instead. +const $ = createShell().asFluent(); -Use this when you want to handle errors programmatically without try/catch. +// Parse JSON output +const pkgSchema = z.object({ + name: z.string(), + version: z.string(), + engines: z.object({ + node: z.string(), + }).optional(), +}); -#### Parameters +const pkg = await $`cat package.json`.parse(pkgSchema); -- `command: string | string[]` - The command to execute. -- `options?: RunOptions` - Optional execution options. +// Get Node version +const nodeVersion = await $`node --version`; -#### Returns +// Get system info as lines +const osInfo = await $`uname -a`.toLines(); + +console.log(`Project: ${pkg.name}@${pkg.version}`); +console.log(`Node: ${nodeVersion}`); +console.log(`OS: ${osInfo[0]}`); +``` + +### 4. Safe Command Execution ```typescript -interface SafeResult { - /** Captured stdout output, or null if not captured */ - stdout: string | null; +import { createShell } from '@thaitype/shell'; - /** Captured stderr output, or null if not captured */ - stderr: string | null; +const $ = createShell().asFluent(); - /** Exit code returned by the executed process */ - exitCode: number | undefined; +async function deployApp() { + // Test connection + const ping = await $`curl -s https://api.example.com/health`.result(); + if (!ping.success) { + console.error('❌ API is not reachable'); + return false; + } + + // Run tests + const tests = await $`npm test`.result(); + if (!tests.success) { + console.error('❌ Tests failed'); + return false; + } - /** True if command exited with code 0 */ - success: boolean; + // Deploy + const deploy = await $`npm run deploy`.result(); + if (!deploy.success) { + console.error('❌ Deployment failed'); + console.error(deploy.stderr); + return false; + } + + console.log('✅ Deployment successful!'); + return true; } + +await deployApp(); ``` -### `shell.execute(command, options?)` +### 5. Dry-Run Mode for Testing + +Test your automation scripts without actually executing commands: + +```typescript +import { createShell } from '@thaitype/shell'; + +const shell = createShell({ + dryRun: true, // Commands logged but not executed + verbose: true +}); + +const $ = shell.asFluent(); + +// These commands will be logged but not executed +await $`rm -rf node_modules`; +// Output: $ rm -rf node_modules +// (nothing is actually deleted) + +await $`git push origin main`; +// Output: $ git push origin main +// (nothing is actually pushed) -Low-level method with explicit `throwOnError` control. +console.log('✅ Dry run complete - no actual changes made!'); +``` -#### Parameters +## Traditional Shell API -- `command: string | string[]` - The command to execute. -- `options?: RunOptions & { throwOnError?: boolean }` - Optional execution options including throwOnError flag. +For cases where you need more control or don't want the fluent API, use the traditional methods: -#### RunOptions +### `shell.run()` - Throws on Error ```typescript -interface RunOptions extends ExecaOptions { - /** Override the output behavior for this specific command */ - outputMode?: OutputMode; // 'capture' | 'live' | 'all' +import { createShell } from '@thaitype/shell'; - /** Override verbose logging for this specific command */ - verbose?: boolean; +const shell = createShell(); - /** Override dry-run mode for this specific command */ - dryRun?: boolean; +try { + const result = await shell.run('npm test'); + console.log('Tests passed!', result.stdout); +} catch (error) { + console.error('Tests failed:', error.message); } ``` -Inherits all options from [execa's Options](https://github.com/sindresorhus/execa#options). +### `shell.safeRun()` - Never Throws -**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). +```typescript +const shell = createShell(); -**Throws on error** - Command failure or validation failure will throw an exception. +const result = await shell.safeRun('npm test'); -#### Parameters +if (!result.success) { + console.error('Command failed with exit code:', result.exitCode); + console.error('Error output:', result.stderr); +} else { + console.log('Success:', result.stdout); +} +``` -- `command: string | string[]` - The command to execute. -- `schema: StandardSchemaV1` - A Standard Schema to validate the JSON output. -- `options?: RunOptions` - Optional execution options. +### Output Modes -#### Returns +```typescript +const shell = createShell(); -- Type-safe parsed and validated output based on the schema. +// Capture mode (default): Capture output for programmatic use +const result1 = await shell.run('ls -la', { outputMode: 'capture' }); +console.log('Files:', result1.stdout); -#### Throws +// Live mode: Stream output to console in real-time (no capture) +await shell.run('npm test', { outputMode: 'live' }); +// Output appears in real-time, stdout/stderr will be null -- Error when command fails -- Error when output is not valid JSON -- Error when output doesn't match the schema +// All mode: Both capture AND stream simultaneously +const result2 = await shell.run('npm run build', { outputMode: 'all' }); +// Output streams to console AND is captured +console.log('Build output was:', result2.stdout); +``` -**Example with Zod:** +### Schema Validation ```typescript import { createShell } from '@thaitype/shell'; @@ -388,92 +475,169 @@ const packageSchema = z.object({ version: z.string(), }); -// Execute command and validate JSON output -const pkg = await shell.runParse( - 'cat package.json', - packageSchema -); +// Throws on error +const pkg = await shell.runParse('cat package.json', packageSchema); +console.log(`${pkg.name}@${pkg.version}`); -console.log(pkg.name, pkg.version); // Type-safe! +// Never throws +const result = await shell.safeRunParse('cat package.json', packageSchema); +if (result.success) { + console.log(`${result.data.name}@${result.data.version}`); +} else { + console.error('Validation failed:', result.error); +} ``` -### `shell.safeRunParse(command, schema, options?)` +## API Reference -Execute a command, parse its stdout as JSON, and validate it against a Standard Schema. +### Factory Function -**Never throws** - Returns a result object with success/error information. +#### `createShell(options?)` -#### Parameters +Creates a new Shell instance with better type inference (recommended). -- `command: string | string[]` - The command to execute. -- `schema: StandardSchemaV1` - A Standard Schema to validate the JSON output. -- `options?: RunOptions` - Optional execution options. +```typescript +const shell = createShell({ + outputMode: 'capture', // 'capture' | 'live' | 'all' + dryRun: false, + verbose: false, + throwMode: 'simple', // 'simple' | 'raw' + logger: { + debug: (msg, ctx) => console.debug(msg), + warn: (msg, ctx) => console.warn(msg), + }, + execaOptions: { + env: { NODE_ENV: 'production' }, + timeout: 30000, + cwd: '/app', + }, +}); +``` + +### Fluent API -#### Returns +#### `shell.asFluent()` + +Returns a fluent shell function that supports tagged templates and function calls. ```typescript -type ValidationResult = - | { success: true; data: T } - | { success: false; error: Array<{ message: string }> }; +const $ = shell.asFluent(); + +// Tagged template +await $`command`; + +// Function calls +await $('command'); +await $(['command', 'arg']); +await $(command, options); ``` -**Example with Zod:** +**Returns:** `DollarFunction` that creates `LazyCommandHandle` instances. + +**Throws:** Error if shell has `outputMode: 'live'` (fluent API requires output capture). + +#### `LazyCommandHandle` + +Handle returned by fluent API with lazy execution and memoization. +**Direct await - Throwable:** ```typescript -import { createShell } from '@thaitype/shell'; -import { z } from 'zod'; +const output: string = await $`command`; +``` -const shell = createShell(); +**Methods:** -const userSchema = z.object({ - username: z.string(), - id: z.number(), -}); +- **`.result()`** - Non-throwable execution + ```typescript + const result = await $`command`.result(); + // result: { success: boolean, stdout: string, stderr: string, exitCode: number | undefined } + ``` -const result = await shell.safeRunParse( - 'curl -s https://api.example.com/user', - userSchema -); +- **`.toLines()`** - Split output into lines (throws on error) + ```typescript + const lines: string[] = await $`command`.toLines(); + ``` -if (result.success) { - console.log('User:', result.data.username); -} else { - console.error('Validation failed:', result.error); +- **`.parse(schema)`** - Parse and validate JSON (throws on error) + ```typescript + const data: T = await $`command`.parse(schema); + ``` + +- **`.safeParse(schema)`** - Parse and validate JSON (never throws) + ```typescript + const result = await $`command`.safeParse(schema); + // result: { success: true, data: T } | { success: false, error: Error[] } + ``` + +### Traditional Shell Methods + +#### `shell.run(command, options?)` + +Execute command that throws on error. + +**Returns:** `Promise` +```typescript +{ stdout: string | null, stderr: string | null } +``` + +#### `shell.safeRun(command, options?)` + +Execute command that never throws. + +**Returns:** `Promise` +```typescript +{ + stdout: string | null, + stderr: string | null, + exitCode: number | undefined, + success: boolean } ``` -### Output Modes +#### `shell.runParse(command, schema, options?)` + +Execute, parse, and validate JSON (throws on error). -- **`capture`** (default): Captures stdout/stderr for programmatic access. Output is not printed to console. -- **`live`**: Streams stdout/stderr directly to console in real-time. Output is not captured. -- **`all`**: Both captures AND streams output simultaneously. +**Returns:** `Promise` (inferred from schema) -## Advanced Examples +#### `shell.safeRunParse(command, schema, options?)` -### Using run() vs safeRun() +Execute, parse, and validate JSON (never throws). +**Returns:** `Promise>` ```typescript -import { createShell } from '@thaitype/shell'; +{ success: true, data: T } | { success: false, error: Error[] } +``` -const shell = createShell(); +### Options -// run() - Throws on error (fail fast) -try { - const result = await shell.run('npm test'); - console.log('Tests passed!', result.stdout); -} catch (error) { - console.error('Tests failed:', error.message); +#### `ShellOptions` + +```typescript +interface ShellOptions { + outputMode?: 'capture' | 'live' | 'all'; // default: 'capture' + dryRun?: boolean; // default: false + verbose?: boolean; // default: false + throwMode?: 'simple' | 'raw'; // default: 'simple' + logger?: ShellLogger; + execaOptions?: ExecaOptions; // Merged with command options } +``` -// safeRun() - Never throws, check success flag -const result = await shell.safeRun('npm test'); -if (result.success) { - console.log('Tests passed!', result.stdout); -} else { - console.error('Tests failed with exit code:', result.exitCode); +#### `RunOptions` + +```typescript +interface RunOptions extends ExecaOptions { + outputMode?: 'capture' | 'live' | 'all'; + verbose?: boolean; + dryRun?: boolean; } ``` +All options from [execa](https://github.com/sindresorhus/execa#options) are supported. Shell-level and command-level options are deep merged. + +## Advanced Usage + ### Custom Logger Integration ```typescript @@ -501,37 +665,31 @@ const shell = createShell({ } }); -await shell.run('npm install'); -// Commands are logged using Winston with contextual information +const $ = shell.asFluent(); +await $`npm install`; +// Commands logged via Winston with context ``` -### Combining with Execa Options +### Deep Merge Options ```typescript -import { createShell } from '@thaitype/shell'; - -// Shell-level default options const shell = createShell({ execaOptions: { - env: { API_KEY: 'default-key', NODE_ENV: 'development' }, + env: { API_KEY: 'default', NODE_ENV: 'dev' }, timeout: 5000, - cwd: '/default/directory' } }); -// Command-level options are deep merged with shell-level -const result = await shell.run('node script.js', { - env: { NODE_ENV: 'production', EXTRA: 'value' }, // Deep merged - timeout: 30000, // Overrides shell-level timeout - outputMode: 'capture' +const $ = shell.asFluent(); + +// Options are deep merged +const result = await $('node script.js', { + env: { NODE_ENV: 'prod', EXTRA: 'value' }, + timeout: 30000, }); -// Resulting options: -// { -// env: { API_KEY: 'default-key', NODE_ENV: 'production', EXTRA: 'value' }, -// timeout: 30000, -// cwd: '/default/directory' -// } +// Resulting env: { API_KEY: 'default', NODE_ENV: 'prod', EXTRA: 'value' } +// Resulting timeout: 30000 ``` ## License From b62d7cb1cd6566e07f3cebf3709d31e5a371b1e7 Mon Sep 17 00:00:00 2001 From: Thada Wangthammang Date: Mon, 10 Nov 2025 10:22:45 +0700 Subject: [PATCH 15/19] refactor: replace tagged template syntax with function call syntax in examples and tests --- README.md | 95 ++++++++------------ examples/taggedTemplates.ts | 174 ------------------------------------ src/shell.ts | 99 +++++++------------- test/shell.test.ts | 158 +++++++------------------------- 4 files changed, 99 insertions(+), 427 deletions(-) delete mode 100644 examples/taggedTemplates.ts diff --git a/README.md b/README.md index 3723cbb..dbd4939 100644 --- a/README.md +++ b/README.md @@ -15,16 +15,16 @@ import { createShell } from '@thaitype/shell'; const $ = createShell().asFluent(); // Simple and elegant -const output = await $`echo hello world`; +const output = await $('echo hello world'); // Chain operations -const lines = await $`ls -la`.toLines(); +const lines = await $('ls -la').toLines(); // Parse JSON with validation -const pkg = await $`cat package.json`.parse(schema); +const pkg = await $('cat package.json').parse(schema); // Handle errors gracefully -const result = await $`some-command`.result(); +const result = await $('some-command').result(); if (!result.success) { console.error('Failed:', result.stderr); } @@ -32,7 +32,7 @@ if (!result.success) { **Key Features:** -- **Fluent API** - Elegant tagged template syntax and chainable methods +- **Fluent API** - Elegant function call syntax with chainable methods - **Type-safe** - Full TypeScript support with automatic type inference - **Flexible output modes** - Capture, stream live, or both simultaneously - **Schema validation** - Built-in JSON parsing with Standard Schema (Zod, Valibot, etc.) @@ -74,7 +74,7 @@ import { createShell } from '@thaitype/shell'; const $ = createShell().asFluent(); // Execute and get output -const output = await $`echo "Hello World"`; +const output = await $('echo "Hello World"'); console.log(output); // "Hello World" // Use function call syntax @@ -89,25 +89,9 @@ const files = await $(['echo', 'file with spaces.txt']); The fluent API provides an elegant, modern way to run shell commands with powerful features like lazy execution, memoization, and chainable operations. -### Tagged Templates +### Command Execution -Use backticks for natural command syntax with interpolation: - -```typescript -const $ = createShell().asFluent(); - -const name = 'world'; -const greeting = await $`echo hello ${name}`; -console.log(greeting); // "hello world" - -// Works with any shell command -const files = await $`ls -la /tmp`; -const branch = await $`git rev-parse --abbrev-ref HEAD`; -``` - -### Function Call Syntax - -For dynamic commands or when you need to pass options: +Execute commands using string or array syntax: ```typescript const $ = createShell().asFluent(); @@ -129,7 +113,7 @@ Handle failures gracefully without try-catch: ```typescript const $ = createShell().asFluent(); -const result = await $`some-command-that-might-fail`.result(); +const result = await $('some-command-that-might-fail').result(); if (!result.success) { console.error(`Command failed with exit code ${result.exitCode}`); @@ -147,11 +131,11 @@ Split output into an array of lines: const $ = createShell().asFluent(); // Get directory listing as lines -const files = await $`ls -1 /tmp`.toLines(); +const files = await $('ls -1 /tmp').toLines(); files.forEach(file => console.log(`File: ${file}`)); // Read and process file lines -const lines = await $`cat /etc/hosts`.toLines(); +const lines = await $('cat /etc/hosts').toLines(); const nonEmpty = lines.filter(line => line.trim() !== ''); ``` @@ -173,7 +157,7 @@ const packageSchema = z.object({ }); // Parse and validate (throws on error) -const pkg = await $`cat package.json`.parse(packageSchema); +const pkg = await $('cat package.json').parse(packageSchema); console.log(`Package: ${pkg.name}@${pkg.version}`); // API response example @@ -183,7 +167,7 @@ const userSchema = z.object({ email: z.string().email(), }); -const user = await $`curl -s https://api.example.com/user/1`.parse(userSchema); +const user = await $('curl -s https://api.example.com/user/1').parse(userSchema); console.log(`User: ${user.username} (${user.email})`); ``` @@ -199,7 +183,7 @@ const schema = z.object({ data: z.array(z.any()), }); -const result = await $`curl -s https://api.example.com/data`.safeParse(schema); +const result = await $('curl -s https://api.example.com/data').safeParse(schema); if (result.success) { console.log('Data:', result.data.data); @@ -220,7 +204,7 @@ Commands don't execute until consumed, and multiple consumptions share execution const $ = createShell().asFluent(); // Create handle - command hasn't run yet -const handle = $`echo expensive operation`; +const handle = $('echo expensive operation'); // First consumption - executes command const output1 = await handle; @@ -244,14 +228,14 @@ const shell = createShell({ outputMode: 'capture' }); // Default const $ = shell.asFluent(); // Capture mode: Output is captured for programmatic use -const output = await $`npm run build`; +const output = await $('npm run build'); console.log(output); // All mode: Both capture AND stream to console const shell2 = createShell({ outputMode: 'all' }); const $2 = shell2.asFluent(); -const result = await $2`npm test`.result(); +const result = await $2('npm test').result(); // Test output appears in real-time on console // AND is available in result.stdout @@ -278,17 +262,17 @@ const $ = shell.asFluent(); console.log('🏗️ Building project...'); // Clean -await $`rm -rf dist`; +await $('rm -rf dist'); // Build -const buildResult = await $`npm run build`.result(); +const buildResult = await $('npm run build').result(); if (!buildResult.success) { console.error('❌ Build failed!'); process.exit(1); } // Test -await $`npm test`; +await $('npm test'); console.log('✅ Build complete!'); ``` @@ -301,17 +285,17 @@ import { createShell } from '@thaitype/shell'; const $ = createShell().asFluent(); // Get current branch -const branch = await $`git rev-parse --abbrev-ref HEAD`; +const branch = await $('git rev-parse --abbrev-ref HEAD'); console.log(`Current branch: ${branch}`); // Check for uncommitted changes -const status = await $`git status --porcelain`.result(); +const status = await $('git status --porcelain').result(); if (status.stdout.trim() !== '') { console.log('⚠️ You have uncommitted changes'); } // Get recent commits as lines -const commits = await $`git log --oneline -5`.toLines(); +const commits = await $('git log --oneline -5').toLines(); console.log('Recent commits:'); commits.forEach(commit => console.log(` ${commit}`)); ``` @@ -333,13 +317,13 @@ const pkgSchema = z.object({ }).optional(), }); -const pkg = await $`cat package.json`.parse(pkgSchema); +const pkg = await $('cat package.json').parse(pkgSchema); // Get Node version -const nodeVersion = await $`node --version`; +const nodeVersion = await $('node --version'); // Get system info as lines -const osInfo = await $`uname -a`.toLines(); +const osInfo = await $('uname -a').toLines(); console.log(`Project: ${pkg.name}@${pkg.version}`); console.log(`Node: ${nodeVersion}`); @@ -355,21 +339,21 @@ const $ = createShell().asFluent(); async function deployApp() { // Test connection - const ping = await $`curl -s https://api.example.com/health`.result(); + const ping = await $('curl -s https://api.example.com/health').result(); if (!ping.success) { console.error('❌ API is not reachable'); return false; } // Run tests - const tests = await $`npm test`.result(); + const tests = await $('npm test').result(); if (!tests.success) { console.error('❌ Tests failed'); return false; } // Deploy - const deploy = await $`npm run deploy`.result(); + const deploy = await $('npm run deploy').result(); if (!deploy.success) { console.error('❌ Deployment failed'); console.error(deploy.stderr); @@ -398,11 +382,11 @@ const shell = createShell({ const $ = shell.asFluent(); // These commands will be logged but not executed -await $`rm -rf node_modules`; +await $('rm -rf node_modules'); // Output: $ rm -rf node_modules // (nothing is actually deleted) -await $`git push origin main`; +await $('git push origin main'); // Output: $ git push origin main // (nothing is actually pushed) @@ -518,14 +502,11 @@ const shell = createShell({ #### `shell.asFluent()` -Returns a fluent shell function that supports tagged templates and function calls. +Returns a fluent shell function that supports function calls. ```typescript const $ = shell.asFluent(); -// Tagged template -await $`command`; - // Function calls await $('command'); await $(['command', 'arg']); @@ -542,30 +523,30 @@ Handle returned by fluent API with lazy execution and memoization. **Direct await - Throwable:** ```typescript -const output: string = await $`command`; +const output: string = await $('command'); ``` **Methods:** - **`.result()`** - Non-throwable execution ```typescript - const result = await $`command`.result(); + const result = await $('command').result(); // result: { success: boolean, stdout: string, stderr: string, exitCode: number | undefined } ``` - **`.toLines()`** - Split output into lines (throws on error) ```typescript - const lines: string[] = await $`command`.toLines(); + const lines: string[] = await $('command').toLines(); ``` - **`.parse(schema)`** - Parse and validate JSON (throws on error) ```typescript - const data: T = await $`command`.parse(schema); + const data: T = await $('command').parse(schema); ``` - **`.safeParse(schema)`** - Parse and validate JSON (never throws) ```typescript - const result = await $`command`.safeParse(schema); + const result = await $('command').safeParse(schema); // result: { success: true, data: T } | { success: false, error: Error[] } ``` @@ -666,7 +647,7 @@ const shell = createShell({ }); const $ = shell.asFluent(); -await $`npm install`; +await $('npm install'); // Commands logged via Winston with context ``` diff --git a/examples/taggedTemplates.ts b/examples/taggedTemplates.ts deleted file mode 100644 index 35f98e2..0000000 --- a/examples/taggedTemplates.ts +++ /dev/null @@ -1,174 +0,0 @@ -/** - * Example demonstrating Tagged Template Literals with Lazy Execution - * - * Task-2 features: - * - Tagged template support: $`echo hello` - * - Function call support (backward compatible): $('echo hello') - * - Lazy execution: command doesn't run until consumed - * - Memoization: multiple consumptions share one execution - * - .result() method for non-throwable execution - */ - -import { createShell } from '../src/index.js'; -import { z } from 'zod'; - -async function main() { - console.log('=== Tagged Template Literals + Lazy Execution ===\n'); - - // Create fluent shell function - const $ = createShell({ verbose: false }).asFluent(); - - console.log('--- Example 1: Tagged Template Basic ---'); - // Tagged template - new in task-2! - const greeting = await $`echo hello`; - console.log(`Result: ${greeting}\n`); - - console.log('--- Example 2: Tagged Template with Interpolation ---'); - // Interpolate values into template - const name = 'world'; - const msg = await $`echo hello ${name}`; - console.log(`Result: ${msg}\n`); - - console.log('--- Example 3: Multiple Interpolations ---'); - // Multiple interpolated values - const a = 'foo'; - const b = 'bar'; - const c = 'baz'; - const result = await $`echo ${a} ${b} ${c}`; - console.log(`Result: ${result}\n`); - - console.log('--- Example 4: Function Call (Backward Compatible) ---'); - // Function call syntax still works - const funcResult = await $('echo "function call works"'); - console.log(`Result: ${funcResult}\n`); - - console.log('--- Example 5: Array Command ---'); - // Array syntax for precise argv control - const arrayResult = await $(['echo', 'array', 'syntax']); - console.log(`Result: ${arrayResult}\n`); - - console.log('--- Example 6: Non-throwable Execution with .result() ---'); - // Use .result() for non-throwable execution - const successResult = await $`echo success`.result(); - console.log(`Success: ${successResult.success}`); - console.log(`Stdout: ${successResult.stdout}`); - console.log(`Exit code: ${successResult.exitCode}\n`); - - // Failed command with .result() - doesn't throw! - const failResult = await $`sh -c "exit 42"`.result(); - console.log(`Success: ${failResult.success}`); - console.log(`Exit code: ${failResult.exitCode}`); - console.log(`Note: .result() never throws, even on failure!\n`); - - console.log('--- Example 7: Lazy Execution ---'); - // Command doesn't execute until consumed - console.log('Creating handle (no execution yet)...'); - const lazyHandle = $`echo "I am lazy"`; - console.log('Handle created, but command has not run yet!'); - console.log('Now consuming handle (execution starts)...'); - const lazyResult = await lazyHandle; - console.log(`Result: ${lazyResult}\n`); - - console.log('--- Example 8: Memoization ---'); - // Multiple consumptions share one execution - const memoHandle = $`echo "executed once"`; - console.log('First consumption (executes):'); - const memo1 = await memoHandle; - console.log('Second consumption (reuses):'); - const memo2 = await memoHandle; - console.log('Third consumption with .result() (still reuses):'); - const memo3 = await memoHandle.result(); - console.log(`All three got same result: ${memo1 === memo2 && memo2 === memo3.stdout}\n`); - - console.log('--- Example 9: Tagged Template with .toLines() ---'); - // Combine tagged template with helper methods - const lines = await $`printf "apple\nbanana\ncherry"`.toLines(); - console.log('Fruits:'); - lines.forEach((fruit, i) => { - console.log(` ${i + 1}. ${fruit}`); - }); - console.log(); - - console.log('--- Example 10: Tagged Template with .parse() ---'); - // Parse JSON output with Zod schema - const PackageSchema = z.object({ - name: z.string(), - version: z.string(), - }); - - const pkg = await $`cat package.json`.parse(PackageSchema); - console.log(`Package: ${pkg.name} v${pkg.version}\n`); - - console.log('--- Example 11: safeParse() - Non-throwable Parsing ---'); - // safeParse() never throws, returns ValidationResult instead - const ConfigSchema = z.object({ - name: z.string(), - version: z.string(), - }); - - // Success case - const parseResult1 = await $`echo '{"name":"app","version":"1.0.0"}'`.safeParse(ConfigSchema); - if (parseResult1.success) { - console.log(`✅ Parse success: ${parseResult1.data.name} v${parseResult1.data.version}`); - } else { - console.log(`❌ Parse failed: ${parseResult1.error[0].message}`); - } - - // Command failure case (doesn't throw!) - const parseResult2 = await $`sh -c "exit 1"`.safeParse(ConfigSchema); - if (parseResult2.success) { - console.log(`✅ Parse success: ${parseResult2.data}`); - } else { - console.log(`❌ Command failed (non-throwing): ${parseResult2.error[0].message.split('\n')[0]}`); - } - - // Invalid JSON case (doesn't throw!) - const parseResult3 = await $`echo "not json"`.safeParse(ConfigSchema); - if (parseResult3.success) { - console.log(`✅ Parse success: ${parseResult3.data}`); - } else { - console.log(`❌ JSON invalid (non-throwing): ${parseResult3.error[0].message.split('\n')[0]}`); - } - - // Schema validation failure (doesn't throw!) - const parseResult4 = await $`echo '{"name":"app"}'`.safeParse(ConfigSchema); // missing 'version' - if (parseResult4.success) { - console.log(`✅ Parse success: ${parseResult4.data}`); - } else { - console.log(`❌ Validation failed (non-throwing): Schema validation error`); - } - console.log(); - - console.log('--- Example 12: Error Handling Comparison ---'); - // Throwable vs non-throwable - try { - console.log('Attempting throwable execution...'); - await $`sh -c "exit 1"`; - } catch (error) { - console.log(`Caught error (throwable): ${(error as Error).message.split('\n')[0]}`); - } - - const safeResult = await $`sh -c "exit 1"`.result(); - console.log(`Non-throwable result: success=${safeResult.success}, exitCode=${safeResult.exitCode}\n`); - - console.log('--- Example 13: Chaining with Variables ---'); - // Use result of one command in another - const dir = await $`echo test-dir`; - console.log(`Got directory name: ${dir}`); - - const $dryRun = createShell({ dryRun: true }).asFluent(); - await $dryRun`mkdir ${dir}`; - console.log(`Would create directory: ${dir} (dry run)\n`); - - console.log('--- Example 14: Complex Template ---'); - // More complex template with multiple parts - const user = 'alice'; - const age = 30; - const city = 'wonderland'; - const complexMsg = await $`echo "User: ${user}, Age: ${age}, City: ${city}"`; - console.log(`Complex: ${complexMsg}\n`); - - console.log('✅ All examples completed successfully!'); -} - -main().catch(console.error); diff --git a/src/shell.ts b/src/shell.ts index 9209315..ed962c2 100644 --- a/src/shell.ts +++ b/src/shell.ts @@ -292,7 +292,6 @@ export type CommandHandle = PromiseLike & { */ export type FluentShellFn = (command: string | string[]) => CommandHandle; - /** * Command handle with lazy execution and memoization. * Extends CommandHandle with `.result()` method for non-throwable execution. @@ -389,21 +388,26 @@ export type LazyCommandHandle = PromiseLike & { }; /** - * Function that supports both tagged templates and function calls for command execution. + * Function that supports string and array command execution. * Returned by `shell.asFluent()`. * - * Supports three call signatures: - * 1. Tagged template: `` $`echo hello` `` - * 2. String command: `$('echo hello')` or `$('echo hello', { outputMode: 'all' })` - * 3. Argv array: `$(['echo', 'hello'])` or `$(['echo', 'hello'], { outputMode: 'all' })` + * Supports two call signatures: + * 1. String command: `$('echo hello')` or `$('echo hello', { outputMode: 'all' })` + * 2. Argv array: `$(['echo', 'hello'])` or `$(['echo', 'hello'], { outputMode: 'all' })` * * Note: FluentShell does not support 'live' mode. Use 'capture' or 'all' only. * - * @example Tagged template with interpolation + * @example String command + * ```typescript + * const $ = createShell().asFluent(); + * const result = await $('echo hello'); + * ``` + * + * @example String command with template literal interpolation * ```typescript * const $ = createShell().asFluent(); * const name = 'world'; - * const result = await $`echo hello ${name}`; + * const result = await $(`echo hello ${name}`); * ``` * * @example Function call with options @@ -412,18 +416,13 @@ export type LazyCommandHandle = PromiseLike & { * const result = await $('echo hello', { outputMode: 'all' }); * ``` * - * @example Array call with options + * @example Array call with options (recommended for complex arguments) * ```typescript * const $ = createShell().asFluent(); - * const result = await $(['echo', 'hello'], { outputMode: 'all' }); + * const result = await $(['echo', 'file with spaces.txt'], { outputMode: 'all' }); * ``` */ export interface DollarFunction { - /** - * Tagged template call - interpolates values into command string - */ - (parts: TemplateStringsArray, ...values: any[]): LazyCommandHandle; - /** * String or array command call with optional fluent options */ @@ -811,26 +810,6 @@ export class Shell { } } - /** - * Process tagged template literal into a command string. - * Interpolates values between template string parts. - * - * @param parts - Template string parts (TemplateStringsArray) - * @param values - Interpolated values - * @returns Concatenated command string - * - * @internal - */ - private processTaggedTemplate(parts: TemplateStringsArray, values: any[]): string { - // Interleave parts and values - // TODO: Implement argv-safe escaping for interpolated values - let result = parts[0]; - for (let i = 0; i < values.length; i++) { - result += String(values[i]) + parts[i + 1]; - } - return result; - } - /** * Validates that the output mode is compatible with FluentShell. * Throws an error if 'live' mode is used. @@ -860,10 +839,7 @@ export class Shell { * * @internal */ - private createLazyHandle( - command: string | string[], - options: FluentRunOptions - ): LazyCommandHandle { + private createLazyHandle(command: string | string[], options: FluentRunOptions): LazyCommandHandle { // Memoized execution promise - null until first consumption let executionPromise: Promise> | null = null; @@ -986,10 +962,9 @@ export class Shell { } /** - * Create a fluent shell function with tagged template and lazy execution support. + * Create a fluent shell function with lazy execution support. * * Returns a function that supports: - * - Tagged templates: `` $`echo hello` `` * - Function calls: `$('echo hello')` or `$(['echo', 'hello'], { outputMode: 'all' })` * - Lazy execution: command doesn't run until consumed * - Memoization: multiple consumptions share one execution @@ -998,15 +973,14 @@ export class Shell { * Note: FluentShell requires stdout for chaining, so 'live' mode is not supported. * If the shell instance has `outputMode: 'live'`, this method will throw an error. * - * @returns DollarFunction that supports tagged templates and function calls + * @returns DollarFunction that supports function calls * * @throws {Error} If shell instance has `outputMode: 'live'` * - * @example Tagged template with shell default mode + * @example Basic usage * ```typescript - * const shell = createShell({ outputMode: 'capture' }); - * const $ = shell.asFluent(); - * const result = await $`echo hello`; // Uses 'capture' mode + * const $ = createShell().asFluent(); + * const result = await $('echo hello'); * ``` * * @example Function call with mode override @@ -1016,6 +990,12 @@ export class Shell { * const result = await $('echo hello', { outputMode: 'all' }); // Uses 'all' mode * ``` * + * @example Array syntax for precise arguments + * ```typescript + * const $ = createShell().asFluent(); + * const result = await $(['echo', 'file with spaces.txt']); + * ``` + * * @example Error case - live mode not supported * ```typescript * const shell = createShell({ outputMode: 'live' }); @@ -1025,7 +1005,7 @@ export class Shell { * @example Non-throwable execution * ```typescript * const $ = createShell().asFluent(); - * const r = await $`exit 1`.result(); + * const r = await $('sh -c "exit 1"').result(); * if (!r.success) { * console.error(`Failed with exit code ${r.exitCode}`); * } @@ -1034,7 +1014,7 @@ export class Shell { * @example Lazy + memoization * ```typescript * const $ = createShell().asFluent(); - * const handle = $`echo test`; + * const handle = $('echo test'); * const a = await handle; // Executes once * const b = await handle; // Reuses execution * const c = await handle.result(); // Still same execution @@ -1043,32 +1023,15 @@ export class Shell { * @example Helper methods * ```typescript * const $ = createShell().asFluent(); - * const lines = await $`ls -la`.toLines(); - * const data = await $`cat package.json`.parse(schema); + * const lines = await $('ls -la').toLines(); + * const data = await $('cat package.json').parse(schema); * ``` */ public asFluent(): DollarFunction { // Validate shell-level outputMode this.assertFluentMode(this.outputMode); - return ((firstArg: any, ...rest: any[]): LazyCommandHandle => { - // Detect if it's a tagged template call - // Tagged templates pass TemplateStringsArray as first argument - if (Array.isArray(firstArg) && 'raw' in firstArg && Array.isArray((firstArg as any).raw)) { - // Tagged template: process interpolation, use shell default mode - const command = this.processTaggedTemplate(firstArg as TemplateStringsArray, rest); - const mode = this.outputMode; - - // Mode already validated at asFluent() level, but assert for type narrowing - this.assertFluentMode(mode); - - return this.createLazyHandle(command, { outputMode: mode } as FluentRunOptions); - } - - // Function call: string or array with optional options - const command = firstArg as string | string[]; - const options = rest[0] as FluentRunOptions | undefined; - + return ((command: string | string[], options?: FluentRunOptions): LazyCommandHandle => { // Determine effective output mode (options override shell default) const effectiveMode = (options?.outputMode ?? this.outputMode) as OutputMode; diff --git a/test/shell.test.ts b/test/shell.test.ts index 64aaf4d..27809f5 100644 --- a/test/shell.test.ts +++ b/test/shell.test.ts @@ -1030,84 +1030,11 @@ describe('Shell', () => { }); }); - describe('Fluent Shell API - Tagged Templates', () => { - it('should support tagged template basic usage', async () => { - const $ = createShell().asFluent(); - - const result = await $`echo hello`; - - expect(result).toBe('hello'); - }); - - it('should support tagged template with single interpolation', async () => { - const $ = createShell().asFluent(); - const name = 'world'; - - const result = await $`echo hello ${name}`; - - expect(result).toBe('hello world'); - }); - - it('should support tagged template with multiple interpolations', async () => { - const $ = createShell().asFluent(); - const a = 'foo'; - const b = 'bar'; - const c = 'baz'; - - const result = await $`echo ${a} ${b} ${c}`; - - expect(result).toBe('foo bar baz'); - }); - - it('should handle tagged template with number interpolation', async () => { - const $ = createShell().asFluent(); - const num = 42; - - const result = await $`echo ${num}`; - - expect(result).toBe('42'); - }); - - it('should handle tagged template with empty interpolation', async () => { - const $ = createShell().asFluent(); - const empty = ''; - - const result = await $`echo test${empty}value`; - - expect(result).toBe('testvalue'); - }); - - it('should work with tagged template and toLines()', async () => { - const $ = createShell().asFluent(); - - const lines = await $`printf "a\nb\nc"`.toLines(); - - expect(lines).toEqual(['a', 'b', 'c']); - }); - - it('should work with tagged template and parse()', async () => { - const $ = createShell().asFluent(); - const schema = z.object({ - value: z.string(), - }); - - const result = await $`echo '{"value":"test"}'`.parse(schema); - - expect(result.value).toBe('test'); - }); - - it('should throw error for failing tagged template command', async () => { - const $ = createShell().asFluent(); - - await expect($`sh -c "exit 1"`).rejects.toThrow(); - }); - }); - describe('Fluent Shell API - .result() Non-throwable Execution', () => { it('should return success result for successful command', async () => { const $ = createShell().asFluent(); - const result = await $`echo test`.result(); + const result = await $('echo test').result(); expect(result.success).toBe(true); expect(result.stdout).toBe('test'); @@ -1118,7 +1045,7 @@ describe('Shell', () => { it('should return failure result without throwing', async () => { const $ = createShell().asFluent(); - const result = await $`sh -c "exit 1"`.result(); + const result = await $('sh -c "exit 1"').result(); expect(result.success).toBe(false); expect(result.exitCode).toBe(1); @@ -1136,7 +1063,7 @@ describe('Shell', () => { it('should capture stderr in result', async () => { const $ = createShell().asFluent(); - const result = await $`sh -c "echo error >&2; exit 1"`.result(); + const result = await $('sh -c "echo error >&2; exit 1"').result(); expect(result.success).toBe(false); expect(result.stderr).toBe('error'); @@ -1154,7 +1081,7 @@ describe('Shell', () => { it('should include both stdout and stderr on success', async () => { const $ = createShell().asFluent(); - const result = await $`sh -c "echo out; echo err >&2"`.result(); + const result = await $('sh -c "echo out; echo err >&2"').result(); expect(result.success).toBe(true); expect(result.stdout).toBe('out'); @@ -1168,7 +1095,7 @@ describe('Shell', () => { const $ = createShell({ verbose: true, logger: { debug: mockDebug } }).asFluent(); // Create handle - should NOT execute yet - const handle = $`echo test`; + const handle = $('echo test'); // No execution yet expect(mockDebug).not.toHaveBeenCalled(); @@ -1184,7 +1111,7 @@ describe('Shell', () => { const mockDebug = vi.fn(); const $ = createShell({ verbose: true, logger: { debug: mockDebug } }).asFluent(); - const handle = $`echo test`; + const handle = $('echo test'); expect(mockDebug).not.toHaveBeenCalled(); const result = await handle; @@ -1197,7 +1124,7 @@ describe('Shell', () => { const mockDebug = vi.fn(); const $ = createShell({ verbose: true, logger: { debug: mockDebug } }).asFluent(); - const handle = $`echo test`; + const handle = $('echo test'); expect(mockDebug).not.toHaveBeenCalled(); const result = await handle.result(); @@ -1211,7 +1138,7 @@ describe('Shell', () => { const mockDebug = vi.fn(); const $ = createShell({ verbose: true, logger: { debug: mockDebug } }).asFluent(); - const handle = $`echo test`; + const handle = $('echo test'); expect(mockDebug).not.toHaveBeenCalled(); const result = await handle.toLines(); @@ -1225,7 +1152,7 @@ describe('Shell', () => { const $ = createShell({ verbose: true, logger: { debug: mockDebug } }).asFluent(); const schema = z.object({ value: z.string() }); - const handle = $`echo '{"value":"test"}'`; + const handle = $('echo \'{"value":"test"}\''); expect(mockDebug).not.toHaveBeenCalled(); const result = await handle.parse(schema); @@ -1240,7 +1167,7 @@ describe('Shell', () => { const mockDebug = vi.fn(); const $ = createShell({ verbose: true, logger: { debug: mockDebug } }).asFluent(); - const handle = $`echo test`; + const handle = $('echo test'); const result1 = await handle; const result2 = await handle; @@ -1258,7 +1185,7 @@ describe('Shell', () => { const mockDebug = vi.fn(); const $ = createShell({ verbose: true, logger: { debug: mockDebug } }).asFluent(); - const handle = $`echo test`; + const handle = $('echo test'); const result1 = await handle; const result2 = await handle.result(); @@ -1275,7 +1202,7 @@ describe('Shell', () => { const mockDebug = vi.fn(); const $ = createShell({ verbose: true, logger: { debug: mockDebug } }).asFluent(); - const handle = $`printf "a\nb"`; + const handle = $('printf "a\nb"'); const result1 = await handle.result(); const result2 = await handle.toLines(); @@ -1291,7 +1218,7 @@ describe('Shell', () => { const mockDebug = vi.fn(); const $ = createShell({ verbose: true, logger: { debug: mockDebug } }).asFluent(); - const handle = $`echo '{"value":"test"}'`; + const handle = $('echo \'{"value":"test"}\''); const schema = z.object({ value: z.string() }); const result1 = await handle; @@ -1309,7 +1236,7 @@ describe('Shell', () => { it('should handle errors consistently across multiple consumptions', async () => { const $ = createShell().asFluent(); - const handle = $`sh -c "exit 1"`; + const handle = $('sh -c "exit 1"'); // First consumption throws await expect(handle).rejects.toThrow(); @@ -1323,22 +1250,6 @@ describe('Shell', () => { expect(result.exitCode).toBe(1); }); - it('should memoize with tagged template interpolation', async () => { - const mockDebug = vi.fn(); - const $ = createShell({ verbose: true, logger: { debug: mockDebug } }).asFluent(); - const name = 'world'; - - const handle = $`echo hello ${name}`; - - const result1 = await handle; - const result2 = await handle; - - expect(result1).toBe('hello world'); - expect(result2).toBe('hello world'); - - // Should only execute once - expect(mockDebug).toHaveBeenCalledTimes(1); - }); }); describe('Fluent Shell API - safeParse() Non-throwable', () => { @@ -1349,7 +1260,7 @@ describe('Shell', () => { version: z.string(), }); - const result = await $`echo '{"name":"test","version":"1.0.0"}'`.safeParse(schema); + const result = await $('echo \'{"name":"test","version":"1.0.0"}\'').safeParse(schema); expect(result.success).toBe(true); if (result.success) { @@ -1362,7 +1273,7 @@ describe('Shell', () => { const $ = createShell().asFluent(); const schema = z.object({ value: z.string() }); - const result = await $`sh -c "exit 1"`.safeParse(schema); + const result = await $('sh -c "exit 1"').safeParse(schema); expect(result.success).toBe(false); if (!result.success) { @@ -1375,7 +1286,7 @@ describe('Shell', () => { const $ = createShell().asFluent(); const schema = z.object({ value: z.string() }); - const result = await $`true`.safeParse(schema); + const result = await $('true').safeParse(schema); expect(result.success).toBe(false); if (!result.success) { @@ -1387,7 +1298,7 @@ describe('Shell', () => { const $ = createShell().asFluent(); const schema = z.object({ value: z.string() }); - const result = await $`echo "not valid json"`.safeParse(schema); + const result = await $('echo "not valid json"').safeParse(schema); expect(result.success).toBe(false); if (!result.success) { @@ -1402,7 +1313,7 @@ describe('Shell', () => { count: z.number(), }); - const result = await $`echo '{"name":"test","count":"not-a-number"}'`.safeParse(schema); + const result = await $('echo \'{"name":"test","count":"not-a-number"}\'').safeParse(schema); expect(result.success).toBe(false); if (!result.success) { @@ -1443,7 +1354,7 @@ describe('Shell', () => { }), }); - const result = await $`echo '{"user":{"name":"Alice","email":"alice@example.com"}}'`.safeParse(schema); + const result = await $('echo \'{"user":{"name":"Alice","email":"alice@example.com"}}\'').safeParse(schema); expect(result.success).toBe(true); if (result.success) { @@ -1458,7 +1369,7 @@ describe('Shell', () => { items: z.array(z.string()), }); - const result = await $`echo '{"items":["a","b","c"]}'`.safeParse(schema); + const result = await $('echo \'{"items":["a","b","c"]}\'').safeParse(schema); expect(result.success).toBe(true); if (result.success) { @@ -1471,15 +1382,15 @@ describe('Shell', () => { const schema = z.object({ value: z.string() }); // Command failure - should not throw - const result1 = await $`sh -c "exit 1"`.safeParse(schema); + const result1 = await $('sh -c "exit 1"').safeParse(schema); expect(result1.success).toBe(false); // Invalid JSON - should not throw - const result2 = await $`echo "{{{"`.safeParse(schema); + const result2 = await $('echo "{{{"').safeParse(schema); expect(result2.success).toBe(false); // Schema validation failure - should not throw - const result3 = await $`echo '{"value":123}'`.safeParse(schema); + const result3 = await $('echo \'{"value":123}\'').safeParse(schema); expect(result3.success).toBe(false); // All should have returned without throwing @@ -1491,7 +1402,7 @@ describe('Shell', () => { const $ = createShell({ verbose: true, logger: { debug: mockDebug } }).asFluent(); const schema = z.object({ value: z.string() }); - const handle = $`echo '{"value":"test"}'`; + const handle = $('echo \'{"value":"test"}\''); // First call with safeParse const result1 = await handle.safeParse(schema); @@ -1520,7 +1431,7 @@ describe('Shell', () => { uppercase: data.name.toUpperCase(), })); - const result = await $`echo '{"name":"hello"}'`.safeParse(schema); + const result = await $('echo \'{"name":"hello"}\'').safeParse(schema); expect(result.success).toBe(true); if (result.success) { @@ -1534,7 +1445,7 @@ describe('Shell', () => { const shell = createShell({ outputMode: 'capture' }); const $ = shell.asFluent(); - const result = await $`echo hello`; + const result = await $('echo hello'); expect(result).toBe('hello'); }); @@ -1590,7 +1501,7 @@ describe('Shell', () => { const shell = createShell({ outputMode: 'all' }); const $ = shell.asFluent(); - const lines = await $`printf "line1\\nline2"`.toLines(); + const lines = await $('printf "line1\\nline2"').toLines(); expect(lines).toEqual(['line1', 'line2']); }); @@ -1600,7 +1511,7 @@ describe('Shell', () => { const $ = shell.asFluent(); const schema = z.object({ value: z.string() }); - const data = await $`echo '{"value":"test"}'`.parse(schema); + const data = await $('echo \'{"value":"test"}\'').parse(schema); expect(data.value).toBe('test'); }); @@ -1637,15 +1548,6 @@ describe('Shell', () => { expect(result.stdout).toBe('override'); }); - it('should work with tagged templates in all mode', async () => { - const shell = createShell({ outputMode: 'all' }); - const $ = shell.asFluent(); - const name = 'world'; - - const result = await $`echo hello ${name}`; - - expect(result).toBe('hello world'); - }); it('should handle options merging with outputMode override', async () => { const shell = createShell({ outputMode: 'capture', verbose: true }); @@ -1663,7 +1565,7 @@ describe('Shell', () => { const $ = shell.asFluent(); const schema = z.object({ key: z.string() }); - const result = await $`echo '{"key":"value"}'`.safeParse(schema); + const result = await $('echo \'{"key":"value"}\'').safeParse(schema); expect(result.success).toBe(true); if (result.success) { From 7db9acda0390d415043a24028612846f0213a329 Mon Sep 17 00:00:00 2001 From: Thada Wangthammang Date: Mon, 10 Nov 2025 10:23:42 +0700 Subject: [PATCH 16/19] docs: remove outdated design goal document for Fluent API --- .agent/design-goal.md | 94 ------------------------------------------- 1 file changed, 94 deletions(-) delete mode 100644 .agent/design-goal.md diff --git a/.agent/design-goal.md b/.agent/design-goal.md deleted file mode 100644 index c1bcb61..0000000 --- a/.agent/design-goal.md +++ /dev/null @@ -1,94 +0,0 @@ -เยี่ยมครับ — ถ้าคุณเลือกแนวทาง .result() เป็นแกนหลักของ Fluent API -เราจะออกแบบ “ตระกูล methods” ที่สอดคล้องกันได้หลายแบบ โดยยึดหลักว่าแต่ละเมธอดคือ finalizer — ตัวปิดท้ายของ $() ที่ “รันจริง + คืนผลในรูปแบบหนึ่ง” (ไม่ใช่ chain ต่อ) - -⸻ - -🌱 ตระกูลเมธอดที่ต่อยอดได้จาก .result() - -🔹 กลุ่ม “ผลลัพธ์พื้นฐาน” (Basic outcomes) - -Method Return type แนวคิด -.result() Promise คืนผลรวมพื้นฐาน stdout/stderr/exitCode -.stdout() Promise คืนเฉพาะ stdout โดยตรง -.stderr() Promise คืนเฉพาะ stderr -.exitCode() Promise คืน exit code อย่างเดียว -.success() Promise คืนสถานะสำเร็จ (exitCode === 0) - - -⸻ - -🔹 กลุ่ม “ผลลัพธ์เชิงข้อมูล” (Structured output) - -Method Return type แนวคิด -.json() Promise แปลง stdout เป็น JSON -.parse(schema) Promise แปลง stdout ด้วย Zod/Standard Schema -.lines() Promise split stdout เป็นบรรทัด -.csv() Promise[]> parse stdout เป็น CSV -.table() Promise[]> แปลง stdout เป็น table-like data (smart detect) - - -⸻ - -🔹 กลุ่ม “ผลลัพธ์เชิงระบบ” (System & metadata) - -Method Return type แนวคิด -.timing() { durationMs: number, exitCode: number } คืนข้อมูลเวลารัน + สถานะ -.env() Promise> แปลง stdout เป็น key=value env -.files() Promise ใช้กับ ls หรือ find-like command -.pid() Promise คืน process id (ถ้ามี) - - -⸻ - -🔹 กลุ่ม “ผลลัพธ์เชิง functional / effectful” - -Method Return type แนวคิด -.effect() Promise คืนผลในรูปแบบ functional (Result) -.either() Promise> สไตล์ FP สำหรับ error-safe pipeline -.tap(fn) Promise ทำ side effect เช่น log โดยไม่เปลี่ยนผลลัพธ์ -.map(fn) Promise แปลง stdout ตามฟังก์ชัน - - -⸻ - -🔹 กลุ่ม “การประมวลผลต่อเนื่อง” (Streaming / Interactive) - -Method Return type แนวคิด -.stream() ReadableStream stream stdout แบบต่อเนื่อง -.pipeTo(target) Promise pipe stdout ไป process/file -.observe(callback) Promise รับ event ระหว่างรัน -.interactive() Promise เปิด stdin/stdout inherit เต็มรูปแบบ - - -⸻ - -🧭 แนว grouping ภายหลัง - -คุณสามารถใช้แนว grouping ตาม prefix เพื่อจัดหมวด API ให้สวยได้ภายหลัง เช่น - • Data-oriented → .json(), .csv(), .lines(), .table() - • Result-oriented → .result(), .effect(), .either() - • System-oriented → .timing(), .pid(), .exitCode() - • Stream-oriented → .stream(), .pipeTo(), .observe() - -⸻ - -✨ ตัวอย่างภาพรวมการใช้งานในอนาคต - -const users = await $`cat users.json`.parse(UserArraySchema); -const files = await $`ls -1`.lines(); -const log = await $`npm test`.result(); -const time = await $`sleep 1`.timing(); -const ok = await $`exit 0`.success(); - -await $`ls -la`.pipeTo(process.stdout); -await $`echo hi`.tap(console.log); - - -⸻ - -สรุป: - -✅ ใช้ .result() เป็น core finalizer ดีมาก เพราะเปิดทางให้คุณออกแบบ “families ของ finalizers” ต่อไปได้หลากหลาย — -โดยรักษาแนวคิดเดียวกันว่า “ทุกเมธอดคือจุดปิดของ shell expression ที่คืนค่าในรูปแบบหนึ่งของผลลัพธ์” - -นั่นทำให้ Fluent API ของคุณทั้ง อ่านลื่น, ขยายได้, และ รักษา semantics ของคำว่า “result” ได้ตรงมาก. \ No newline at end of file From 06e456a0ac6b143ae78b2aed6c4e7730ae9dcbd2 Mon Sep 17 00:00:00 2001 From: Thada Wangthammang Date: Mon, 10 Nov 2025 10:39:55 +0700 Subject: [PATCH 17/19] docs: remove design specifications and pre-development plans for FluentShell API enhancement --- .DS_Store | Bin 0 -> 6148 bytes .agent/.DS_Store | Bin 0 -> 6148 bytes .agent/README.md | 10 + .agent/task-1/pre-dev-plan.md | 159 ---------- .agent/task-1/spec.md | 119 ------- .agent/task-2/pre-dev-plan.md | 489 ---------------------------- .agent/task-2/spec.md | 110 ------- .agent/task-3/pre-dev-plan.md | 579 ---------------------------------- .agent/task-3/spec.md | 198 ------------ 9 files changed, 10 insertions(+), 1654 deletions(-) create mode 100644 .DS_Store create mode 100644 .agent/.DS_Store create mode 100644 .agent/README.md delete mode 100644 .agent/task-1/pre-dev-plan.md delete mode 100644 .agent/task-1/spec.md delete mode 100644 .agent/task-2/pre-dev-plan.md delete mode 100644 .agent/task-2/spec.md delete mode 100644 .agent/task-3/pre-dev-plan.md delete mode 100644 .agent/task-3/spec.md diff --git a/.DS_Store b/.DS_Store new file mode 100644 index 0000000000000000000000000000000000000000..4ae5dff320ec273eec2cf26176a105ce808a83d1 GIT binary patch literal 6148 zcmeHKJ8AX(Sa9PqrA`pY4a8!fz!yj$#!Xm*u~Tp5bLD7x^JzA+yLDsU!01V% zc@lcX&W?y^fBt$GS&2voH5fvm6Ha^7Qk8Wo@d51@d39}3*CCXRvr>A>JE0C0h@8|L0i z0E-2HHE|3?1g1d+2351g(4Zq;GOs3%fk79|=0o#l%??HVcAQ^4U9<*rqyki6s=#wB zS62Tw@EiUAl*APkpaKu2fG*bCb&n@yZEYTBwYI=N;Fj|XH^ba17`z+H1@V-^m;4Wg<&0T*E43hX&L&p$$qDprKhvt+--jT7}7np#A3 zem<@ulZcFPQ@L2!n>{z**++&mCkOWA81W14cNZlEfg7;MkzE(HCqgga^y>{tEnwC%0;vJ&^%eQ zLs35+`xjp>T0` with helper methods -- Support direct awaiting: `await $('echo test')` returns stdout as string -- Support helper methods before await: `await $('ls').toLines()` returns array of lines -- Support schema parsing: `await $('gh api /user').parse(UserSchema)` with Zod-like schemas - -## Architecture Design - -### 1. CommandHandle Type -```typescript -type CommandHandle = PromiseLike & { - toLines(): Promise; - parse(schema: { parse(x: any): T }): Promise; -}; -``` - -### 2. Fluent Shell Function Signature -```typescript -export type FluentShellFn = (command: string | string[]) => CommandHandle; -``` - -### 3. Shell Class Extension -Add method to Shell class: -```typescript -class Shell { - // ... existing methods ... - - asFluent(): FluentShellFn { - // Implementation - } -} -``` - -## Implementation Steps - -### Step 1: Define Types -- Create `CommandHandle` interface in `src/shell.ts` -- Create `FluentShellFn` type alias -- Ensure types are exported from `src/index.ts` - -### Step 2: Implement asFluent() Method -Add to Shell class: -- Return a function that accepts command (string | string[]) -- Function returns CommandHandle instance -- Leverage existing `run()` method for execution -- Handle output mode appropriately (should default to 'capture') - -### Step 3: Implement CommandHandle -Create CommandHandle by: -- Execute command via `this.run()` to get a promise -- Create an object that implements `PromiseLike` -- Bind the promise's `.then()` method to make it thenable -- Add helper methods: - - `toLines()`: Split stdout by newlines (`/\r?\n/`) - - `parse(schema)`: Parse stdout as JSON, then validate with schema - -### Step 4: Handle Edge Cases -- Empty stdout handling in `toLines()` -- JSON parse errors in `parse()` -- Propagate errors from underlying command execution -- Consider output modes (only 'capture' makes sense for fluent API) - -### Step 5: Add Tests -Create tests in `test/shell.test.ts`: -- Direct await: `await $('echo test')` returns 'test' -- toLines(): `await $('printf "a\nb\nc"').toLines()` returns ['a', 'b', 'c'] -- parse(): `await $('echo \'{"key":"value"}\'').parse(schema)` returns object -- Error propagation when command fails -- Test with both string and array command formats - -### Step 6: Add Usage Examples -Update or create example files showing: -- Basic usage: `const result = await $('ls -la')` -- Using toLines(): `const files = await $('ls').toLines()` -- Using parse() with Zod schema -- Custom shell config: `createShell({ verbose: true }).asFluent()` - -## Technical Considerations - -### PromiseLike Implementation -The key pattern from the spec: -```typescript -const execPromise = this.run(command); -const handle: Partial = {}; - -// Make it thenable -handle.then = execPromise.then.bind(execPromise); - -// Add helpers -handle.toLines = () => execPromise.then(s => s.split(/\r?\n/)); -handle.parse = (schema) => execPromise.then(s => schema.parse(JSON.parse(s))); - -return handle as CommandHandle; -``` - -### Output Mode Behavior -- Fluent API should use 'capture' mode by default -- 'live' mode doesn't make sense (returns null for stdout) -- Could potentially allow override via options - -### Error Handling -- Command execution errors should propagate through the promise chain -- JSON parse errors in `parse()` should also propagate -- Schema validation errors should propagate from schema.parse() - -### Type Safety -- `parse()` should infer return type `T` from schema -- Ensure TypeScript properly recognizes `CommandHandle` as both awaitable and having helper methods -- Return type of direct await should be `string` -- Return type of `toLines()` should be `Promise` -- Return type of `parse(schema)` should be `Promise` - -## Files to Modify - -1. `src/shell.ts` - - Add `CommandHandle` type - - Add `FluentShellFn` type - - Add `asFluent()` method to Shell class - -2. `src/index.ts` - - Export new types: `CommandHandle`, `FluentShellFn` - -3. `test/shell.test.ts` - - Add test suite for fluent API - -4. Examples (optional) - - Create or update examples showing fluent API usage - -## Success Criteria - -- ✅ `const $ = createShell().asFluent()` works -- ✅ `await $('echo test')` returns 'test' -- ✅ `await $('ls').toLines()` returns array of lines -- ✅ `await $('echo \'{"a":1}\'').parse(schema)` parses JSON -- ✅ All tests pass -- ✅ TypeScript compilation succeeds with no errors -- ✅ Proper error propagation from failed commands -- ✅ Clean API that matches the spec examples - -## Non-Goals (Out of Scope) - -- Modifying existing `run()`, `safeRun()`, or `execute()` methods -- Changing how the Shell class handles stdio/output modes -- Adding new command execution logic (reuse existing methods) -- Supporting chaining of helper methods (not required by spec) -- Supporting options override in fluent API (can be added later if needed) - -## Notes - -- The fluent API is a convenience layer on top of existing Shell functionality -- It should delegate to `run()` for actual execution -- Focus on developer ergonomics and type safety -- Keep implementation simple - it's essentially a wrapper with helper methods diff --git a/.agent/task-1/spec.md b/.agent/task-1/spec.md deleted file mode 100644 index 5e025cd..0000000 --- a/.agent/task-1/spec.md +++ /dev/null @@ -1,119 +0,0 @@ - -## Short form - -- export const shell = createShell({ verbose: true }); - -## cleaner shell api - -- export const $ = shell.asFluent(); // in the library code of @thaitype/shell -or user need some custom config, user can do like this: - -```ts -import { createShell } from '@thaitype/shell'; -export const $ = createShell({ verbose: true }).asFluent(); - -``` - -for clean shell api usage: - -the user can use the `$` to run shell command directly and get the result in promise. no need to use `.stdout` or `.stderr` to get the result. - -```ts -import { $ } from '@thaitype/shell'; - -const files = await $('ls -la').toLines(); -for(const file of files) { - console.log(`File: ${file}`); -} -``` - -note: `toLines()` is a helper method to split the stdout by new line and return as array of strings. -also, user can use other helper methods like `parse(zodObjectSchema)` to parse the stdout into object using zod schema. - - -const data = await $('echo test'); -await $(`mkdir ${data}`); - - -🔧 ตัวอย่างโค้ด $ แบบ Thenable Handle - -import { execa } from "execa"; - -// แค่ helper ตัวเล็ก จำลอง execa -async function runCommand(cmd: string): Promise { - const { stdout } = await execa("bash", ["-lc", cmd]); - return stdout.trim(); -} - -// พื้นฐานของ CommandHandle -type CommandHandle = PromiseLike & { - toLines(): Promise; - parse(schema: { parse(x: any): T }): Promise; -}; - -// ตัวสร้าง $ -function $(cmd: string): CommandHandle { - // ตัว Promise จริง ๆ ที่จะรันคำสั่ง - const execPromise = runCommand(cmd); - - // สร้าง handle ว่าง ๆ ขึ้นมา - const handle: Partial = {}; - - // ทำให้ await handle ทำงานได้ (thenable) - handle.then = execPromise.then.bind(execPromise); - - // เพิ่ม helper method ต่าง ๆ - handle.toLines = () => execPromise.then((s) => s.split(/\r?\n/)); - handle.parse = (schema) => - execPromise.then((s) => schema.parse(JSON.parse(s))); - - // คืน handle (พร้อม type casting) - return handle as CommandHandle; -} - - -⸻ - -💻 ตัวอย่างการใช้งานจริง - -// ✅ ใช้แบบรับ stdout ตรง ๆ -const name = await $('echo hello'); -console.log('Name:', name); // -> hello - -// ✅ ใช้ helper แปลงเป็น array ของบรรทัด -const files = await $('ls -la').toLines(); -for (const file of files) { - console.log('File:', file); -} - -// ✅ ใช้ parse() กับ Zod schema -import { z } from "zod"; - -const UserSchema = z.object({ - login: z.string(), - id: z.number(), -}); - -const user = await $('gh api /user').parse(UserSchema); -console.log('User login:', user.login); - - -⸻ - -🧠 สรุปแนวคิด - -ฟีเจอร์ ทำงานอย่างไร -await $('cmd') ใช้ .then() ของ handle → คืน stdout (string) -await $('cmd').toLines() เรียก helper ก่อน await → คืน string[] -await $('cmd').parse(schema) เรียก helper ก่อน await → คืน object -await h; await h.toLines() ❌ ไม่ได้ เพราะ handle ถูก resolve แล้ว - - -⸻ - -✅ ข้อดีของดีไซน์นี้ - • ใช้ได้ทั้งสองสไตล์ -→ await $('echo hi') และ await $('ls').toLines() - • TypeScript จับได้หมด (เพราะ PromiseLike) - • ไม่ต้องมี class / instance แยกออกมา - diff --git a/.agent/task-2/pre-dev-plan.md b/.agent/task-2/pre-dev-plan.md deleted file mode 100644 index 9cb4fa9..0000000 --- a/.agent/task-2/pre-dev-plan.md +++ /dev/null @@ -1,489 +0,0 @@ -# Pre-Development Plan: Enhance asFluent() with Tagged Templates and Lazy Execution - -## Overview -Enhance the existing `asFluent()` method to support tagged template literals, lazy execution, and memoization. The returned function (commonly named `$`) will support: -- Tagged template literals: `` $`echo hello` `` -- Function calls: `$('echo hello')` and `$(['echo', 'hello'])` -- Lazy execution: doesn't run until consumed -- Memoization: one handle, one execution -- `.result()` method for non-throwable execution - -## Before and After - -### Before (Task-1): -```typescript -const shell = createShell({ verbose: true }); -const $ = shell.asFluent(); - -// Only function calls supported -const result = await $('echo hello'); // ✅ Works -const lines = await $('ls').toLines(); // ✅ Works - -// Tagged templates NOT supported -await $`echo hello`; // ❌ Doesn't work -``` - -### After (Task-2): -```typescript -const shell = createShell({ verbose: true }); -const $ = shell.asFluent(); - -// Function calls still work (backward compatible) -const result = await $('echo hello'); // ✅ Still works -const lines = await $('ls').toLines(); // ✅ Still works - -// Tagged templates NOW supported -const output = await $`echo hello`; // ✅ Now works! -const files = await $`ls -la`.toLines(); // ✅ Now works! - -// Interpolation in tagged templates -const name = 'world'; -const msg = await $`echo hello ${name}`; // ✅ New feature! - -// Non-throwable execution with .result() -const r = await $`exit 1`.result(); // ✅ New feature! -if (!r.success) { - console.error(r.exitCode, r.stderr); -} - -// Lazy execution + memoization -const handle = $`echo test`; -const a = await handle; // Executes once -const b = await handle.result(); // Reuses same execution -``` - -## Goals -- **Enhance `asFluent()`** to return a function that supports: - - Tagged template: `` await $`echo ${name}` `` - - Function calls: `await $('echo hello')` and `await $(['echo', 'hello'])` -- **Add lazy execution**: only starts when first "consumed" (await, .result(), .toLines(), .parse()) -- **Add memoization**: one handle, one execution, shared by all consumers -- **Add `.result()` method**: non-throwable execution path -- **Maintain backward compatibility**: existing function call syntax still works - -## Key Enhancements from Current asFluent() - -### Current `asFluent()` behavior (from task-1): -- Returns a function: `(command: string | string[]) => CommandHandle` -- Executes immediately when called -- Each method call creates new execution -- No memoization -- Only supports function calls, not tagged templates - -### Enhanced behavior (task-2): -- Returns a function: `DollarFunction` (supports tagged templates + function calls) -- Deferred execution (lazy) -- Execution starts only when consumed (await, .result(), .toLines(), .parse()) -- One execution shared by all methods (memoized) -- Supports tagged templates in addition to function calls -- Has `.result()` for non-throwable execution - -## Architecture Design - -### 1. New Types - -```typescript -/** - * Result type for non-throwable execution - */ -export type CommandResult = { - success: boolean; // exitCode === 0 - stdout: string; - stderr: string; - exitCode: number | undefined; // undefined when process failed to start -}; - -/** - * Command handle with lazy execution and memoization - * Supports both throwable (await) and non-throwable (.result()) patterns - */ -export type LazyCommandHandle = PromiseLike & { - /** - * Non-throwable execution - returns result object with success flag - */ - result(): Promise; - - /** - * Split stdout into array of lines - */ - toLines(): Promise; - - /** - * Parse stdout as JSON and validate with schema - */ - parse(schema: { parse(x: unknown): T }): Promise; -}; - -/** - * Overloaded $ function signatures - */ -export interface DollarFunction { - // Tagged template - (parts: TemplateStringsArray, ...values: any[]): LazyCommandHandle; - - // String command - (command: string): LazyCommandHandle; - - // Argv array - (command: string[]): LazyCommandHandle; -} -``` - -### 2. Shell Class Modification - -Modify existing `asFluent()` method in Shell class: -```typescript -class Shell { - // ... existing methods ... - - /** - * Create a fluent shell function with tagged template support and lazy execution. - * The returned function can be used with tagged templates or regular function calls. - * - * @example Tagged template - * ```typescript - * const $ = shell.asFluent(); - * const files = await $`ls -la`.toLines(); - * ``` - * - * @example Function call - * ```typescript - * const $ = shell.asFluent(); - * const result = await $('echo hello'); - * ``` - */ - asFluent(): DollarFunction { - // NEW implementation with lazy execution and tagged template support - } -} -``` - -**Note**: This replaces the current `createFluentShell()` implementation from task-1. - -### 3. Lazy Execution Implementation Pattern - -```typescript -function createLazyHandle(shell: Shell, command: string | string[]): LazyCommandHandle { - // Memoized execution promise - let executionPromise: Promise | null = null; - - // Lazy executor - only runs once, then memoizes - const start = (): Promise => { - if (executionPromise === null) { - executionPromise = shell.safeRun(command, { outputMode: 'capture' }) - .then(result => ({ - success: result.success, - stdout: result.stdout ?? '', - stderr: result.stderr ?? '', - exitCode: result.exitCode, - })); - } - return executionPromise; - }; - - const handle: Partial = {}; - - // Throwable path: await handle - handle.then = (onFulfilled, onRejected) => { - return start().then(result => { - if (!result.success) { - // Throw error based on throwMode - throw new Error(`Command failed with exit code ${result.exitCode}`); - } - return result.stdout; - }).then(onFulfilled, onRejected); - }; - - // Non-throwable path: handle.result() - handle.result = () => start(); - - // Helper methods - handle.toLines = () => start().then(result => { - if (!result.success) { - throw new Error(`Command failed with exit code ${result.exitCode}`); - } - if (!result.stdout) return []; - return result.stdout.split(/\r?\n/); - }); - - handle.parse = (schema: { parse(x: unknown): T }): Promise => { - return start().then(result => { - if (!result.success) { - throw new Error(`Command failed with exit code ${result.exitCode}`); - } - const parsed = JSON.parse(result.stdout); - return schema.parse(parsed); - }); - }; - - return handle as LazyCommandHandle; -} -``` - -### 4. Tagged Template Processing - -```typescript -function processTaggedTemplate( - parts: TemplateStringsArray, - values: any[] -): string { - // Interleave parts and values - // For now: simple string interpolation - // Later: proper argv-safe escaping for interpolated values - let result = parts[0]; - for (let i = 0; i < values.length; i++) { - result += String(values[i]) + parts[i + 1]; - } - return result; -} -``` - -## Implementation Steps - -### Step 1: Add New Types to src/shell.ts -- Define `CommandResult` type (for `.result()` method) -- Define `LazyCommandHandle` type (extends `CommandHandle` with `.result()`) -- Define `DollarFunction` interface with overloads for tagged templates -- Keep existing `CommandHandle` and `FluentShellFn` types for backward compatibility -- Export all new types - -### Step 2: Implement Lazy Execution Core -- Create `createLazyHandle()` helper function -- Implement memoized `start()` function that runs command only once -- Implement `then` method for throwable path (replaces immediate execution) -- Implement `result()` method for non-throwable path (new feature) -- Update `toLines()` and `parse()` to use lazy execution - -### Step 3: Implement Tagged Template Processing -- Create `processTaggedTemplate()` helper function -- Simple string interpolation for now (concatenate parts and values) -- Note: argv-safe escaping to be added later (future enhancement) - -### Step 4: Replace asFluent() Implementation in Shell Class -- Remove current `createFluentShell()` implementation -- Implement new `asFluent()` method that: - - Returns `DollarFunction` (supports tagged templates + function calls) - - Detects tagged template vs function call - - Processes input and delegates to `createLazyHandle()` - - Maintains backward compatibility with function call syntax - -### Step 5: Export from src/index.ts -- Export new types: `CommandResult`, `LazyCommandHandle`, `DollarFunction` -- Keep existing exports for backward compatibility - -### Step 6: Update Existing Tests -- Modify `test/shell.test.ts`: - - Keep existing "Fluent Shell API - asFluent" tests - - Update tests to verify lazy execution behavior - - Add tests for new `.result()` method - -### Step 7: Add New Tests for Tagged Templates and Lazy Execution -Create additional test cases in `test/shell.test.ts`: -- **Basic Functionality**: - - Tagged template: `` await $`echo test` `` - - String call: `await $('echo test')` - - Array call: `await $(['echo', 'test'])` - -- **Lazy Execution**: - - Verify execution doesn't start immediately - - Verify execution starts on first consume - -- **Memoization**: - - Multiple awaits share same execution - - `.result()` and `await` share same execution - - `.toLines()` and `.parse()` share same execution - -- **Throwable Path**: - - `await $`exit 1`` throws error - - Error includes exit code and stderr - - Respects throwMode setting - -- **Non-throwable Path**: - - `.result()` doesn't throw - - Returns CommandResult with success: false - - Includes stdout, stderr, exitCode - -- **Helper Methods**: - - `.toLines()` splits output correctly - - `.parse()` parses JSON with schema - - Both helpers throw on command failure - -- **Template Interpolation**: - - `` $`echo ${value}` `` includes interpolated values - - Multiple interpolations work - - Edge cases: empty strings, numbers, etc. - -### Step 8: Add Usage Examples -Update `examples/fluentShell.ts` or create `examples/lazyFluent.ts`: -- Tagged template basic usage -- Function call usage (existing examples still work) -- `.result()` for non-throwable execution (new) -- `.toLines()` and `.parse()` with templates -- Demonstrating lazy + memoization behavior -- Show that `asFluent()` now supports both styles - -## Technical Considerations - -### Lazy Execution Details -- Execution promise is `null` initially -- First consumer (await, .result(), etc.) triggers start() -- start() creates promise and memoizes it -- Subsequent consumers reuse memoized promise -- No race conditions: all consumers wait on same promise - -### Memoization Guarantee -```typescript -const handle = $`echo test`; -const a = await handle; // Executes once -const b = await handle; // Reuses same execution -const c = await handle.result(); // Still same execution -``` - -### Tagged Template vs Function Call Detection -```typescript -public asFluent(): DollarFunction { - return ((firstArg: any, ...rest: any[]) => { - // Check if it's a tagged template call - if ( - Array.isArray(firstArg) && - 'raw' in firstArg && - Array.isArray(firstArg.raw) - ) { - // Tagged template: firstArg is TemplateStringsArray - const command = processTaggedTemplate(firstArg, rest); - return createLazyHandle(this, command); - } - - // Function call with string or array - return createLazyHandle(this, firstArg); - }) as DollarFunction; -} -``` - -### Error Handling Strategy -- **Throwable path** (`await $...`): - - Checks result.success in `then` callback - - Throws error if success === false - - Error format based on throwMode (simple vs raw) - -- **Non-throwable path** (`.result()`): - - Always returns CommandResult - - Never throws (unless Shell itself throws) - - User checks result.success manually - -- **Helper methods** (`.toLines()`, `.parse()`): - - Throw if command failed (exit code ≠ 0) - - Also throw on parsing errors - - Consistent with throwable semantics - -### Type Safety -- Overloaded signatures provide proper type inference -- `await $...` returns `string` -- `.result()` returns `Promise` -- `.toLines()` returns `Promise` -- `.parse(schema)` returns `Promise` where T inferred from schema - -## Files to Modify - -1. **src/shell.ts** - - Add `CommandResult` type - - Add `LazyCommandHandle` type (extends existing `CommandHandle`) - - Add `DollarFunction` interface - - **Replace** `createFluentShell()` method with enhanced `asFluent()` method - - Add helper functions: `createLazyHandle()`, `processTaggedTemplate()` - -2. **src/index.ts** - - Export new types: `CommandResult`, `LazyCommandHandle`, `DollarFunction` - -3. **test/shell.test.ts** - - **Update** existing "Fluent Shell API - asFluent" test suite - - Add new test cases for tagged templates - - Add new test cases for lazy execution and memoization - - Add new test cases for `.result()` method - -4. **examples/fluentShell.ts** or **examples/lazyFluent.ts** - - Update existing examples or create new file - - Demonstrate tagged template usage - - Demonstrate lazy execution behavior - - Demonstrate memoization guarantee - - Show `.result()` for non-throwable execution - -## Success Criteria - -- ✅ `const $ = shell.asFluent()` works -- ✅ `` const result = await $`echo test` `` works and returns 'test' -- ✅ `await $('echo test')` works (backward compatibility) -- ✅ `await $(['echo', 'test'])` works (backward compatibility) -- ✅ `` const r = await $`exit 1`.result() `` doesn't throw, r.success === false -- ✅ `` await $`exit 1` `` throws error -- ✅ `` await $`echo test`.toLines() `` returns array -- ✅ Tagged template with interpolation: `` $`echo ${value}` `` works -- ✅ Lazy execution verified (doesn't run until consumed) -- ✅ Memoization verified (multiple consumers share one execution) -- ✅ All existing tests still pass (backward compatibility) -- ✅ New tests for tagged templates and lazy execution pass -- ✅ TypeScript compilation succeeds -- ✅ Type inference works correctly for all overloads -- ✅ Respects Shell options (verbose, dryRun, throwMode) - -## Non-Goals (Out of Scope for Initial Implementation) - -- Argv-safe escaping for template interpolations (mark as TODO) -- Pipeline/redirection support (future: `` $sh`ls | grep x` ``) -- Interactive stdin auto-inherit (future: `.live()` or `.withShell()`) -- Additional methods like `.json()`, `.safeParse()`, `.csv()`, `.stream()`, `.timing()` -- Performance optimizations for template parsing -- Support for nested templates - -## Future Enhancements (Document as TODOs) - -1. **Argv-safe interpolation**: - ```typescript - // Each ${value} should be treated as single argv element - $`git commit -m ${message}` // message shouldn't word-split - ``` - -2. **Shell mode for pipelines**: - ```typescript - $sh`ls | grep .ts` - // or - $`ls -la`.withShell().pipe($`grep .ts`) - ``` - -3. **Interactive stdin**: - ```typescript - await $`vim myfile.txt`.live() - ``` - -4. **Additional helper methods**: - - `.json()` - auto-parse JSON without schema - - `.text()` - alias for direct await - - `.lines()` - alias for toLines() - - `.stream()` - return Node.js stream - - `.timing()` - include execution timing in result - -5. **Enhanced result options**: - ```typescript - await $`...`.result({ - includeTiming: true, - includeCommand: true - }) - ``` - -## Notes - -- The `asFluent()` method returns a function (`DollarFunction`), not a handle directly -- This function can be used with both tagged template and function call syntax -- Usage pattern: `const $ = shell.asFluent(); await $`echo test`` -- Or with createShell: `const $ = createShell().asFluent(); await $`ls`` -- The returned function can be named anything (commonly `$`, but could be `sh`, `cmd`, etc.) -- Consider adding a top-level export for convenience: - ```typescript - export const $ = createShell().asFluent(); - ``` -- The lazy execution is truly lazy - handle can be created without execution -- Multiple consumption of same handle is safe and efficient (memoized) -- The `.result()` method is the primary way to avoid exceptions -- **Implementation note**: This enhances the existing `asFluent()` method from task-1 -- **Backward compatibility**: All existing function call syntax continues to work -- **New behavior**: Adds tagged template support, lazy execution, and `.result()` method diff --git a/.agent/task-2/spec.md b/.agent/task-2/spec.md deleted file mode 100644 index ec2ca37..0000000 --- a/.agent/task-2/spec.md +++ /dev/null @@ -1,110 +0,0 @@ -นี่คือ Design Spec สำหรับ $ ที่รองรับทั้ง tagged template และการเรียกแบบฟังก์ชัน $(…) โดยคง lazy execution และมี .result() เป็น non-throwable finalizer - -เป้าหมาย - • เรียกใช้สั้นแบบ DSL: - • await $echo hi`` → คืน stdout (throwable) - • await $exit 2.result() → คืน { success, stdout, stderr, exitCode } (non-throwable) - • รองรับสองรูปแบบอินพุต: tagged template และ function call (string หรือ string[]) - • เป็น lazy จริง: ค่อยเริ่มรันเมื่อถูก “consume” ครั้งแรก (เช่น await, .result(), .toLines(), .parse()) - -พฤติกรรมหลัก - • $ คืนค่าเป็น Thenable Command Handle (อ็อบเจ็กต์ที่ await ได้ + มีเมธอด helper) - • เส้นทาง throwable: await $(…) หรือ await $…`` → ถ้า exit code ≠ 0 ให้โยนข้อผิดพลาด - • เส้นทาง non-throwable: await $(…).result() → คืนอ็อบเจ็กต์ผลแบบปลอดภัย - • ทุกเมธอด/การ await ภายใต้ handle เดียวกัน แชร์ execution เดียว (memoize) - -รูปแบบอินพุตที่รองรับ - • Tagged template: - -$`echo ${name} and ${age}` - -ส่วน literal ใช้เป็นโครงคำสั่ง, ส่วน interpolation ทุกตัวถือเป็น “อาร์กิวเมนต์เดี่ยว” (ภายหลังค่อยเติม escaping/argv-safe) - - • Function call: - • $(string) → ตัวช่วยง่าย, เหมาะข้อความคำสั่งดิบ - • $(string[]) → คุม argv ตรง ๆ เช่น $(["bash","-lc","ls -la"]) - -สัญญา (Contract) ของผลลัพธ์ - • Thenable Command Handle: PromiseLike & Helpers - • await handle → stdout: string (throwable) - • handle.result(): Promise → non-throwable - • handle.toLines(): Promise → แปลง stdout เป็นอาเรย์บรรทัด - • handle.parse(schema): Promise → แปลง stdout ด้วยสคีมา (throw เมื่อ invalid) - • Result (non-throwable): - -type Result = { - success: boolean; // exitCode === 0 - stdout: string; - stderr: string; - exitCode?: number; // undefined เมื่อ process ไม่ได้เริ่ม/ล้มก่อน start -} - - - -การออกแบบภายใน (สำคัญต่อความ “lazy”) - • ตัว $ ไม่รันทันที; จะสร้าง executor แบบ deferred: - • start(): Promise สร้างขึ้นครั้งแรกที่ถูกเรียก แล้ว memoize - • ทุกเมธอด (then, result, toLines, parse) จะเรียก start() ก่อนใช้งาน - • การทำงานของ then: - • เรียก start() → ได้ Promise - • ถ้า result.success === false ให้สร้างและ throw ข้อผิดพลาด (รูปแบบข้อความตาม throwMode) - • ถ้า success === true ส่ง result.stdout เข้าสู่ onFulfilled - • การทำงานของ .result()/.toLines()/.parse(): - • เรียก start() แล้ว map ผล โดย ไม่ เริ่ม process ซ้ำ - -การแตกโอเวอร์โหลด (Type Signature โดยย่อ) - -// 1) tagged template -function $( - parts: TemplateStringsArray, ...vals: any[] -): CommandHandle; - -// 2) string -function $(cmd: string): CommandHandle; - -// 3) argv -function $(argv: string[]): CommandHandle; - -// Thenable handle -type CommandHandle = PromiseLike & { - result(): Promise; - toLines(): Promise; - parse(schema: { parse(x: unknown): T }): Promise; -}; - -Error Model - • เส้นทาง await $… → throwable by default - • ถ้าอยากไม่โยน ให้ใช้ .result() เสมอ - • รูปแบบข้อความข้อผิดพลาดควบคุมได้ด้วย throwMode: "simple" | "raw" ที่ระดับอินสแตนซ์ Shell/$ - • ในอนาคตเพิ่ม .result({ includeTiming: true }) ได้โดยไม่กระทบสัญญาหลัก - -ตัวอย่างการใช้งาน - -// Throwable (สั้นสุด) -const who = await $`whoami`; // 'mildr' - -// Non-throwable -const r = await $`exit 2`.result(); // { success:false, exitCode:2, stdout:'', stderr:'...' } -if (!r.success) console.warn(r.exitCode, r.stderr); - -// Helper -const files = await $`ls -la`.toLines(); -const user = await $`gh api /user`.parse(UserSchema); - -// Function call forms -const text = await $('echo hello'); -const list = await $(['bash','-lc','printf "a\nb"']).toLines(); - -กติกาพิเศษ (ภายหลังค่อยเติม) - • Escaping & argv-safe: interpolation ใน template จะถูกแปลงเป็นอาร์กิวเมนต์เดี่ยวเสมอ (กันแตกคำ) - • Pipeline/Redirection: แยกเป็นโหมด shell ชัดเจน เช่น $sh\ls | grep x`หรือ.withShell()` - • Interactive stdin: auto-inherit เมื่อ .live() หรือส่ง { stdin: 'inherit' } รายคำสั่งได้ - • Memoization: handle เดียวกัน ถูก consume หลายแบบต้องอาศัยผลจาก execution เดียว - -เหตุผลที่ดีไซน์นี้ตอบโจทย์ - • API เรียบ สั้น ใช้ได้ทั้งสองสไตล์ โดยยังคง lazy จริง - • ไม่บังคับผู้ใช้เลือกระหว่าง template vs function; ทั้งสองทางวิ่งเข้า กลไกเดียวกัน - • แยก throwable vs non-throwable แบบชัดเจนผ่าน await กับ .result() - • ขยายต่อได้ง่าย: .json(), .safeParse(), .csv(), .stream(), .timing() โดยไม่ทำลายสัญญาหลัก - -สรุป: ใช้ $ เป็น thenable handle ที่รองรับ tagged template และ $(…) พร้อม .result() เป็น non-throwable finalizer จะได้ API ที่สวย, lazy, และขยายได้ในอนาคต. \ No newline at end of file diff --git a/.agent/task-3/pre-dev-plan.md b/.agent/task-3/pre-dev-plan.md deleted file mode 100644 index 2da10f4..0000000 --- a/.agent/task-3/pre-dev-plan.md +++ /dev/null @@ -1,579 +0,0 @@ -# FluentShell API Enhancement - Pre-Development Plan - -## Overview - -Enhance FluentShell (`asFluent()` / `$`) to align with `ShellOptions` while maintaining safety and type correctness. The fluent API will support `capture` and `all` modes but explicitly reject `live` mode since fluent operations require stdout for chaining, parsing, and memoization. - -## Design Goals - -1. **Consistency**: FluentShell should respect `ShellOptions` configuration -2. **Safety**: Prevent `live` mode usage which breaks fluent operations -3. **Flexibility**: Allow per-command `outputMode` overrides (except `live`) -4. **Type Safety**: Enforce constraints at compile-time where possible -5. **Backward Compatibility**: Maintain existing tagged template and function call patterns - ---- - -## Current State Analysis - -### Existing Implementation (`src/shell.ts`) - -**Current `DollarFunction` signature** (lines ~424-439): -```typescript -export interface DollarFunction { - (parts: TemplateStringsArray, ...values: any[]): LazyCommandHandle; - (command: string): LazyCommandHandle; - (command: string[]): LazyCommandHandle; -} -``` - -**Current `createLazyHandle()` implementation** (lines ~828-972): -- Hardcodes `outputMode: 'capture'` at line 859 -- No options parameter - always uses capture mode -- Returns `Promise>` - -**Current `asFluent()` implementation** (lines ~1024-1037): -- No validation of shell's `outputMode` -- Always creates lazy handles with capture mode -- No per-command options support - ---- - -## Required Changes - -### 1. Type Definitions - -#### 1.1 Add `FluentOutputMode` type -**Location**: After `OutputMode` type definition (after line 14) - -```typescript -/** - * Output modes supported by FluentShell. - * Excludes 'live' mode since fluent operations require stdout for chaining and parsing. - */ -export type FluentOutputMode = Exclude; -``` - -#### 1.2 Add `FluentRunOptions` type -**Location**: After `RunOptions` definition (after line 187) - -```typescript -/** - * Options for fluent shell command execution. - * Restricts outputMode to exclude 'live' mode. - * - * @template Mode - The output mode for this command (capture or all only) - */ -export type FluentRunOptions = - Omit, 'outputMode'> & { - outputMode?: Mode; - }; -``` - -#### 1.3 Update `DollarFunction` interface -**Location**: Replace existing definition (lines ~424-439) - -```typescript -/** - * Function that supports both tagged templates and function calls for command execution. - * Returned by `shell.asFluent()`. - * - * Supports three call signatures: - * 1. Tagged template: `` $`echo hello` `` - * 2. String command: `$('echo hello')` or `$('echo hello', { outputMode: 'all' })` - * 3. Argv array: `$(['echo', 'hello'])` or `$(['echo', 'hello'], { outputMode: 'all' })` - * - * Note: FluentShell does not support 'live' mode. Use 'capture' or 'all' only. - * - * @example Tagged template - * ```typescript - * const $ = createShell().asFluent(); - * const name = 'world'; - * const result = await $`echo hello ${name}`; - * ``` - * - * @example Function call with options - * ```typescript - * const $ = createShell().asFluent(); - * const result = await $('echo hello', { outputMode: 'all' }); - * ``` - * - * @example Array call with options - * ```typescript - * const $ = createShell().asFluent(); - * const result = await $(['echo', 'hello'], { outputMode: 'all' }); - * ``` - */ -export interface DollarFunction { - /** - * Tagged template call - interpolates values into command string - */ - (parts: TemplateStringsArray, ...values: any[]): LazyCommandHandle; - - /** - * String or array command call with optional fluent options - */ - (command: string | string[], options?: FluentRunOptions): LazyCommandHandle; -} -``` - ---- - -### 2. Helper Method - `assertFluentMode()` - -**Location**: Add as private method in `Shell` class (after `processTaggedTemplate`, around line 840) - -```typescript -/** - * Validates that the output mode is compatible with FluentShell. - * Throws an error if 'live' mode is used. - * - * @param mode - The output mode to validate - * @throws {Error} If mode is 'live' - * - * @internal - */ -private assertFluentMode(mode: OutputMode): asserts mode is FluentOutputMode { - if (mode === 'live') { - throw new Error( - "FluentShell does not support outputMode: 'live'. " + - "Use 'capture' or 'all', or call shell.run(..., { outputMode: 'live' }) instead." - ); - } -} -``` - ---- - -### 3. Update `createLazyHandle()` Method - -**Location**: Modify existing method (lines ~828-972) - -**Changes**: -1. Add `options` parameter with type `FluentRunOptions` -2. Remove hardcoded `outputMode: 'capture'` at line 859 -3. Pass options through to `safeRun()` -4. Update return type to use `FluentOutputMode` -5. Update promise type to `Promise>` - -**New signature**: -```typescript -/** - * Create a lazy command handle with memoized execution. - * The command doesn't execute until first consumption (await, .result(), .toLines(), .parse()). - * Multiple consumptions share the same execution result. - * - * @param command - Command to execute (string or array) - * @param options - Fluent execution options (validated to not use 'live' mode) - * @returns LazyCommandHandle with deferred execution - * - * @internal - */ -private createLazyHandle( - command: string | string[], - options: FluentRunOptions -): LazyCommandHandle { - let executionPromise: Promise> | null = null; - - const start = (): Promise> => { - if (executionPromise === null) { - // Pass options directly to safeRun (outputMode already validated) - executionPromise = this.safeRun(command, options as RunOptions); - } - return executionPromise; - }; - - // ... rest of implementation (handle.then, handle.result, etc.) -} -``` - -**Key change at line 859**: -- **Before**: `executionPromise = this.safeRun(command, { outputMode: 'capture' }).then(result => ({...}))` -- **After**: `executionPromise = this.safeRun(command, options as RunOptions)` -- **Remove** the `.then()` transformation (lines 859-864) since we're no longer forcing capture mode - ---- - -### 4. Update `asFluent()` Method - -**Location**: Modify existing method (lines ~1024-1037) - -**Changes**: -1. Add validation to reject `live` mode at shell level -2. Handle options parameter in function call path -3. Determine effective output mode (options → shell default) -4. Validate effective mode before creating lazy handle -5. Pass options to `createLazyHandle()` - -**New implementation**: -```typescript -/** - * Create a fluent shell function with tagged template and lazy execution support. - * - * Returns a function that supports: - * - Tagged templates: `` $`echo hello` `` - * - Function calls: `$('echo hello')` or `$(['echo', 'hello'], { outputMode: 'all' })` - * - Lazy execution: command doesn't run until consumed - * - Memoization: multiple consumptions share one execution - * - Non-throwable path: `.result()` returns result with success flag - * - * Note: FluentShell requires stdout for chaining, so 'live' mode is not supported. - * If the shell instance has `outputMode: 'live'`, this method will throw an error. - * - * @returns DollarFunction that supports tagged templates and function calls - * - * @throws {Error} If shell instance has `outputMode: 'live'` - * - * @example Tagged template with shell default mode - * ```typescript - * const shell = createShell({ outputMode: 'capture' }); - * const $ = shell.asFluent(); - * const result = await $`echo hello`; // Uses 'capture' mode - * ``` - * - * @example Function call with mode override - * ```typescript - * const shell = createShell({ outputMode: 'capture' }); - * const $ = shell.asFluent(); - * const result = await $('echo hello', { outputMode: 'all' }); // Uses 'all' mode - * ``` - * - * @example Error case - live mode not supported - * ```typescript - * const shell = createShell({ outputMode: 'live' }); - * shell.asFluent(); // ❌ Throws error - * ``` - */ -public asFluent(): DollarFunction { - // Validate shell-level outputMode - this.assertFluentMode(this.outputMode); - - return ((firstArg: any, ...rest: any[]): LazyCommandHandle => { - // Detect if it's a tagged template call - if (Array.isArray(firstArg) && 'raw' in firstArg && Array.isArray((firstArg as any).raw)) { - // Tagged template: process interpolation, use shell default mode - const command = this.processTaggedTemplate(firstArg as TemplateStringsArray, rest); - const mode = this.outputMode; - - // Mode already validated at asFluent() level, but assert for type narrowing - this.assertFluentMode(mode); - - return this.createLazyHandle(command, { outputMode: mode } as FluentRunOptions); - } - - // Function call: string or array with optional options - const command = firstArg as string | string[]; - const options = rest[0] as FluentRunOptions | undefined; - - // Determine effective output mode (options override shell default) - const effectiveMode = (options?.outputMode ?? this.outputMode) as OutputMode; - - // Validate effective mode - this.assertFluentMode(effectiveMode); - - // Create lazy handle with effective options - const effectiveOptions: FluentRunOptions = { - ...(options ?? {}), - outputMode: effectiveMode, - }; - - return this.createLazyHandle(command, effectiveOptions); - }) as DollarFunction; -} -``` - ---- - -### 5. Update `LazyCommandHandle` Return Types - -**Location**: `LazyCommandHandle` type definition (lines ~315-394) - -**Changes**: -- Update `result()` return type to support both `capture` and `all` modes -- Currently uses `RunResult`, should use `RunResult` - -**Note**: This may require making `LazyCommandHandle` generic or keeping it flexible enough to handle both modes. Consider type implications carefully. - -**Option A - Keep simple (recommended)**: -```typescript -result(): Promise>; -``` - -**Option B - Make generic**: -```typescript -export type LazyCommandHandle = PromiseLike & { - result(): Promise>; - // ... other methods -} -``` - -Recommendation: Use **Option A** for simplicity. The nullability difference between modes is handled at runtime. - ---- - -## Implementation Order - -### Phase 1: Type Definitions -1. Add `FluentOutputMode` type -2. Add `FluentRunOptions` type -3. Update `DollarFunction` interface - -### Phase 2: Validation Logic -4. Add `assertFluentMode()` helper method - -### Phase 3: Core Implementation -5. Update `createLazyHandle()` signature and implementation -6. Update `asFluent()` implementation - -### Phase 4: Type Alignment -7. Update `LazyCommandHandle.result()` return type (if needed) -8. Fix any type issues in the lazy handle implementation - -### Phase 5: Testing & Documentation -9. Add comprehensive tests (see test cases below) -10. Update JSDoc comments for all modified code -11. Verify type safety with `pnpm check-types` -12. Run full test suite with `pnpm test:ci` - ---- - -## Test Cases - -### Test File Location: `test/shell.test.ts` - -Add new test suite: "Fluent Shell API - OutputMode Support" - -#### Test 1: Shell with capture mode + tagged template -```typescript -test('should work with capture mode (default)', async () => { - const shell = createShell({ outputMode: 'capture' }); - const $ = shell.asFluent(); - const result = await $`echo hello`; - expect(result).toBe('hello'); -}); -``` - -#### Test 2: Shell with all mode + function call -```typescript -test('should work with all mode', async () => { - const shell = createShell({ outputMode: 'all' }); - const $ = shell.asFluent(); - const result = await $(['echo', 'world']).result(); - expect(result.success).toBe(true); - expect(result.stdout).toBe('world'); -}); -``` - -#### Test 3: Shell with live mode should reject asFluent() -```typescript -test('should throw when shell has live mode', () => { - const shell = createShell({ outputMode: 'live' }); - expect(() => shell.asFluent()).toThrow( - "FluentShell does not support outputMode: 'live'" - ); -}); -``` - -#### Test 4: Override to live mode should throw -```typescript -test('should throw when overriding to live mode', async () => { - const shell = createShell({ outputMode: 'capture' }); - const $ = shell.asFluent(); - await expect(async () => { - await $(['echo', 'x'], { outputMode: 'live' as any }); - }).rejects.toThrow("FluentShell does not support outputMode: 'live'"); -}); -``` - -#### Test 5: Override to all mode should work -```typescript -test('should allow overriding to all mode', async () => { - const shell = createShell({ outputMode: 'capture' }); - const $ = shell.asFluent(); - const result = await $(['echo', 'test'], { outputMode: 'all' }).result(); - expect(result.success).toBe(true); - expect(result.stdout).toBe('test'); -}); -``` - -#### Test 6: Inherit mode from ShellOptions -```typescript -test('should inherit outputMode from ShellOptions', async () => { - const shell = createShell({ outputMode: 'all' }); - const $ = shell.asFluent(); - const result = await $('echo inherited').result(); - expect(result.success).toBe(true); - expect(result.stdout).toBe('inherited'); -}); -``` - -#### Test 7: Helper methods work in all mode -```typescript -test('should support toLines() in all mode', async () => { - const shell = createShell({ outputMode: 'all' }); - const $ = shell.asFluent(); - const lines = await $`echo -e "line1\nline2"`.toLines(); - expect(lines).toEqual(['line1', 'line2']); -}); - -test('should support parse() in all mode', async () => { - const shell = createShell({ outputMode: 'all' }); - const $ = shell.asFluent(); - const schema = z.object({ value: z.string() }); - const data = await $`echo '{"value":"test"}'`.parse(schema); - expect(data.value).toBe('test'); -}); -``` - -#### Test 8: Memoization works with options -```typescript -test('should memoize execution with options', async () => { - const shell = createShell({ outputMode: 'capture' }); - const $ = shell.asFluent(); - const handle = $(['echo', 'memo'], { outputMode: 'all' }); - - const result1 = await handle.result(); - const result2 = await handle.result(); - - expect(result1).toBe(result2); // Same object reference -}); -``` - -#### Test 9: Error message validation -```typescript -test('should provide clear error message for live mode', () => { - const shell = createShell({ outputMode: 'live' }); - expect(() => shell.asFluent()).toThrow( - "FluentShell does not support outputMode: 'live'. " + - "Use 'capture' or 'all', or call shell.run(..., { outputMode: 'live' }) instead." - ); -}); -``` - ---- - -## Breaking Changes - -### User-Facing Changes - -1. **None for existing usage**: All current code using `$` without options continues to work -2. **New capability**: Users can now pass options to `$` function calls -3. **New restriction**: Shells with `outputMode: 'live'` cannot call `.asFluent()` - -### Internal Changes - -1. `DollarFunction` signature expanded (backward compatible) -2. `createLazyHandle()` signature changed (private method) -3. `asFluent()` implementation changed (behavior preserved for valid cases) - ---- - -## Type Safety Considerations - -### Compile-Time Safety - -1. `FluentOutputMode` excludes `'live'` at type level -2. `FluentRunOptions` enforces valid modes in type signature -3. `assertFluentMode()` provides type assertion for narrowing - -### Runtime Safety - -1. Validation at `asFluent()` level (shell configuration) -2. Validation at per-command level (options override) -3. Clear error messages guide users to alternatives - -### Edge Cases - -1. **Type casting workaround**: User forces `{ outputMode: 'live' as any }` - - Caught by runtime validation in `assertFluentMode()` - - Throws with helpful error message - -2. **Null/undefined stdout in 'all' mode**: - - `RunResult` = `SafeResult` with `stdout: string | null` - - Existing code handles this correctly - - Helper methods like `.parse()` check for null stdout - ---- - -## Documentation Updates - -### Files to Update - -1. **README.md**: Add examples of fluent API with `all` mode and options -2. **CLAUDE.md**: Document the new FluentShell behavior and restrictions -3. **JSDoc comments**: Already included in code examples above - -### Key Points to Document - -1. FluentShell supports `capture` and `all` modes only -2. `live` mode incompatible with fluent operations (why: no stdout for chaining) -3. Per-command mode override via options parameter -4. Error handling and error messages -5. Type safety guarantees - ---- - -## Checklist - -### Before Implementation -- [ ] Review spec.md and ensure understanding -- [ ] Review current implementation in `src/shell.ts` -- [ ] Plan test cases -- [ ] Consider edge cases and error scenarios - -### During Implementation -- [ ] Add `FluentOutputMode` type -- [ ] Add `FluentRunOptions` type -- [ ] Update `DollarFunction` interface -- [ ] Add `assertFluentMode()` method -- [ ] Update `createLazyHandle()` signature and implementation -- [ ] Update `asFluent()` implementation -- [ ] Update `LazyCommandHandle` return types if needed -- [ ] Add JSDoc comments -- [ ] Fix any TypeScript errors - -### After Implementation -- [ ] Run `pnpm check-types` (must pass) -- [ ] Run `pnpm test:ci` (must pass) -- [ ] Add new test suite for FluentShell outputMode support -- [ ] Verify all test cases pass -- [ ] Check code coverage (maintain >97%) -- [ ] Manual testing with various scenarios -- [ ] Update documentation (README, CLAUDE.md) -- [ ] Review error messages for clarity - ---- - -## Success Criteria - -1. ✅ All existing tests pass without modification -2. ✅ New test suite covers all specified test cases -3. ✅ TypeScript compilation succeeds with no errors -4. ✅ Code coverage remains above 97% -5. ✅ Error messages are clear and actionable -6. ✅ API is backward compatible for existing usage -7. ✅ Type safety prevents invalid mode usage at compile time -8. ✅ Runtime validation catches edge cases (type casting workarounds) -9. ✅ Documentation clearly explains new capabilities and restrictions - ---- - -## Estimated Complexity - -- **Type Definitions**: Low complexity, straightforward exclusion and extension -- **Validation Logic**: Low complexity, simple assertion function -- **Core Implementation**: Medium complexity, requires careful handling of options merging -- **Testing**: Medium complexity, comprehensive coverage of modes and edge cases -- **Documentation**: Low complexity, mostly examples and explanations - -**Total Estimated Effort**: 2-3 hours for implementation and testing - ---- - -## References - -- **Spec Document**: `.agent/task-3/spec.md` -- **Current Implementation**: `src/shell.ts` lines 509-1039 (Shell class) -- **Type Definitions**: `src/shell.ts` lines 1-223 -- **Test File**: `test/shell.test.ts` -- **CLAUDE.md**: Project documentation for AI assistants diff --git a/.agent/task-3/spec.md b/.agent/task-3/spec.md deleted file mode 100644 index d3e95a8..0000000 --- a/.agent/task-3/spec.md +++ /dev/null @@ -1,198 +0,0 @@ - - -🧩 FluentShell Design Spec (v2) - -1. วัตถุประสงค์ - • ทำให้ FluentShell (asFluent() / $) สอดคล้องกับ ShellOptions - • จำกัดการใช้งานให้รองรับเฉพาะ capture และ all mode -(เนื่องจาก FluentShell ต้องมี stdout สำหรับการ parse และ memoize) - • อนุญาตให้ $ override outputMode ต่อคำสั่งได้ แต่ห้ามใช้ 'live' - • คงรูปแบบการเรียกทั้ง “tagged template” และ “function call” ไว้เหมือนเดิม - -⸻ - -2. OutputMode Policy - -Mode FluentShell เหตุผล -'capture' ✅ อนุญาต มี stdout สำหรับ parse และ memoize -'all' ✅ อนุญาต แสดงผลสด + ยัง capture stdout ได้ -'live' ❌ ห้ามใช้ ไม่มี stdout → chain ต่อไม่ได้ - - -⸻ - -3. Type Definitions - -/** Mode ที่ FluentShell อนุญาตให้ใช้ */ -type FluentOutputMode = Exclude; - -/** RunOptions ที่จำกัดไม่ให้ใช้ live mode */ -type FluentRunOptions = - Omit, 'outputMode'> & { - outputMode?: M; - }; - -/** - * FluentShell function type - * รองรับทั้ง template call และ function call - */ -export interface DollarFunction { - /** Tagged template call — `` $`echo ${name}` `` */ - (parts: TemplateStringsArray, ...values: any[]): LazyCommandHandle; - - /** Function call — $('echo hi') หรือ $(['echo', 'hi'], { ...options }) */ - (command: string | string[], options?: FluentRunOptions): LazyCommandHandle; -} - - -⸻ - -4. พฤติกรรมการเลือก outputMode - -ลำดับการตัดสินใจ (effective mode) - 1. ถ้าเรียก $([...], options) → ใช้ options.outputMode - 2. ถ้าไม่มีใน options → ใช้ this.outputMode จาก ShellOptions - 3. ถ้า mode ที่ได้คือ 'live' → throw Error - -⸻ - -5. พฤติกรรม asFluent() - -กติกา - • ถ้า Shell ถูกสร้างด้วย outputMode: 'live' → throw ทันทีเมื่อเรียก asFluent() - • ภายใน $ ต้องตรวจอีกชั้น เผื่อผู้ใช้ส่ง { outputMode: 'live' } ใน options - -ตัวอย่างโค้ด (pseudo-implementation) - -public asFluent(): DollarFunction { - if (this.outputMode === 'live') { - throw new Error( - "FluentShell does not support `outputMode: 'live'`. " + - "Use `shell.run(..., { outputMode: 'live' })` instead." - ); - } - - const $impl = (first: any, ...rest: any[]): LazyCommandHandle => { - // ✅ Tagged template - if (Array.isArray(first) && 'raw' in first) { - const command = this.processTaggedTemplate(first as TemplateStringsArray, rest); - const mode = this.outputMode; - this.assertFluentMode(mode); - return this.createLazyHandle(command, { outputMode: mode }); - } - - // ✅ Function call - const command = first as string | string[]; - const maybeOptions = rest[0] as FluentRunOptions | undefined; - const mode = (maybeOptions?.outputMode ?? this.outputMode) as OutputMode; - this.assertFluentMode(mode); - return this.createLazyHandle(command, { ...(maybeOptions ?? {}), outputMode: mode }); - }; - - return $impl as DollarFunction; -} - -private assertFluentMode(mode: OutputMode) { - if (mode === 'live') { - throw new Error( - "FluentShell does not support `outputMode: 'live'`. " + - "Use 'capture' or 'all', or call `shell.run(..., { outputMode: 'live' })`." - ); - } -} - - -⸻ - -6. createLazyHandle() Behavior - • รับ RunOptions (ที่ผ่าน assert แล้วว่าไม่ใช่ live) - • ใช้โหมดที่ได้จริง (effectiveOptions.outputMode) - • ไม่บังคับ capture ภายในอีกต่อไป -(ใช้ค่าที่ resolve แล้วจาก ShellOptions หรือ override) - -private createLazyHandle( - command: string | string[], - effectiveOptions: RunOptions -): LazyCommandHandle { - let executionPromise: Promise> | null = null; - - const start = (): Promise> => { - if (!executionPromise) { - executionPromise = this.safeRun(command, effectiveOptions); - } - return executionPromise; - }; - - // ... other fluent methods: await handle / result / toLines / parse / safeParse - return handle as LazyCommandHandle; -} - - -⸻ - -7. ตัวอย่างการใช้งาน - -✅ ใช้ค่าจาก ShellOptions (capture mode) - -const shell = createShell({ outputMode: 'capture' }); -const $ = shell.asFluent(); - -const text = await $`echo hello`; -console.log(text); // 'hello' - -✅ ใช้ all mode (แสดงผล + capture) - -const shell = createShell({ outputMode: 'all' }); -const $ = shell.asFluent(); - -const r = await $(['echo', 'world'], { outputMode: 'all' }).result(); -console.log(r.stdout); // 'world' - -❌ พยายามใช้ live mode - -const shell = createShell({ outputMode: 'live' }); -shell.asFluent(); // ❌ throw Error: "FluentShell does not support live mode" - -const $ = createShell({ outputMode: 'capture' }).asFluent(); -await $(["echo", "x"], { outputMode: "live" }); // ❌ throw Error - - -⸻ - -8. ข้อความ error มาตรฐาน - -FluentShell does not support outputMode: 'live'. -Use 'capture' or 'all', or call shell.run(..., { outputMode: 'live' }) instead. - -⸻ - -9. Test Cases ที่ควรมี - -Case Input Expected -Shell capture mode + $ template $ ใช้งานได้ คืน stdout ✅ -Shell all mode + $ string call $(['echo','x']) → แสดงผล + capture stdout ✅ -Shell live mode + .asFluent() throw ✅ -$([...], { outputMode: 'live' }) throw ✅ -$([...], { outputMode: 'all' }) ทำงานได้, แสดงผล + capture ✅ -$([...]) → inherit จาก ShellOptions ถูกต้องตาม mode ✅ -.toLines(), .parse() ใน all mode ทำงานได้ ✅ -.result() memoize ได้ ✅ - - -⸻ - -🔧 สรุปการเปลี่ยนแปลงหลักจากสเปคเดิม - -เดิม ใหม่ -บังคับ capture ในทุก $ ยึดค่าจาก ShellOptions -ไม่มี override ต่อคำสั่ง เพิ่ม override ผ่าน $([...], options) -ไม่ตรวจ live mode ตรวจสองชั้น (ใน asFluent() และในแต่ละ $) -Signature ของ $ มี overload แยก no-opt รวมเป็น `(command: string - - -⸻ - -ผลลัพธ์สุดท้าย: -FluentShell จะทำงาน ปลอดภัย, predictable, type-safe, -รองรับทั้งโหมด capture และ all, -และมี DX ที่ดีด้วย error message ชัดเจนสำหรับ live mode. \ No newline at end of file From eaa9049b2246a8a4bdaca3f1af45557745f4bce1 Mon Sep 17 00:00:00 2001 From: Thada Wangthammang Date: Mon, 10 Nov 2025 10:41:12 +0700 Subject: [PATCH 18/19] docs(changeset): Introduce Fluent Shell API for an elegant, chainable interface for shell command --- .changeset/shaky-planes-lay.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/shaky-planes-lay.md diff --git a/.changeset/shaky-planes-lay.md b/.changeset/shaky-planes-lay.md new file mode 100644 index 0000000..0d2224d --- /dev/null +++ b/.changeset/shaky-planes-lay.md @@ -0,0 +1,5 @@ +--- +'@thaitype/shell': major +--- + +Introduce Fluent Shell API for an elegant, chainable interface for shell command From f71fa80959ced9520cee09c242a5a774fd8ca9a5 Mon Sep 17 00:00:00 2001 From: Thada Wangthammang Date: Mon, 10 Nov 2025 10:48:26 +0700 Subject: [PATCH 19/19] chore: remove unnecessary .DS_Store files and add to .gitignore --- .DS_Store | Bin 6148 -> 0 bytes .agent/.DS_Store | Bin 6148 -> 0 bytes .gitignore | 1 + 3 files changed, 1 insertion(+) delete mode 100644 .DS_Store delete mode 100644 .agent/.DS_Store diff --git a/.DS_Store b/.DS_Store deleted file mode 100644 index 4ae5dff320ec273eec2cf26176a105ce808a83d1..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 6148 zcmeHKJ8AX(Sa9PqrA`pY4a8!fz!yj$#!Xm*u~Tp5bLD7x^JzA+yLDsU!01V% zc@lcX&W?y^fBt$GS&2voH5fvm6Ha^7Qk8Wo@d51@d39}3*CCXRvr>A>JE0C0h@8|L0i z0E-2HHE|3?1g1d+2351g(4Zq;GOs3%fk79|=0o#l%??HVcAQ^4U9<*rqyki6s=#wB zS62Tw@EiUAl*APkpaKu2fG*bCb&n@yZEYTBwYI=N;Fj|XH^ba17`z+H1@V-^m;4Wg<&0T*E43hX&L&p$$qDprKhvt+--jT7}7np#A3 zem<@ulZcFPQ@L2!n>{z**++&mCkOWA81W14cNZlEfg7;MkzE(HCqgga^y>{tEnwC%0;vJ&^%eQ zLs35+`xjp>T0