diff --git a/docs/windows.md b/docs/windows.md index f475e8e43f..e5286c1a34 100644 --- a/docs/windows.md +++ b/docs/windows.md @@ -24,17 +24,19 @@ await execa`node ./script.js`; Although Windows does not natively support shebangs, Execa adds support for them. -## Signals +## PATHEXT support -Only few [signals](termination.md#other-signals) work on Windows with Node.js: [`SIGTERM`](termination.md#sigterm), [`SIGKILL`](termination.md#sigkill), [`SIGINT`](https://en.wikipedia.org/wiki/Signal_(IPC)#SIGINT) and [`SIGQUIT`](termination.md#sigquit). Also, sending signals from other processes is [not supported](termination.md#signal-name-and-description). Finally, the [`forceKillAfterDelay`](api.md#optionsforcekillafterdelay) option [is a noop](termination.md#forceful-termination) on Windows. +On Windows, the [`PATHEXT`](https://ss64.com/nt/path.html#pathext) environment variable defines which file extensions can be executed without specifying the extension. For example, if `.JS` is in `PATHEXT`: -## Asynchronous I/O - -The default value for the [`stdin`](api.md#optionsstdin), [`stdout`](api.md#optionsstdout) and [`stderr`](api.md#optionsstderr) options is [`'pipe'`](output.md#stdout-and-stderr). This returns the output as [`result.stdout`](api.md#resultstdout) and [`result.stderr`](api.md#resultstderr) and allows for [manual streaming](streams.md#manual-streaming). +```js +// On Windows, if PATHEXT includes .JS, both work: +await execa`node script.js`; +await execa`node script`; // Automatically finds script.js +``` -Instead of `'pipe'`, `'overlapped'` can be used instead to use [asynchronous I/O](https://learn.microsoft.com/en-us/windows/win32/fileio/synchronous-and-asynchronous-i-o) under-the-hood on Windows, instead of the default behavior which is synchronous. On other platforms, asynchronous I/O is always used, so `'overlapped'` behaves the same way as `'pipe'`. +Execa uses [`node-which`](https://github.com/npm/node-which) internally to resolve the absolute file path of the executable, ensuring proper `PATHEXT` support on Windows. -## Escaping +## Commands with spaces Windows requires files and arguments to be quoted when they contain spaces, tabs, backslashes or double quotes. Unlike Unix, this is needed even when no [shell](shell.md) is used. @@ -44,6 +46,51 @@ When not using any shell, Execa performs that quoting automatically. This ensure await execa`npm run ${'task with space'}`; ``` +## How Windows execution works + +Under the hood, Execa uses [`node-cross-spawn`](https://github.com/moxystudio/node-cross-spawn) to fix several Windows-specific issues. Here's what happens when you run a command on Windows: + +1. **Resolve the executable**: The command name is resolved to an absolute path using `node-which`, which properly handles `PATHEXT`. + +2. **Detect shebangs**: If the file is not a `.exe` or `.com` file, Execa reads the first 150 bytes to detect any shebang (e.g., `#!/usr/bin/env node`). + +3. **Execute via cmd.exe**: For files with shebangs or scripts (like `.bat`, `.cmd` files), Execa runs them through `cmd.exe /d /s /c` with proper escaping, rather than using Node.js's default behavior. + +This process is automatic and only applies to Windows. It is skipped when using [`shell: true`](shell.md), though using a shell has different trade-offs (see below). + +## Comparison: Automatic fixes vs shell mode + +When deciding whether to use a shell on Windows, consider the following: + +| Feature | Automatic fixes (default) | `shell: true` | +|---------|--------------------------|---------------| +| PATHEXT support | ✅ Yes | ✅ Yes (via `cmd.exe`) | +| Shebang support | ✅ Yes | ❌ No | +| Escaping quality | ✅ Excellent (cross-spawn) | ⚠️ Good (Node.js built-in) | +| Extra overhead | Minimal | One more process | + +**Recommendation**: Use the default (automatic fixes) for most cases. Use `shell: true` only when you specifically need shell features like redirection (`>`, `|`) or environment variable expansion. + +```js +// Default: Uses automatic Windows fixes +await execa`./my-script.js`; + +// Shell mode: Use only when you need shell features +await execa({shell: true})`echo %PATH% | findstr "Node"`; +``` + +## Signals + +Only few [signals](termination.md#other-signals) work on Windows with Node.js: [`SIGTERM`](termination.md#sigterm), [`SIGKILL`](termination.md#sigkill), [`SIGINT`](https://en.wikipedia.org/wiki/Signal_(IPC)#SIGINT) and [`SIGQUIT`](termination.md#sigquit). Also, sending signals from other processes is [not supported](termination.md#signal-name-and-description). Finally, the [`forceKillAfterDelay`](api.md#optionsforcekillafterdelay) option [is a noop](termination.md#forceful-termination) on Windows. + +## Asynchronous I/O + +The default value for the [`stdin`](api.md#optionsstdin), [`stdout`](api.md#optionsstdout) and [`stderr`](api.md#optionsstderr) options is [`'pipe'`](output.md#stdout-and-stderr). This returns the output as [`result.stdout`](api.md#resultstdout) and [`result.stderr`](api.md#resultstderr) and allows for [manual streaming](streams.md#manual-streaming). + +Instead of `'pipe'`, `'overlapped'` can be used instead to use [asynchronous I/O](https://learn.microsoft.com/en-us/windows/win32/fileio/synchronous-and-asynchronous-i-o) under-the-hood on Windows, instead of the default behavior which is synchronous. On other platforms, asynchronous I/O is always used, so `'overlapped'` behaves the same way as `'pipe'`. + +## Escaping with shells + When using a [shell](shell.md), the user must manually perform shell-specific quoting, on both Unix and Windows. When the [`shell`](api.md#optionsshell) option is `true`, [`cmd.exe`](https://en.wikipedia.org/wiki/Cmd.exe) is used on Windows and `sh` on Unix. Unfortunately, both shells use different quoting rules. With `cmd.exe`, this mostly involves double quoting arguments and prepending double quotes with a backslash. ```js diff --git a/examples/build-script.js b/examples/build-script.js new file mode 100644 index 0000000000..33a7da2230 --- /dev/null +++ b/examples/build-script.js @@ -0,0 +1,39 @@ +import { execa } from 'execa'; + +/** + * Build automation example with error handling + * Demonstrates running build commands with proper error handling + */ + +async function runBuild() { + console.log('🔨 Starting build process...\n'); + + try { + // Clean previous build + console.log('🧹 Cleaning previous build...'); + await execa('rm', ['-rf', 'dist']); + console.log('✅ Clean complete\n'); + + // Type checking + console.log('🔍 Running type check...'); + await execa('tsc', ['--noEmit'], { stdio: 'inherit' }); + console.log('✅ Type check passed\n'); + + // Building + console.log('📦 Building project...'); + const { stdout } = await execa('npm', ['run', 'build']); + console.log(stdout); + console.log('✅ Build successful!\n'); + + } catch (error) { + console.error('❌ Build failed:', error.message); + process.exit(1); + } +} + +// Run if executed directly +if (import.meta.url === `file://${process.argv[1]}`) { + runBuild(); +} + +export { runBuild }; diff --git a/examples/git-helpers.js b/examples/git-helpers.js new file mode 100644 index 0000000000..1050b4c4ce --- /dev/null +++ b/examples/git-helpers.js @@ -0,0 +1,70 @@ +import { execa } from 'execa'; + +/** + * Git helper functions using execa + * Wrappers for common git operations + */ + +/** + * Get the current git branch + */ +async function getCurrentBranch() { + const { stdout } = await execa('git', ['branch', '--show-current']); + return stdout.trim(); +} + +/** + * Get the latest commit message + */ +async function getLatestCommit() { + const { stdout } = await execa('git', ['log', '-1', '--pretty=%s']); + return stdout.trim(); +} + +/** + * Check if working directory is clean + */ +async function isWorkingDirectoryClean() { + try { + await execa('git', ['diff', '--quiet']); + await execa('git', ['diff', '--staged', '--quiet']); + return true; + } catch { + return false; + } +} + +/** + * Get repository status + */ +async function getStatus() { + const { stdout } = await execa('git', ['status', '--short']); + return stdout || 'Working directory clean'; +} + +/** + * Stage all changes and commit + */ +async function stageAndCommit(message) { + await execa('git', ['add', '.']); + await execa('git', ['commit', '-m', message]); + console.log(`✅ Committed: ${message}`); +} + +// Demo +if (import.meta.url === `file://${process.argv[1]}`) { + console.log('📁 Git Repository Info\n'); + + console.log(`Branch: ${await getCurrentBranch()}`); + console.log(`Last commit: ${await getLatestCommit()}`); + console.log(`Working directory: ${await isWorkingDirectoryClean() ? '✅ Clean' : '⚠️ Dirty'}`); + console.log(`Status:\n${await getStatus()}`); +} + +export { + getCurrentBranch, + getLatestCommit, + isWorkingDirectoryClean, + getStatus, + stageAndCommit, +}; diff --git a/examples/parallel-tasks.js b/examples/parallel-tasks.js new file mode 100644 index 0000000000..d9f9e24ffc --- /dev/null +++ b/examples/parallel-tasks.js @@ -0,0 +1,80 @@ +import { execa } from 'execa'; + +/** + * Running commands in parallel + * Demonstrates concurrent execution with proper error handling + */ + +/** + * Run multiple linting tasks in parallel + */ +async function runParallelLints() { + console.log('🔍 Running linters in parallel...\n'); + + try { + const tasks = [ + { name: 'ESLint', cmd: ['eslint', '.'] }, + { name: 'Prettier', cmd: ['prettier', '--check', '.'] }, + { name: 'TypeScript', cmd: ['tsc', '--noEmit'] }, + ]; + + const results = await Promise.allSettled( + tasks.map(async ({ name, cmd }) => { + console.log(`🚀 Starting ${name}...`); + await execa('npx', cmd); + return { name, status: 'passed' }; + }) + ); + + console.log('\n📊 Results:'); + let hasErrors = false; + + for (const result of results) { + if (result.status === 'fulfilled') { + console.log(` ✅ ${result.value.name}`); + } else { + console.log(` ❌ ${result.reason.name || 'Task'}`); + hasErrors = true; + } + } + + if (hasErrors) { + process.exit(1); + } + + } catch (error) { + console.error('Unexpected error:', error); + process.exit(1); + } +} + +/** + * Run multiple independent commands in parallel + * with a limit on concurrency + */ +async function runWithLimit(tasks, limit = 3) { + const results = []; + const executing = []; + + for (const [index, task] of tasks.entries()) { + const promise = task().then(result => ({ index, result })); + results.push(promise); + + if (tasks.length >= limit) { + executing.push(promise); + if (executing.length >= limit) { + await Promise.race(executing); + executing.splice(executing.findIndex(p => p === promise), 1); + } + } + } + + return Promise.all(results); +} + +// Demo +if (import.meta.url === `file://${process.argv[1]}`) { + runParallelLints(); +} + +export { runParallelLints, runWithLimit }; diff --git a/examples/readme.md b/examples/readme.md new file mode 100644 index 0000000000..4ce2322d5d --- /dev/null +++ b/examples/readme.md @@ -0,0 +1,46 @@ +# Execa Examples + +This directory contains practical examples demonstrating common use cases with `execa`. + +## Examples + +### build-script.js +Build automation with error handling. Shows how to chain build commands and handle failures gracefully. + +```bash +node examples/build-script.js +``` + +### git-helpers.js +Git operation wrappers. Demonstrates async helper functions for common git operations. + +```bash +node examples/git-helpers.js +``` + +### parallel-tasks.js +Running commands in parallel. Shows concurrent execution with `Promise.allSettled()` and progress tracking. + +```bash +node examples/parallel-tasks.js +``` + +### security-scanning.js +Security scanning utilities. Demonstrates integrating security tools like npm audit, semgrep, and gitleaks. + +```bash +node examples/security-scanning.js +``` + +### stream-processing.js +Output filtering with transforms. Demonstrates using Node.js Transform streams to process command output. + +```bash +node examples/stream-processing.js +``` + +## Notes + +- All examples use ES modules (`.js` extension with `import` syntax) +- Each example can be run directly or imported as a module +- Error handling is included in all examples diff --git a/examples/security-scanning.js b/examples/security-scanning.js new file mode 100644 index 0000000000..88e99ebd72 --- /dev/null +++ b/examples/security-scanning.js @@ -0,0 +1,214 @@ +import { execa } from 'execa'; + +/** + * Security scanning utilities using execa + * Demonstrates running security tools and processing their output + */ + +/** + * Run npm audit and parse the results + * Returns summary of vulnerabilities found + */ +async function runNpmAudit(options = {}) { + const args = ['audit', '--json']; + + // Add audit level filter if specified + if (options.level) { + args.push('--audit-level', options.level); + } + + try { + const { stdout } = await execa('npm', args); + const audit = JSON.parse(stdout); + + return { + success: true, + vulnerabilities: audit.metadata?.vulnerabilities || {}, + total: audit.metadata?.totalDependencies || 0, + advisories: Object.keys(audit.advisories || {}).length, + }; + } catch (error) { + // npm audit returns exit code 1 when vulnerabilities are found + // but still outputs valid JSON + if (error.stdout) { + try { + const audit = JSON.parse(error.stdout); + return { + success: false, + vulnerabilities: audit.metadata?.vulnerabilities || {}, + total: audit.metadata?.totalDependencies || 0, + advisories: Object.keys(audit.advisories || {}).length, + exitCode: error.exitCode, + }; + } catch { + // JSON parsing failed + } + } + throw error; + } +} + +/** + * Run a security linter (semgrep) on the codebase + * Requires semgrep to be installed: pip install semgrep + */ +async function runSemgrepScan(rules = 'p/security-audit') { + try { + const { stdout } = await execa('semgrep', [ + '--config', rules, + '--json', + '--quiet', + '.', + ]); + + const results = JSON.parse(stdout); + return { + success: true, + findings: results.results?.length || 0, + errors: results.errors?.length || 0, + rules: Object.keys(results.rules || {}).length, + details: results.results || [], + }; + } catch (error) { + // semgrep returns non-zero when findings exist + if (error.stdout) { + try { + const results = JSON.parse(error.stdout); + return { + success: false, + findings: results.results?.length || 0, + errors: results.errors?.length || 0, + rules: Object.keys(results.rules || {}).length, + details: results.results || [], + exitCode: error.exitCode, + }; + } catch { + // JSON parsing failed + } + } + + // semgrep not installed or other error + if (error.message?.includes('ENOENT')) { + return { + success: false, + error: 'semgrep not installed. Install with: pip install semgrep', + }; + } + throw error; + } +} + +/** + * Check for secrets in git history using gitleaks + * Requires gitleaks to be installed: https://github.com/gitleaks/gitleaks + */ +async function runSecretScan() { + try { + const { stdout } = await execa('gitleaks', [ + 'git', + '.', + '--verbose', + '--redact', + ]); + + return { + success: true, + secretsFound: false, + message: 'No secrets detected', + }; + } catch (error) { + // gitleaks exits with code 1 when secrets are found + if (error.exitCode === 1 && error.stdout) { + const lines = error.stdout.split('\n').filter(line => line.includes('Finding:')); + return { + success: false, + secretsFound: true, + findings: lines.length, + message: `Found ${lines.length} potential secret(s)`, + hint: 'Run "gitleaks git . -v" for details (secrets redacted in output)', + }; + } + + if (error.message?.includes('ENOENT')) { + return { + success: false, + error: 'gitleaks not installed. See: https://github.com/gitleaks/gitleaks', + }; + } + throw error; + } +} + +/** + * Generate a security report summary + */ +async function generateSecurityReport() { + console.log('🔒 Running Security Scans...\n'); + + const report = { + timestamp: new Date().toISOString(), + scans: {}, + }; + + // Run npm audit + console.log('📦 Running npm audit...'); + try { + report.scans.npmAudit = await runNpmAudit(); + const v = report.scans.npmAudit.vulnerabilities; + if (v.critical > 0 || v.high > 0) { + console.log(` ⚠️ ${v.critical} critical, ${v.high} high severity vulnerabilities`); + } else { + console.log(` ✅ ${report.scans.npmAudit.total} dependencies checked`); + } + } catch (error) { + console.log(` ❌ npm audit failed: ${error.message}`); + report.scans.npmAudit = { error: error.message }; + } + + // Run secret scan + console.log('\n🔐 Scanning for secrets...'); + try { + report.scans.secrets = await runSecretScan(); + if (report.scans.secrets.secretsFound) { + console.log(` ⚠️ ${report.scans.secrets.message}`); + } else if (report.scans.secrets.error) { + console.log(` ℹ️ ${report.scans.secrets.error}`); + } else { + console.log(` ✅ ${report.scans.secrets.message}`); + } + } catch (error) { + console.log(` ❌ Secret scan failed: ${error.message}`); + report.scans.secrets = { error: error.message }; + } + + // Run semgrep + console.log('\n🛡️ Running semgrep security audit...'); + try { + report.scans.semgrep = await runSemgrepScan(); + if (report.scans.semgrep.findings > 0) { + console.log(` ⚠️ Found ${report.scans.semgrep.findings} security issue(s)`); + } else if (report.scans.semgrep.error) { + console.log(` ℹ️ ${report.scans.semgrep.error}`); + } else { + console.log(' ✅ No security issues found'); + } + } catch (error) { + console.log(` ❌ Semgrep failed: ${error.message}`); + report.scans.semgrep = { error: error.message }; + } + + console.log('\n📊 Security Report Complete'); + return report; +} + +// Demo +if (import.meta.url === `file://${process.argv[1]}`) { + await generateSecurityReport(); +} + +export { + runNpmAudit, + runSemgrepScan, + runSecretScan, + generateSecurityReport, +}; diff --git a/examples/stream-processing.js b/examples/stream-processing.js new file mode 100644 index 0000000000..8937d58f06 --- /dev/null +++ b/examples/stream-processing.js @@ -0,0 +1,95 @@ +import { execa } from 'execa'; +import { Transform } from 'node:stream'; + +/** + * Stream processing with transforms + * Demonstrates filtering and transforming command output + */ + +/** + * Filter lines containing specific keywords + */ +function createKeywordFilter(keywords) { + return new Transform({ + transform(chunk, encoding, callback) { + const lines = chunk.toString().split('\n'); + const filtered = lines + .filter(line => keywords.some(kw => line.toLowerCase().includes(kw.toLowerCase()))) + .join('\n'); + callback(null, filtered ? filtered + '\n' : ''); + }, + }); +} + +/** + * Prefix each line with a timestamp + */ +function createTimestampPrefix() { + return new Transform({ + transform(chunk, encoding, callback) { + const lines = chunk.toString().split('\n'); + const prefixed = lines + .filter(line => line.trim()) + .map(line => `[${new Date().toISOString()}] ${line}`) + .join('\n'); + callback(null, prefixed + '\n'); + }, + }); +} + +/** + * Count lines and show progress + */ +function createProgressCounter() { + let count = 0; + return new Transform({ + transform(chunk, encoding, callback) { + const lines = chunk.toString().split('\n'); + const nonEmpty = lines.filter(line => line.trim()); + count += nonEmpty.length; + process.stdout.write(`\r📊 Processed ${count} lines...`); + callback(null, chunk); + }, + flush(callback) { + console.log(`\n✅ Total: ${count} lines`); + callback(); + }, + }); +} + +/** + * Run a command and process its output through transforms + */ +async function runWithTransforms(command, args, transforms) { + const subprocess = execa(command, args); + + let stream = subprocess.stdout; + for (const transform of transforms) { + stream = stream.pipe(transform); + } + + stream.pipe(process.stdout); + await subprocess; +} + +// Demo +if (import.meta.url === `file://${process.argv[1]}`) { + console.log('🔍 Running npm audit with filtered output:\n'); + + try { + await runWithTransforms( + 'npm', + ['audit', '--json'], + [createKeywordFilter(['severity', 'critical', 'high'])] + ); + } catch { + // npm audit exits with non-zero on vulnerabilities + } +} + +export { + createKeywordFilter, + createTimestampPrefix, + createProgressCounter, + runWithTransforms, +};