diff --git a/changelog.md b/changelog.md index 25d5303..76aa828 100644 --- a/changelog.md +++ b/changelog.md @@ -1,5 +1,15 @@ # Changelog +## 2025-10-17 - 1.6.0 - feat(core) +Add spawnPath child-process API with timeout/abort/terminate support, export native types, and expand README + +- Implement spawnPath(filePath, fromFileUrl?, options?) in ts/index.ts producing an ITsrunChildProcess with childProcess, stdout, stderr, exitCode, kill() and terminate() +- Introduce ISpawnOptions (cwd, env, args, stdio, timeout, signal) and ITsrunChildProcess interfaces for robust process control +- Handle timeouts (auto SIGTERM), AbortSignal cancellation, and graceful terminate() (SIGTERM then SIGKILL after 5s) +- Export Node types ChildProcess and Readable from ts/plugins.ts for improved typings +- Greatly expand README: add badges, table of contents, detailed API docs and examples for runPath, runCli and spawnPath, and troubleshooting guidance +- Add local .claude/settings.local.json (environment/settings file) + ## 2025-10-16 - 1.5.0 - feat(core) Add cwd option and child-process execution for custom working directory; implement signal-forwarding child runner; update docs and bump package version to 1.4.0 diff --git a/readme.md b/readme.md index 5cfc006..baa782e 100644 --- a/readme.md +++ b/readme.md @@ -1,9 +1,31 @@ # @git.zone/tsrun +[![npm version](https://img.shields.io/npm/v/@git.zone/tsrun.svg)](https://www.npmjs.com/package/@git.zone/tsrun) +[![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](https://opensource.org/licenses/MIT) +[![TypeScript](https://img.shields.io/badge/TypeScript-%3E%3D3.x-blue)](https://www.typescriptlang.org/) +[![Node.js](https://img.shields.io/badge/Node.js-%3E%3D16.x-green)](https://nodejs.org/) + > Run TypeScript files instantly, without the compilation hassle ⚑ Execute TypeScript programs on-the-fly with zero configuration. Perfect for scripts, prototyping, and development workflows. +## Table of Contents + +- [What is tsrun?](#what-is-tsrun) +- [Installation](#installation) +- [Usage](#usage) + - [CLI Usage](#-cli-usage) + - [Programmatic API](#-programmatic-api) +- [Features](#features) +- [Why tsrun?](#why-tsrun) +- [Common Use Cases](#common-use-cases) +- [API Reference](#api-reference) +- [Examples](#examples) +- [Package Information](#package-information) +- [Requirements](#requirements) +- [Troubleshooting](#troubleshooting) +- [License and Legal Information](#license-and-legal-information) + ## What is tsrun? **tsrun** is a lightweight TypeScript execution tool that lets you run `.ts` files directlyβ€”no build step required. It's like running JavaScript with `node`, but for TypeScript. Under the hood, tsrun uses [tsx](https://github.com/esbuild-kit/tsx) for lightning-fast execution while keeping your workflow simple and efficient. @@ -40,19 +62,56 @@ All arguments are passed through to your TypeScript program, just as if you were ### πŸ’» Programmatic API -Import tsrun in your code for dynamic TypeScript execution: +tsrun provides three powerful functions for different execution needs: + +#### runPath() - Simple Execution + +Wait for a script to complete. Perfect for sequential workflows. ```typescript -import { runPath, runCli } from '@git.zone/tsrun'; +import { runPath } from '@git.zone/tsrun'; -// Run a TypeScript file from an absolute or relative path -await runPath('./scripts/myScript.ts'); +// Run a TypeScript file (absolute or relative path) +await runPath('./scripts/build.ts'); -// Run with path resolution relative to a file URL -await runPath('./myScript.ts', import.meta.url); +// With path resolution relative to a file URL +await runPath('./build.ts', import.meta.url); -// Run in CLI mode programmatically (respects process.argv) -await runCli('./myScript.ts'); +// With custom working directory +await runPath('./build.ts', import.meta.url, { cwd: '/path/to/project' }); +``` + +#### runCli() - CLI Mode + +Run with process.argv integration, as if invoked from command line. + +```typescript +import { runCli } from '@git.zone/tsrun'; + +// Respects process.argv for argument passing +await runCli('./script.ts'); +``` + +#### spawnPath() - Advanced Control + +Full process control with stdio access, timeouts, and cancellation. + +```typescript +import { spawnPath } from '@git.zone/tsrun'; + +// Returns immediately with process handle +const proc = spawnPath('./task.ts', import.meta.url, { + timeout: 30000, + cwd: '/path/to/project', + env: { NODE_ENV: 'production' }, + args: ['--verbose'] +}); + +// Access stdout/stderr streams +proc.stdout?.on('data', (chunk) => console.log(chunk.toString())); + +// Wait for completion +const exitCode = await proc.exitCode; ``` ## Features @@ -69,6 +128,8 @@ await runCli('./myScript.ts'); πŸ”€ **Custom Working Directory** - Execute scripts with different cwds for parallel multi-project workflows. +πŸŽ›οΈ **Advanced Process Control** - Full control with spawnPath() for stdio access, timeouts, and cancellation. + ## Why tsrun? Sometimes you just want to run a TypeScript file without setting up a build pipeline, configuring webpack, or waiting for `tsc` to compile. That's where tsrun shines: @@ -78,6 +139,84 @@ Sometimes you just want to run a TypeScript file without setting up a build pipe - **Development Workflows**: Integrate TypeScript execution into your tooling - **CI/CD**: Run TypeScript-based build scripts without pre-compilation +## Common Use Cases + +### Development Scripts + +```typescript +// scripts/dev-setup.ts +import { runPath } from '@git.zone/tsrun'; + +console.log('Setting up development environment...'); +await runPath('./install-deps.ts', import.meta.url); +await runPath('./init-db.ts', import.meta.url); +await runPath('./seed-data.ts', import.meta.url); +console.log('βœ“ Development environment ready!'); +``` + +### Multi-Project Builds + +```typescript +// build-all-projects.ts +import { runPath } from '@git.zone/tsrun'; + +const projects = [ + '/workspace/frontend', + '/workspace/backend', + '/workspace/shared' +]; + +await Promise.all( + projects.map(cwd => + runPath('./build.ts', import.meta.url, { cwd }) + ) +); +``` + +### Long-Running Tasks with Monitoring + +```typescript +// monitor-task.ts +import { spawnPath } from '@git.zone/tsrun'; + +const proc = spawnPath('./data-migration.ts', import.meta.url, { + timeout: 300000, // 5 minutes max + env: { LOG_LEVEL: 'verbose' } +}); + +let lineCount = 0; +proc.stdout?.on('data', (chunk) => { + lineCount++; + if (lineCount % 100 === 0) { + console.log(`Processed ${lineCount} lines...`); + } +}); + +try { + await proc.exitCode; + console.log('Migration completed successfully!'); +} catch (error) { + console.error('Migration failed:', error.message); + process.exit(1); +} +``` + +## API Reference + +Choose the right function for your use case: + +| Function | Use When | Returns | Execution Mode | +|----------|----------|---------|----------------| +| `runPath()` | Simple script execution, sequential workflows | Promise (waits) | In-process (or child with cwd) | +| `runCli()` | Need process.argv integration | Promise (waits) | In-process | +| `spawnPath()` | Need process control, stdio access, timeout/cancel | Process handle | Child process | + +**Quick decision guide:** +- 🎯 **Need to wait for completion?** β†’ Use `runPath()` or `runCli()` +- πŸŽ›οΈ **Need to capture output or control process?** β†’ Use `spawnPath()` +- ⚑ **Running multiple scripts in parallel?** β†’ Use `runPath()` with custom `cwd` or `spawnPath()` +- ⏱️ **Need timeout or cancellation?** β†’ Use `spawnPath()` + ## Examples ### Simple Script @@ -172,6 +311,87 @@ await Promise.all([ - Each child process runs with its own isolated working directory - Exit codes and signals are properly forwarded +### Advanced Process Control with spawnPath() + +For advanced use cases requiring full process control, stdio access, or timeout/cancellation support, use `spawnPath()`. Unlike `runPath()` which waits for completion, `spawnPath()` returns immediately with a process handle. + +```typescript +import { spawnPath } from '@git.zone/tsrun'; + +// Basic spawning with output capture +const proc = spawnPath('./build.ts', import.meta.url); + +proc.stdout?.on('data', (chunk) => { + console.log('Output:', chunk.toString()); +}); + +proc.stderr?.on('data', (chunk) => { + console.error('Error:', chunk.toString()); +}); + +const exitCode = await proc.exitCode; +console.log(`Process exited with code ${exitCode}`); +``` + +**With timeout and custom environment:** + +```typescript +const proc = spawnPath('./long-running-task.ts', import.meta.url, { + timeout: 30000, // Kill after 30 seconds + cwd: '/path/to/project', + env: { + NODE_ENV: 'production', + API_KEY: 'secret' + }, + args: ['--mode', 'fast'] +}); + +try { + const exitCode = await proc.exitCode; + console.log('Task completed:', exitCode); +} catch (error) { + console.error('Task failed or timed out:', error.message); +} +``` + +**AbortController integration:** + +```typescript +const controller = new AbortController(); +const proc = spawnPath('./task.ts', import.meta.url, { + signal: controller.signal +}); + +// Cancel after 5 seconds +setTimeout(() => controller.abort(), 5000); + +try { + await proc.exitCode; +} catch (error) { + console.log('Process was aborted'); +} +``` + +**Graceful termination:** + +```typescript +const proc = spawnPath('./server.ts', import.meta.url); + +// Later: gracefully shut down +// Sends SIGTERM, waits 5s, then SIGKILL if still running +await proc.terminate(); +``` + +**Key differences from runPath():** + +| Feature | runPath() | spawnPath() | +|---------|-----------|-------------| +| Returns | Promise (waits) | Process handle (immediate) | +| Default execution | In-process (unless cwd) | Always child process | +| stdio | 'inherit' (transparent) | 'pipe' (capturable) | +| Process control | Limited | Full (streams, signals, timeout) | +| Use case | Simple script execution | Complex process management | + ## Package Information - **npmjs**: [@git.zone/tsrun](https://www.npmjs.com/package/@git.zone/tsrun) @@ -183,6 +403,60 @@ await Promise.all([ - **Node.js**: >= 16.x - **TypeScript**: >= 3.x (automatically handled by tsx) +## Troubleshooting + +### Common Issues + +**"Cannot find module" errors** + +Make sure you're using absolute paths or paths relative to `import.meta.url`: + +```typescript +// ❌ Wrong - relative to cwd +await runPath('./script.ts'); + +// βœ… Correct - relative to current file +await runPath('./script.ts', import.meta.url); +``` + +**Process hangs or doesn't complete** + +When using `spawnPath()`, make sure to await the `exitCode` promise: + +```typescript +const proc = spawnPath('./script.ts', import.meta.url); +// Don't forget to await! +await proc.exitCode; +``` + +**Timeout not working** + +Timeouts only work with `spawnPath()`, not with `runPath()`: + +```typescript +// ❌ Wrong - timeout is ignored +await runPath('./script.ts', import.meta.url, { timeout: 5000 }); + +// βœ… Correct - use spawnPath for timeout support +const proc = spawnPath('./script.ts', import.meta.url, { timeout: 5000 }); +await proc.exitCode; +``` + +**Environment variables not available** + +The `env` option automatically merges with `process.env` - your custom values override parent values: + +```typescript +// Parent env is automatically inherited +spawnPath('./script.ts', import.meta.url, { + env: { + CUSTOM_VAR: 'value' // Added to parent env + } +}); + +// Script will see both process.env AND CUSTOM_VAR +``` + ## License and Legal Information This repository contains open-source code that is licensed under the MIT License. A copy of the MIT License can be found in the [license](license) file within this repository. diff --git a/ts/00_commitinfo_data.ts b/ts/00_commitinfo_data.ts index ad440e2..61d917f 100644 --- a/ts/00_commitinfo_data.ts +++ b/ts/00_commitinfo_data.ts @@ -3,6 +3,6 @@ */ export const commitinfo = { name: '@git.zone/tsrun', - version: '1.5.0', + version: '1.6.0', description: 'run typescript programs efficiently' } diff --git a/ts/index.ts b/ts/index.ts index 0339093..0481734 100644 --- a/ts/index.ts +++ b/ts/index.ts @@ -5,6 +5,64 @@ export interface IRunOptions { cwd?: string; } +export interface ISpawnOptions { + /** Working directory for the child process */ + cwd?: string; + + /** Environment variables (merged with parent's env) */ + env?: Record; + + /** Additional CLI arguments to pass to the script */ + args?: string[]; + + /** + * Stdio configuration + * - 'pipe': Create pipes for stdin/stdout/stderr (default) + * - 'inherit': Use parent's stdio + * - Array: Custom configuration per stream + */ + stdio?: 'pipe' | 'inherit' | ['pipe' | 'inherit' | 'ignore', 'pipe' | 'inherit' | 'ignore', 'pipe' | 'inherit' | 'ignore']; + + /** + * Optional timeout in milliseconds + * If provided, process is automatically killed after timeout + */ + timeout?: number; + + /** + * AbortSignal for cancellation support + * Allows external cancellation of the process + */ + signal?: AbortSignal; +} + +export interface ITsrunChildProcess { + /** Direct access to Node's ChildProcess object */ + childProcess: plugins.ChildProcess; + + /** Readable stream for stdout (null if stdio is 'inherit') */ + stdout: plugins.Readable | null; + + /** Readable stream for stderr (null if stdio is 'inherit') */ + stderr: plugins.Readable | null; + + /** Promise that resolves with the exit code when process ends */ + exitCode: Promise; + + /** + * Send signal to process + * Returns true if signal was sent successfully + */ + kill(signal?: NodeJS.Signals): boolean; + + /** + * Gracefully terminate the process + * Tries SIGTERM first, waits 5s, then SIGKILL if still running + * Returns a promise that resolves when process is terminated + */ + terminate(): Promise; +} + export const runPath = async (pathArg: string, fromFileUrl?: string, options?: IRunOptions) => { pathArg = fromFileUrl ? plugins.path.join(plugins.path.dirname(plugins.url.fileURLToPath(fromFileUrl)), pathArg) @@ -95,3 +153,125 @@ const runInChildProcess = async (pathArg: string | undefined, cwd: string): Prom }); }); }; + +export const spawnPath = ( + filePath: string, + fromFileUrl?: string | URL, + options?: ISpawnOptions +): ITsrunChildProcess => { + const { spawn } = require('child_process'); + + // 1. Resolve path (similar to runPath) + const resolvedPath = fromFileUrl + ? plugins.path.join( + plugins.path.dirname( + plugins.url.fileURLToPath( + typeof fromFileUrl === 'string' ? fromFileUrl : fromFileUrl.href + ) + ), + filePath + ) + : filePath; + + // 2. Build spawn args + const cliChildPath = plugins.path.join(__dirname, '../cli.child.js'); + const args = [ + ...process.execArgv, + cliChildPath, + resolvedPath, + ...(options?.args || []) + ]; + + // 3. Build spawn options + const spawnOptions = { + cwd: options?.cwd || process.cwd(), + env: { ...process.env, ...options?.env }, + stdio: options?.stdio || 'pipe', + shell: false, + windowsHide: false + }; + + // 4. Spawn child process + const child = spawn(process.execPath, args, spawnOptions); + + // 5. Set up timeout if provided + let timeoutId: NodeJS.Timeout | undefined; + let timeoutTriggered = false; + + if (options?.timeout) { + timeoutId = setTimeout(() => { + timeoutTriggered = true; + child.kill('SIGTERM'); + }, options.timeout); + } + + // 6. Set up AbortSignal if provided + let abortHandler: (() => void) | undefined; + if (options?.signal) { + abortHandler = () => { + child.kill('SIGTERM'); + }; + options.signal.addEventListener('abort', abortHandler); + } + + // 7. Create exitCode promise + const exitCodePromise = new Promise((resolve, reject) => { + child.on('close', (code: number | null, signal: NodeJS.Signals | null) => { + if (timeoutId) clearTimeout(timeoutId); + if (abortHandler && options?.signal) { + options.signal.removeEventListener('abort', abortHandler); + } + + if (timeoutTriggered) { + reject(new Error(`Process killed: timeout of ${options?.timeout}ms exceeded`)); + } else if (options?.signal?.aborted) { + reject(new Error('Process killed: aborted by signal')); + } else if (signal) { + reject(new Error(`Process killed with signal ${signal}`)); + } else { + resolve(code || 0); + } + }); + + child.on('error', (err: Error) => { + if (timeoutId) clearTimeout(timeoutId); + if (abortHandler && options?.signal) { + options.signal.removeEventListener('abort', abortHandler); + } + reject(err); + }); + }); + + // 8. Implement terminate() method + const terminate = async (): Promise => { + return new Promise((resolve) => { + if (child.killed) { + resolve(); + return; + } + + child.kill('SIGTERM'); + + const killTimeout = setTimeout(() => { + if (!child.killed) { + child.kill('SIGKILL'); + } + }, 5000); + + child.on('close', () => { + clearTimeout(killTimeout); + resolve(); + }); + }); + }; + + // 9. Return ITsrunChildProcess object + return { + childProcess: child, + stdout: child.stdout, + stderr: child.stderr, + exitCode: exitCodePromise, + kill: (signal?: NodeJS.Signals) => child.kill(signal), + terminate + }; +}; diff --git a/ts/plugins.ts b/ts/plugins.ts index 53a6727..93ca4c4 100644 --- a/ts/plugins.ts +++ b/ts/plugins.ts @@ -1,8 +1,11 @@ // node native import * as path from 'path'; import * as url from 'url'; +import type { ChildProcess } from 'child_process'; +import type { Readable } from 'stream'; export { path, url }; +export type { ChildProcess, Readable }; // @pushrocks scope import * as smartfile from '@push.rocks/smartfile';