mdcontext 0.1.0 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.changeset/config.json +9 -9
- package/.claude/settings.local.json +25 -0
- package/.github/workflows/claude-code-review.yml +44 -0
- package/.github/workflows/claude.yml +85 -0
- package/CONTRIBUTING.md +186 -0
- package/NOTES/NOTES +44 -0
- package/README.md +206 -3
- package/biome.json +1 -1
- package/dist/chunk-23UPXDNL.js +3044 -0
- package/dist/chunk-2W7MO2DL.js +1366 -0
- package/dist/chunk-3NUAZGMA.js +1689 -0
- package/dist/chunk-7TOWB2XB.js +366 -0
- package/dist/chunk-7XOTOADQ.js +3065 -0
- package/dist/chunk-AH2PDM2K.js +3042 -0
- package/dist/chunk-BNXWSZ63.js +3742 -0
- package/dist/chunk-BTL5DJVU.js +3222 -0
- package/dist/chunk-HDHYG7E4.js +104 -0
- package/dist/chunk-HLR4KZBP.js +3234 -0
- package/dist/chunk-IP3FRFEB.js +1045 -0
- package/dist/chunk-KHU56VDO.js +3042 -0
- package/dist/chunk-KRYIFLQR.js +85 -89
- package/dist/chunk-LBSDNLEM.js +287 -0
- package/dist/chunk-MNTQ7HCP.js +2643 -0
- package/dist/chunk-MUJELQQ6.js +1387 -0
- package/dist/chunk-MXJGMSLV.js +2199 -0
- package/dist/chunk-N6QJGC3Z.js +2636 -0
- package/dist/chunk-OBELGBPM.js +1713 -0
- package/dist/chunk-OT7R5XTA.js +3192 -0
- package/dist/chunk-P7X4RA2T.js +106 -0
- package/dist/chunk-PIDUQNC2.js +3185 -0
- package/dist/chunk-POGCDIH4.js +3187 -0
- package/dist/chunk-PSIEOQGZ.js +3043 -0
- package/dist/chunk-PVRT3IHA.js +3238 -0
- package/dist/chunk-QNN4TT23.js +1430 -0
- package/dist/chunk-RE3R45RJ.js +3042 -0
- package/dist/chunk-S7E6TFX6.js +718 -657
- package/dist/chunk-SG6GLU4U.js +1378 -0
- package/dist/chunk-SJCDV2ST.js +274 -0
- package/dist/chunk-SYE5XLF3.js +104 -0
- package/dist/chunk-T5VLYBZD.js +103 -0
- package/dist/chunk-TOQB7VWU.js +3238 -0
- package/dist/chunk-VFNMZ4ZQ.js +3228 -0
- package/dist/chunk-VVTGZNBT.js +1533 -1423
- package/dist/chunk-W7Q4RFEV.js +104 -0
- package/dist/chunk-XTYYVRLO.js +3190 -0
- package/dist/chunk-Y6MDYVJD.js +3063 -0
- package/dist/cli/main.js +4072 -629
- package/dist/index.d.ts +420 -33
- package/dist/index.js +8 -15
- package/dist/mcp/server.js +103 -7
- package/dist/schema-BAWSG7KY.js +22 -0
- package/dist/schema-E3QUPL26.js +20 -0
- package/dist/schema-EHL7WUT6.js +20 -0
- package/docs/019-USAGE.md +44 -5
- package/docs/020-current-implementation.md +8 -8
- package/docs/021-DOGFOODING-FINDINGS.md +1 -1
- package/docs/CONFIG.md +1123 -0
- package/docs/ERRORS.md +383 -0
- package/docs/summarization.md +320 -0
- package/justfile +40 -0
- package/package.json +39 -33
- package/research/INDEX.md +315 -0
- package/research/code-review/README.md +90 -0
- package/research/code-review/cli-error-handling-review.md +979 -0
- package/research/code-review/code-review-validation-report.md +464 -0
- package/research/code-review/main-ts-review.md +1128 -0
- package/research/config-docs/SUMMARY.md +357 -0
- package/research/config-docs/TEST-RESULTS.md +776 -0
- package/research/config-docs/TODO.md +542 -0
- package/research/config-docs/analysis.md +744 -0
- package/research/config-docs/fix-validation.md +502 -0
- package/research/config-docs/help-audit.md +264 -0
- package/research/config-docs/help-system-analysis.md +890 -0
- package/research/frontmatter/COMMENTS-ARE-SKIPPED.md +149 -0
- package/research/frontmatter/LLM-CODE-NAVIGATION.md +276 -0
- package/research/issue-review.md +603 -0
- package/research/llm-summarization/agent-cli-tools-2026.md +1082 -0
- package/research/llm-summarization/alternative-providers-2026.md +1428 -0
- package/research/llm-summarization/anthropic-2026.md +367 -0
- package/research/llm-summarization/claude-cli-integration.md +1706 -0
- package/research/llm-summarization/cli-integration-patterns.md +3155 -0
- package/research/llm-summarization/openai-2026.md +473 -0
- package/research/llm-summarization/openai-compatible-providers-2026.md +1022 -0
- package/research/llm-summarization/opencode-cli-integration.md +1552 -0
- package/research/llm-summarization/prompt-engineering-2026.md +1426 -0
- package/research/llm-summarization/prototype-results.md +56 -0
- package/research/llm-summarization/provider-switching-patterns-2026.md +2153 -0
- package/research/llm-summarization/typescript-llm-libraries-2026.md +2436 -0
- package/research/mdcontext-pudding/00-EXECUTIVE-SUMMARY.md +282 -0
- package/research/mdcontext-pudding/01-index-embed.md +956 -0
- package/research/mdcontext-pudding/02-search-COMMANDS.md +142 -0
- package/research/mdcontext-pudding/02-search-SUMMARY.md +146 -0
- package/research/mdcontext-pudding/02-search.md +970 -0
- package/research/mdcontext-pudding/03-context.md +779 -0
- package/research/mdcontext-pudding/04-navigation-and-analytics.md +803 -0
- package/research/mdcontext-pudding/04-tree.md +704 -0
- package/research/mdcontext-pudding/05-config.md +1038 -0
- package/research/mdcontext-pudding/06-links-summary.txt +87 -0
- package/research/mdcontext-pudding/06-links.md +679 -0
- package/research/mdcontext-pudding/07-stats.md +693 -0
- package/research/mdcontext-pudding/BUG-FIX-PLAN.md +388 -0
- package/research/mdcontext-pudding/P0-BUG-VALIDATION.md +167 -0
- package/research/mdcontext-pudding/README.md +168 -0
- package/research/mdcontext-pudding/TESTING-SUMMARY.md +128 -0
- package/research/research-quality-review.md +834 -0
- package/research/semantic-search/embedding-text-analysis.md +156 -0
- package/research/semantic-search/multi-word-failure-reproduction.md +171 -0
- package/research/semantic-search/query-processing-analysis.md +207 -0
- package/research/semantic-search/root-cause-and-solution.md +114 -0
- package/research/semantic-search/threshold-validation-report.md +69 -0
- package/research/semantic-search/vector-search-analysis.md +63 -0
- package/research/test-path-issues.md +276 -0
- package/review/ALP-76/1-error-type-design.md +962 -0
- package/review/ALP-76/2-error-handling-patterns.md +906 -0
- package/review/ALP-76/3-error-presentation.md +624 -0
- package/review/ALP-76/4-test-coverage.md +625 -0
- package/review/ALP-76/5-migration-completeness.md +440 -0
- package/review/ALP-76/6-effect-best-practices.md +755 -0
- package/scripts/apply-branch-protection.sh +47 -0
- package/scripts/branch-protection-templates.json +79 -0
- package/scripts/prototype-summarization.ts +346 -0
- package/scripts/rebuild-hnswlib.js +32 -37
- package/scripts/setup-branch-protection.sh +64 -0
- package/src/__tests__/fixtures/semantic-search/multi-word-corpus/.mdcontext/active-provider.json +7 -0
- package/src/__tests__/fixtures/semantic-search/multi-word-corpus/.mdcontext/bm25.json +541 -0
- package/src/__tests__/fixtures/semantic-search/multi-word-corpus/.mdcontext/bm25.meta.json +5 -0
- package/src/__tests__/fixtures/semantic-search/multi-word-corpus/.mdcontext/config.json +8 -0
- package/src/__tests__/fixtures/semantic-search/multi-word-corpus/.mdcontext/embeddings/openai_text-embedding-3-small_512/vectors.bin +0 -0
- package/src/__tests__/fixtures/semantic-search/multi-word-corpus/.mdcontext/embeddings/openai_text-embedding-3-small_512/vectors.meta.bin +0 -0
- package/src/__tests__/fixtures/semantic-search/multi-word-corpus/.mdcontext/indexes/documents.json +60 -0
- package/src/__tests__/fixtures/semantic-search/multi-word-corpus/.mdcontext/indexes/links.json +13 -0
- package/src/__tests__/fixtures/semantic-search/multi-word-corpus/.mdcontext/indexes/sections.json +1197 -0
- package/src/__tests__/fixtures/semantic-search/multi-word-corpus/configuration-management.md +99 -0
- package/src/__tests__/fixtures/semantic-search/multi-word-corpus/distributed-systems.md +92 -0
- package/src/__tests__/fixtures/semantic-search/multi-word-corpus/error-handling.md +78 -0
- package/src/__tests__/fixtures/semantic-search/multi-word-corpus/failure-automation.md +55 -0
- package/src/__tests__/fixtures/semantic-search/multi-word-corpus/job-context.md +69 -0
- package/src/__tests__/fixtures/semantic-search/multi-word-corpus/process-orchestration.md +99 -0
- package/src/cli/argv-preprocessor.test.ts +2 -2
- package/src/cli/cli.test.ts +230 -33
- package/src/cli/commands/config-cmd.ts +642 -0
- package/src/cli/commands/context.ts +97 -9
- package/src/cli/commands/duplicates.ts +122 -0
- package/src/cli/commands/embeddings.ts +529 -0
- package/src/cli/commands/index-cmd.ts +210 -30
- package/src/cli/commands/index.ts +3 -0
- package/src/cli/commands/search.ts +894 -64
- package/src/cli/commands/stats.ts +3 -0
- package/src/cli/commands/tree.ts +26 -5
- package/src/cli/config-layer.ts +176 -0
- package/src/cli/error-handler.test.ts +235 -0
- package/src/cli/error-handler.ts +655 -0
- package/src/cli/flag-schemas.ts +66 -0
- package/src/cli/help.ts +209 -7
- package/src/cli/main.ts +348 -58
- package/src/cli/options.ts +10 -0
- package/src/cli/shared-error-handling.ts +199 -0
- package/src/cli/utils.ts +150 -17
- package/src/config/file-provider.test.ts +320 -0
- package/src/config/file-provider.ts +273 -0
- package/src/config/index.ts +72 -0
- package/src/config/integration.test.ts +667 -0
- package/src/config/precedence.test.ts +277 -0
- package/src/config/precedence.ts +451 -0
- package/src/config/schema.test.ts +414 -0
- package/src/config/schema.ts +603 -0
- package/src/config/service.test.ts +320 -0
- package/src/config/service.ts +243 -0
- package/src/config/testing.test.ts +264 -0
- package/src/config/testing.ts +110 -0
- package/src/core/types.ts +6 -33
- package/src/duplicates/detector.test.ts +183 -0
- package/src/duplicates/detector.ts +414 -0
- package/src/duplicates/index.ts +18 -0
- package/src/embeddings/embedding-namespace.test.ts +300 -0
- package/src/embeddings/embedding-namespace.ts +947 -0
- package/src/embeddings/heading-boost.test.ts +222 -0
- package/src/embeddings/hnsw-build-options.test.ts +198 -0
- package/src/embeddings/hyde.test.ts +272 -0
- package/src/embeddings/hyde.ts +264 -0
- package/src/embeddings/index.ts +2 -0
- package/src/embeddings/openai-provider.ts +332 -83
- package/src/embeddings/pricing.json +22 -0
- package/src/embeddings/provider-constants.ts +204 -0
- package/src/embeddings/provider-errors.test.ts +967 -0
- package/src/embeddings/provider-errors.ts +565 -0
- package/src/embeddings/provider-factory.test.ts +240 -0
- package/src/embeddings/provider-factory.ts +225 -0
- package/src/embeddings/provider-integration.test.ts +788 -0
- package/src/embeddings/query-preprocessing.test.ts +187 -0
- package/src/embeddings/semantic-search-threshold.test.ts +508 -0
- package/src/embeddings/semantic-search.ts +780 -93
- package/src/embeddings/types.ts +293 -16
- package/src/embeddings/vector-store.ts +486 -77
- package/src/embeddings/voyage-provider.ts +313 -0
- package/src/errors/errors.test.ts +845 -0
- package/src/errors/index.ts +533 -0
- package/src/index/ignore-patterns.test.ts +354 -0
- package/src/index/ignore-patterns.ts +305 -0
- package/src/index/indexer.ts +286 -48
- package/src/index/storage.ts +94 -30
- package/src/index/types.ts +40 -2
- package/src/index/watcher.ts +67 -9
- package/src/index.ts +22 -0
- package/src/integration/search-keyword.test.ts +678 -0
- package/src/mcp/server.ts +135 -6
- package/src/parser/parser.ts +18 -19
- package/src/parser/section-filter.test.ts +277 -0
- package/src/parser/section-filter.ts +125 -3
- package/src/search/__tests__/hybrid-search.test.ts +650 -0
- package/src/search/bm25-store.ts +366 -0
- package/src/search/cross-encoder.test.ts +253 -0
- package/src/search/cross-encoder.ts +406 -0
- package/src/search/fuzzy-search.test.ts +419 -0
- package/src/search/fuzzy-search.ts +273 -0
- package/src/search/hybrid-search.ts +448 -0
- package/src/search/path-matcher.test.ts +276 -0
- package/src/search/path-matcher.ts +33 -0
- package/src/search/searcher.test.ts +99 -1
- package/src/search/searcher.ts +189 -67
- package/src/search/wink-bm25.d.ts +30 -0
- package/src/summarization/cli-providers/claude.ts +202 -0
- package/src/summarization/cli-providers/detection.test.ts +273 -0
- package/src/summarization/cli-providers/detection.ts +118 -0
- package/src/summarization/cli-providers/index.ts +8 -0
- package/src/summarization/cost.test.ts +139 -0
- package/src/summarization/cost.ts +102 -0
- package/src/summarization/error-handler.test.ts +127 -0
- package/src/summarization/error-handler.ts +111 -0
- package/src/summarization/index.ts +102 -0
- package/src/summarization/pipeline.test.ts +498 -0
- package/src/summarization/pipeline.ts +231 -0
- package/src/summarization/prompts.test.ts +269 -0
- package/src/summarization/prompts.ts +133 -0
- package/src/summarization/provider-factory.test.ts +396 -0
- package/src/summarization/provider-factory.ts +178 -0
- package/src/summarization/types.ts +184 -0
- package/src/summarize/summarizer.ts +104 -35
- package/src/types/huggingface-transformers.d.ts +66 -0
- package/tests/fixtures/cli/.mdcontext/active-provider.json +7 -0
- package/tests/fixtures/cli/.mdcontext/embeddings/openai_text-embedding-3-small_512/vectors.bin +0 -0
- package/tests/fixtures/cli/.mdcontext/embeddings/openai_text-embedding-3-small_512/vectors.meta.bin +0 -0
- package/tests/fixtures/cli/.mdcontext/indexes/documents.json +4 -4
- package/tests/fixtures/cli/.mdcontext/indexes/sections.json +14 -0
- package/tests/integration/embed-index.test.ts +712 -0
- package/tests/integration/search-context.test.ts +469 -0
- package/tests/integration/search-semantic.test.ts +522 -0
- package/vitest.config.ts +1 -6
- package/AGENTS.md +0 -46
- package/tests/fixtures/cli/.mdcontext/vectors.bin +0 -0
- package/tests/fixtures/cli/.mdcontext/vectors.meta.json +0 -1264
|
@@ -0,0 +1,3155 @@
|
|
|
1
|
+
# CLI Integration Patterns for TypeScript/Node.js (2026)
|
|
2
|
+
|
|
3
|
+
**Last Updated:** January 2026
|
|
4
|
+
**Purpose:** Best practices for integrating with AI CLI tools programmatically in TypeScript/Node.js for the mdcontext project
|
|
5
|
+
|
|
6
|
+
## Table of Contents
|
|
7
|
+
|
|
8
|
+
1. [Process Spawning Patterns](#process-spawning-patterns)
|
|
9
|
+
2. [Output Parsing Strategies](#output-parsing-strategies)
|
|
10
|
+
3. [Error Detection & Handling](#error-detection--handling)
|
|
11
|
+
4. [Timeout Handling](#timeout-handling)
|
|
12
|
+
5. [Shell Escaping & Security](#shell-escaping--security)
|
|
13
|
+
6. [Buffer Management](#buffer-management)
|
|
14
|
+
7. [Exit Code Handling](#exit-code-handling)
|
|
15
|
+
8. [Environment Variables](#environment-variables)
|
|
16
|
+
9. [Testing Strategies](#testing-strategies)
|
|
17
|
+
10. [Production Examples](#production-examples)
|
|
18
|
+
11. [Common Pitfalls](#common-pitfalls)
|
|
19
|
+
12. [Platform Differences](#platform-differences)
|
|
20
|
+
|
|
21
|
+
---
|
|
22
|
+
|
|
23
|
+
## Process Spawning Patterns
|
|
24
|
+
|
|
25
|
+
### Overview: exec vs spawn vs execFile
|
|
26
|
+
|
|
27
|
+
Node.js provides three primary methods for spawning child processes, each with distinct use cases:
|
|
28
|
+
|
|
29
|
+
| Method | Use Case | Shell | Buffering | Best For |
|
|
30
|
+
|--------|----------|-------|-----------|----------|
|
|
31
|
+
| `exec()` | Simple shell commands | Yes | Buffered | Small output, shell features needed |
|
|
32
|
+
| `execFile()` | Direct binary execution | No | Buffered | Security-critical, known binaries |
|
|
33
|
+
| `spawn()` | Streaming processes | Optional | Streaming | Large output, real-time data |
|
|
34
|
+
|
|
35
|
+
### When to Use Each Method
|
|
36
|
+
|
|
37
|
+
**Use `exec()` when:**
|
|
38
|
+
- You need shell features (pipes, redirection, wildcards)
|
|
39
|
+
- Output size is guaranteed small (< 1MB)
|
|
40
|
+
- You're running simple, trusted commands
|
|
41
|
+
- You need synchronous execution (with `execSync()`)
|
|
42
|
+
|
|
43
|
+
**Use `execFile()` when:**
|
|
44
|
+
- Security is paramount (no shell injection risk)
|
|
45
|
+
- Executing a known binary directly
|
|
46
|
+
- You want better performance (no shell overhead)
|
|
47
|
+
- Arguments are dynamic/user-controlled
|
|
48
|
+
|
|
49
|
+
**Use `spawn()` when:**
|
|
50
|
+
- Output may be large or unbounded
|
|
51
|
+
- You need real-time streaming output
|
|
52
|
+
- Long-running processes
|
|
53
|
+
- Fine-grained control over stdio streams
|
|
54
|
+
|
|
55
|
+
### TypeScript Examples
|
|
56
|
+
|
|
57
|
+
#### Pattern 1: Using spawn() for AI CLI Tools (Recommended)
|
|
58
|
+
|
|
59
|
+
```typescript
|
|
60
|
+
import { spawn, type ChildProcess } from 'child_process';
|
|
61
|
+
import { EventEmitter } from 'events';
|
|
62
|
+
|
|
63
|
+
interface CLIResult {
|
|
64
|
+
stdout: string;
|
|
65
|
+
stderr: string;
|
|
66
|
+
exitCode: number | null;
|
|
67
|
+
signal: NodeJS.Signals | null;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
interface SpawnOptions {
|
|
71
|
+
timeout?: number;
|
|
72
|
+
maxBuffer?: number;
|
|
73
|
+
env?: NodeJS.ProcessEnv;
|
|
74
|
+
cwd?: string;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Spawn a CLI process with proper error handling and streaming support
|
|
79
|
+
* Recommended for AI CLI tools like claude, gh, or git
|
|
80
|
+
*/
|
|
81
|
+
async function spawnCLI(
|
|
82
|
+
command: string,
|
|
83
|
+
args: string[],
|
|
84
|
+
options: SpawnOptions = {}
|
|
85
|
+
): Promise<CLIResult> {
|
|
86
|
+
return new Promise((resolve, reject) => {
|
|
87
|
+
const {
|
|
88
|
+
timeout = 120000, // 2 minutes default
|
|
89
|
+
maxBuffer = 10 * 1024 * 1024, // 10MB default
|
|
90
|
+
env = process.env,
|
|
91
|
+
cwd = process.cwd(),
|
|
92
|
+
} = options;
|
|
93
|
+
|
|
94
|
+
let stdout = '';
|
|
95
|
+
let stderr = '';
|
|
96
|
+
let stdoutBytes = 0;
|
|
97
|
+
let stderrBytes = 0;
|
|
98
|
+
let timedOut = false;
|
|
99
|
+
|
|
100
|
+
// Spawn with explicit argument array (prevents shell injection)
|
|
101
|
+
const child: ChildProcess = spawn(command, args, {
|
|
102
|
+
cwd,
|
|
103
|
+
env,
|
|
104
|
+
// Do NOT use shell: true unless absolutely necessary
|
|
105
|
+
shell: false,
|
|
106
|
+
// Ensure stdio is piped for capture
|
|
107
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
// Timeout handler
|
|
111
|
+
const timeoutId = setTimeout(() => {
|
|
112
|
+
timedOut = true;
|
|
113
|
+
child.kill('SIGTERM');
|
|
114
|
+
|
|
115
|
+
// Escalate to SIGKILL if not dead after 5 seconds
|
|
116
|
+
setTimeout(() => {
|
|
117
|
+
if (child.exitCode === null && child.signalCode === null) {
|
|
118
|
+
child.kill('SIGKILL');
|
|
119
|
+
}
|
|
120
|
+
}, 5000);
|
|
121
|
+
}, timeout);
|
|
122
|
+
|
|
123
|
+
// Capture stdout with buffer protection
|
|
124
|
+
child.stdout?.on('data', (chunk: Buffer) => {
|
|
125
|
+
stdoutBytes += chunk.length;
|
|
126
|
+
if (stdoutBytes > maxBuffer) {
|
|
127
|
+
child.kill('SIGTERM');
|
|
128
|
+
reject(new Error(`stdout exceeded maxBuffer of ${maxBuffer} bytes`));
|
|
129
|
+
return;
|
|
130
|
+
}
|
|
131
|
+
stdout += chunk.toString('utf8');
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
// Capture stderr with buffer protection
|
|
135
|
+
child.stderr?.on('data', (chunk: Buffer) => {
|
|
136
|
+
stderrBytes += chunk.length;
|
|
137
|
+
if (stderrBytes > maxBuffer) {
|
|
138
|
+
child.kill('SIGTERM');
|
|
139
|
+
reject(new Error(`stderr exceeded maxBuffer of ${maxBuffer} bytes`));
|
|
140
|
+
return;
|
|
141
|
+
}
|
|
142
|
+
stderr += chunk.toString('utf8');
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
// Handle process exit
|
|
146
|
+
child.on('close', (exitCode: number | null, signal: NodeJS.Signals | null) => {
|
|
147
|
+
clearTimeout(timeoutId);
|
|
148
|
+
|
|
149
|
+
if (timedOut) {
|
|
150
|
+
reject(new Error(`Process timed out after ${timeout}ms`));
|
|
151
|
+
return;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
resolve({
|
|
155
|
+
stdout: stdout.trim(),
|
|
156
|
+
stderr: stderr.trim(),
|
|
157
|
+
exitCode,
|
|
158
|
+
signal,
|
|
159
|
+
});
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
// Handle spawn errors (command not found, permission denied, etc.)
|
|
163
|
+
child.on('error', (err: Error) => {
|
|
164
|
+
clearTimeout(timeoutId);
|
|
165
|
+
reject(new Error(`Failed to spawn process: ${err.message}`));
|
|
166
|
+
});
|
|
167
|
+
});
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
// Usage example for AI CLI
|
|
171
|
+
async function callClaudeCLI(prompt: string): Promise<string> {
|
|
172
|
+
try {
|
|
173
|
+
const result = await spawnCLI('claude', ['--prompt', prompt], {
|
|
174
|
+
timeout: 300000, // 5 minutes for AI responses
|
|
175
|
+
maxBuffer: 50 * 1024 * 1024, // 50MB for large responses
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
if (result.exitCode !== 0) {
|
|
179
|
+
throw new Error(`Claude CLI failed with exit code ${result.exitCode}: ${result.stderr}`);
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
return result.stdout;
|
|
183
|
+
} catch (error) {
|
|
184
|
+
if (error instanceof Error) {
|
|
185
|
+
throw new Error(`Claude CLI error: ${error.message}`);
|
|
186
|
+
}
|
|
187
|
+
throw error;
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
```
|
|
191
|
+
|
|
192
|
+
#### Pattern 2: Using execFile() for Security-Critical Operations
|
|
193
|
+
|
|
194
|
+
```typescript
|
|
195
|
+
import { execFile } from 'child_process';
|
|
196
|
+
import { promisify } from 'util';
|
|
197
|
+
|
|
198
|
+
const execFileAsync = promisify(execFile);
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* Execute a binary directly without shell (more secure)
|
|
202
|
+
* Use this when arguments may come from user input
|
|
203
|
+
*/
|
|
204
|
+
async function safeExecute(
|
|
205
|
+
command: string,
|
|
206
|
+
args: string[],
|
|
207
|
+
options: {
|
|
208
|
+
timeout?: number;
|
|
209
|
+
maxBuffer?: number;
|
|
210
|
+
cwd?: string;
|
|
211
|
+
} = {}
|
|
212
|
+
): Promise<{ stdout: string; stderr: string }> {
|
|
213
|
+
try {
|
|
214
|
+
const { stdout, stderr } = await execFileAsync(command, args, {
|
|
215
|
+
timeout: options.timeout || 120000,
|
|
216
|
+
maxBuffer: options.maxBuffer || 10 * 1024 * 1024,
|
|
217
|
+
cwd: options.cwd || process.cwd(),
|
|
218
|
+
// Note: execFile does NOT use a shell by default
|
|
219
|
+
// This prevents shell injection attacks
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
return {
|
|
223
|
+
stdout: stdout.trim(),
|
|
224
|
+
stderr: stderr.trim(),
|
|
225
|
+
};
|
|
226
|
+
} catch (error: any) {
|
|
227
|
+
// Handle specific error types
|
|
228
|
+
if (error.killed && error.signal === 'SIGTERM') {
|
|
229
|
+
throw new Error(`Process timed out after ${options.timeout}ms`);
|
|
230
|
+
}
|
|
231
|
+
if (error.code === 'ENOENT') {
|
|
232
|
+
throw new Error(`Command not found: ${command}`);
|
|
233
|
+
}
|
|
234
|
+
if (error.code === 'EACCES') {
|
|
235
|
+
throw new Error(`Permission denied: ${command}`);
|
|
236
|
+
}
|
|
237
|
+
throw error;
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
// Usage example
|
|
242
|
+
async function runGitCommand(args: string[]): Promise<string> {
|
|
243
|
+
const { stdout } = await safeExecute('git', args, {
|
|
244
|
+
timeout: 30000, // 30 seconds
|
|
245
|
+
});
|
|
246
|
+
return stdout;
|
|
247
|
+
}
|
|
248
|
+
```
|
|
249
|
+
|
|
250
|
+
#### Pattern 3: Streaming Large Outputs
|
|
251
|
+
|
|
252
|
+
```typescript
|
|
253
|
+
import { spawn } from 'child_process';
|
|
254
|
+
import { EventEmitter } from 'events';
|
|
255
|
+
|
|
256
|
+
interface StreamOptions {
|
|
257
|
+
onStdout?: (chunk: string) => void;
|
|
258
|
+
onStderr?: (chunk: string) => void;
|
|
259
|
+
onProgress?: (bytesRead: number) => void;
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
/**
|
|
263
|
+
* Spawn process with streaming callbacks for real-time processing
|
|
264
|
+
* Ideal for long-running AI generation or large file operations
|
|
265
|
+
*/
|
|
266
|
+
async function spawnWithStreaming(
|
|
267
|
+
command: string,
|
|
268
|
+
args: string[],
|
|
269
|
+
options: StreamOptions = {}
|
|
270
|
+
): Promise<void> {
|
|
271
|
+
return new Promise((resolve, reject) => {
|
|
272
|
+
const child = spawn(command, args, {
|
|
273
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
274
|
+
shell: false,
|
|
275
|
+
});
|
|
276
|
+
|
|
277
|
+
let totalBytesRead = 0;
|
|
278
|
+
|
|
279
|
+
// Stream stdout chunks in real-time
|
|
280
|
+
child.stdout?.on('data', (chunk: Buffer) => {
|
|
281
|
+
totalBytesRead += chunk.length;
|
|
282
|
+
const text = chunk.toString('utf8');
|
|
283
|
+
|
|
284
|
+
options.onStdout?.(text);
|
|
285
|
+
options.onProgress?.(totalBytesRead);
|
|
286
|
+
});
|
|
287
|
+
|
|
288
|
+
// Stream stderr chunks in real-time
|
|
289
|
+
child.stderr?.on('data', (chunk: Buffer) => {
|
|
290
|
+
const text = chunk.toString('utf8');
|
|
291
|
+
options.onStderr?.(text);
|
|
292
|
+
});
|
|
293
|
+
|
|
294
|
+
child.on('close', (exitCode) => {
|
|
295
|
+
if (exitCode === 0) {
|
|
296
|
+
resolve();
|
|
297
|
+
} else {
|
|
298
|
+
reject(new Error(`Process exited with code ${exitCode}`));
|
|
299
|
+
}
|
|
300
|
+
});
|
|
301
|
+
|
|
302
|
+
child.on('error', reject);
|
|
303
|
+
});
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
// Usage example for streaming AI responses
|
|
307
|
+
async function streamClaudeResponse(prompt: string) {
|
|
308
|
+
let response = '';
|
|
309
|
+
|
|
310
|
+
await spawnWithStreaming('claude', ['--stream', '--prompt', prompt], {
|
|
311
|
+
onStdout: (chunk) => {
|
|
312
|
+
response += chunk;
|
|
313
|
+
process.stdout.write(chunk); // Display in real-time
|
|
314
|
+
},
|
|
315
|
+
onStderr: (chunk) => {
|
|
316
|
+
console.error('Error:', chunk);
|
|
317
|
+
},
|
|
318
|
+
onProgress: (bytes) => {
|
|
319
|
+
console.log(`Received ${bytes} bytes...`);
|
|
320
|
+
},
|
|
321
|
+
});
|
|
322
|
+
|
|
323
|
+
return response;
|
|
324
|
+
}
|
|
325
|
+
```
|
|
326
|
+
|
|
327
|
+
---
|
|
328
|
+
|
|
329
|
+
## Output Parsing Strategies
|
|
330
|
+
|
|
331
|
+
### Handling Different Output Formats
|
|
332
|
+
|
|
333
|
+
AI CLI tools typically output in various formats. Here are robust parsing strategies:
|
|
334
|
+
|
|
335
|
+
#### Strategy 1: JSON Output
|
|
336
|
+
|
|
337
|
+
```typescript
|
|
338
|
+
import { z } from 'zod';
|
|
339
|
+
|
|
340
|
+
/**
|
|
341
|
+
* Parse and validate JSON output from CLI tools
|
|
342
|
+
* Many modern CLI tools support --json flags
|
|
343
|
+
*/
|
|
344
|
+
async function parseJSONOutput<T>(
|
|
345
|
+
command: string,
|
|
346
|
+
args: string[],
|
|
347
|
+
schema: z.ZodSchema<T>
|
|
348
|
+
): Promise<T> {
|
|
349
|
+
const result = await spawnCLI(command, args);
|
|
350
|
+
|
|
351
|
+
if (result.exitCode !== 0) {
|
|
352
|
+
throw new Error(`Command failed: ${result.stderr}`);
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
try {
|
|
356
|
+
// Parse JSON
|
|
357
|
+
const parsed = JSON.parse(result.stdout);
|
|
358
|
+
|
|
359
|
+
// Validate with Zod schema
|
|
360
|
+
return schema.parse(parsed);
|
|
361
|
+
} catch (error) {
|
|
362
|
+
if (error instanceof z.ZodError) {
|
|
363
|
+
throw new Error(`Invalid JSON schema: ${error.message}`);
|
|
364
|
+
}
|
|
365
|
+
if (error instanceof SyntaxError) {
|
|
366
|
+
throw new Error(`Invalid JSON output: ${error.message}`);
|
|
367
|
+
}
|
|
368
|
+
throw error;
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
// Example: Parse GitHub CLI output
|
|
373
|
+
const IssueSchema = z.object({
|
|
374
|
+
number: z.number(),
|
|
375
|
+
title: z.string(),
|
|
376
|
+
state: z.enum(['open', 'closed']),
|
|
377
|
+
author: z.string(),
|
|
378
|
+
});
|
|
379
|
+
|
|
380
|
+
type Issue = z.infer<typeof IssueSchema>;
|
|
381
|
+
|
|
382
|
+
async function getGitHubIssue(issueNumber: number): Promise<Issue> {
|
|
383
|
+
return parseJSONOutput(
|
|
384
|
+
'gh',
|
|
385
|
+
['issue', 'view', issueNumber.toString(), '--json', 'number,title,state,author'],
|
|
386
|
+
IssueSchema
|
|
387
|
+
);
|
|
388
|
+
}
|
|
389
|
+
```
|
|
390
|
+
|
|
391
|
+
#### Strategy 2: NDJSON (Newline-Delimited JSON) Streaming
|
|
392
|
+
|
|
393
|
+
```typescript
|
|
394
|
+
import { spawn } from 'child_process';
|
|
395
|
+
import { Transform } from 'stream';
|
|
396
|
+
import split2 from 'split2'; // npm: split2
|
|
397
|
+
|
|
398
|
+
/**
|
|
399
|
+
* Parse streaming NDJSON output line-by-line
|
|
400
|
+
* Efficient for large datasets or continuous output
|
|
401
|
+
*/
|
|
402
|
+
async function parseNDJSONStream<T>(
|
|
403
|
+
command: string,
|
|
404
|
+
args: string[],
|
|
405
|
+
schema: z.ZodSchema<T>,
|
|
406
|
+
onItem: (item: T) => void | Promise<void>
|
|
407
|
+
): Promise<void> {
|
|
408
|
+
return new Promise((resolve, reject) => {
|
|
409
|
+
const child = spawn(command, args, {
|
|
410
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
411
|
+
shell: false,
|
|
412
|
+
});
|
|
413
|
+
|
|
414
|
+
let lineNumber = 0;
|
|
415
|
+
|
|
416
|
+
// Split stdout by newlines and parse each line as JSON
|
|
417
|
+
child.stdout
|
|
418
|
+
?.pipe(split2()) // Split on newlines
|
|
419
|
+
.pipe(
|
|
420
|
+
new Transform({
|
|
421
|
+
objectMode: true,
|
|
422
|
+
async transform(line: string, encoding, callback) {
|
|
423
|
+
lineNumber++;
|
|
424
|
+
|
|
425
|
+
if (!line.trim()) {
|
|
426
|
+
callback();
|
|
427
|
+
return;
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
try {
|
|
431
|
+
const parsed = JSON.parse(line);
|
|
432
|
+
const validated = schema.parse(parsed);
|
|
433
|
+
await onItem(validated);
|
|
434
|
+
callback();
|
|
435
|
+
} catch (error) {
|
|
436
|
+
callback(new Error(`Line ${lineNumber}: ${error}`));
|
|
437
|
+
}
|
|
438
|
+
},
|
|
439
|
+
})
|
|
440
|
+
)
|
|
441
|
+
.on('error', reject)
|
|
442
|
+
.on('finish', resolve);
|
|
443
|
+
|
|
444
|
+
child.on('error', reject);
|
|
445
|
+
});
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
// Example: Stream log entries
|
|
449
|
+
const LogEntrySchema = z.object({
|
|
450
|
+
timestamp: z.string(),
|
|
451
|
+
level: z.enum(['info', 'warn', 'error']),
|
|
452
|
+
message: z.string(),
|
|
453
|
+
});
|
|
454
|
+
|
|
455
|
+
type LogEntry = z.infer<typeof LogEntrySchema>;
|
|
456
|
+
|
|
457
|
+
async function streamLogs() {
|
|
458
|
+
await parseNDJSONStream(
|
|
459
|
+
'some-logging-cli',
|
|
460
|
+
['stream', '--format', 'ndjson'],
|
|
461
|
+
LogEntrySchema,
|
|
462
|
+
async (entry) => {
|
|
463
|
+
console.log(`[${entry.level.toUpperCase()}] ${entry.message}`);
|
|
464
|
+
}
|
|
465
|
+
);
|
|
466
|
+
}
|
|
467
|
+
```
|
|
468
|
+
|
|
469
|
+
#### Strategy 3: Unstructured Text Parsing
|
|
470
|
+
|
|
471
|
+
```typescript
|
|
472
|
+
/**
|
|
473
|
+
* Parse unstructured CLI output with regex patterns
|
|
474
|
+
* Use when JSON output is not available
|
|
475
|
+
*/
|
|
476
|
+
interface ParsePattern<T> {
|
|
477
|
+
pattern: RegExp;
|
|
478
|
+
transform: (matches: RegExpMatchArray) => T;
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
async function parseTextOutput<T>(
|
|
482
|
+
command: string,
|
|
483
|
+
args: string[],
|
|
484
|
+
patterns: ParsePattern<T>[]
|
|
485
|
+
): Promise<T[]> {
|
|
486
|
+
const result = await spawnCLI(command, args);
|
|
487
|
+
|
|
488
|
+
if (result.exitCode !== 0) {
|
|
489
|
+
throw new Error(`Command failed: ${result.stderr}`);
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
const results: T[] = [];
|
|
493
|
+
|
|
494
|
+
for (const { pattern, transform } of patterns) {
|
|
495
|
+
const matches = result.stdout.matchAll(pattern);
|
|
496
|
+
for (const match of matches) {
|
|
497
|
+
results.push(transform(match));
|
|
498
|
+
}
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
return results;
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
// Example: Parse git log output
|
|
505
|
+
interface GitCommit {
|
|
506
|
+
hash: string;
|
|
507
|
+
author: string;
|
|
508
|
+
date: string;
|
|
509
|
+
message: string;
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
async function getGitLog(): Promise<GitCommit[]> {
|
|
513
|
+
const result = await spawnCLI('git', [
|
|
514
|
+
'log',
|
|
515
|
+
'--pretty=format:%H|%an|%ad|%s',
|
|
516
|
+
'--date=iso',
|
|
517
|
+
'-n',
|
|
518
|
+
'10',
|
|
519
|
+
]);
|
|
520
|
+
|
|
521
|
+
return result.stdout.split('\n').map((line) => {
|
|
522
|
+
const [hash, author, date, message] = line.split('|');
|
|
523
|
+
return { hash, author, date, message };
|
|
524
|
+
});
|
|
525
|
+
}
|
|
526
|
+
```
|
|
527
|
+
|
|
528
|
+
#### Strategy 4: Handling Mixed Output (JSON + Progress)
|
|
529
|
+
|
|
530
|
+
```typescript
|
|
531
|
+
/**
|
|
532
|
+
* Some CLI tools output progress to stderr and JSON to stdout
|
|
533
|
+
* Handle this pattern by separating the streams
|
|
534
|
+
*/
|
|
535
|
+
async function parseWithProgress<T>(
|
|
536
|
+
command: string,
|
|
537
|
+
args: string[],
|
|
538
|
+
schema: z.ZodSchema<T>,
|
|
539
|
+
onProgress?: (message: string) => void
|
|
540
|
+
): Promise<T> {
|
|
541
|
+
const result = await new Promise<CLIResult>((resolve, reject) => {
|
|
542
|
+
const child = spawn(command, args, {
|
|
543
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
544
|
+
shell: false,
|
|
545
|
+
});
|
|
546
|
+
|
|
547
|
+
let stdout = '';
|
|
548
|
+
let stderr = '';
|
|
549
|
+
|
|
550
|
+
child.stdout?.on('data', (chunk) => {
|
|
551
|
+
stdout += chunk.toString('utf8');
|
|
552
|
+
});
|
|
553
|
+
|
|
554
|
+
child.stderr?.on('data', (chunk) => {
|
|
555
|
+
const message = chunk.toString('utf8').trim();
|
|
556
|
+
stderr += message + '\n';
|
|
557
|
+
|
|
558
|
+
// Many CLI tools output progress to stderr
|
|
559
|
+
onProgress?.(message);
|
|
560
|
+
});
|
|
561
|
+
|
|
562
|
+
child.on('close', (exitCode, signal) => {
|
|
563
|
+
resolve({ stdout, stderr, exitCode, signal });
|
|
564
|
+
});
|
|
565
|
+
|
|
566
|
+
child.on('error', reject);
|
|
567
|
+
});
|
|
568
|
+
|
|
569
|
+
if (result.exitCode !== 0) {
|
|
570
|
+
throw new Error(`Command failed: ${result.stderr}`);
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
const parsed = JSON.parse(result.stdout);
|
|
574
|
+
return schema.parse(parsed);
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
// Example usage
|
|
578
|
+
async function generateWithProgress() {
|
|
579
|
+
const result = await parseWithProgress(
|
|
580
|
+
'some-ai-cli',
|
|
581
|
+
['generate', '--output', 'json'],
|
|
582
|
+
z.object({ content: z.string(), tokens: z.number() }),
|
|
583
|
+
(progress) => {
|
|
584
|
+
console.log('Progress:', progress);
|
|
585
|
+
}
|
|
586
|
+
);
|
|
587
|
+
|
|
588
|
+
return result;
|
|
589
|
+
}
|
|
590
|
+
```
|
|
591
|
+
|
|
592
|
+
---
|
|
593
|
+
|
|
594
|
+
## Error Detection & Handling
|
|
595
|
+
|
|
596
|
+
### Comprehensive Error Classification
|
|
597
|
+
|
|
598
|
+
```typescript
|
|
599
|
+
/**
|
|
600
|
+
* Error types for CLI integration
|
|
601
|
+
*/
|
|
602
|
+
export enum CLIErrorType {
|
|
603
|
+
// Process errors
|
|
604
|
+
COMMAND_NOT_FOUND = 'COMMAND_NOT_FOUND',
|
|
605
|
+
PERMISSION_DENIED = 'PERMISSION_DENIED',
|
|
606
|
+
SPAWN_FAILED = 'SPAWN_FAILED',
|
|
607
|
+
|
|
608
|
+
// Execution errors
|
|
609
|
+
TIMEOUT = 'TIMEOUT',
|
|
610
|
+
BUFFER_OVERFLOW = 'BUFFER_OVERFLOW',
|
|
611
|
+
NON_ZERO_EXIT = 'NON_ZERO_EXIT',
|
|
612
|
+
SIGNAL_TERMINATED = 'SIGNAL_TERMINATED',
|
|
613
|
+
|
|
614
|
+
// Authentication errors
|
|
615
|
+
AUTH_REQUIRED = 'AUTH_REQUIRED',
|
|
616
|
+
AUTH_EXPIRED = 'AUTH_EXPIRED',
|
|
617
|
+
INVALID_TOKEN = 'INVALID_TOKEN',
|
|
618
|
+
|
|
619
|
+
// Rate limiting
|
|
620
|
+
RATE_LIMITED = 'RATE_LIMITED',
|
|
621
|
+
QUOTA_EXCEEDED = 'QUOTA_EXCEEDED',
|
|
622
|
+
|
|
623
|
+
// Network errors
|
|
624
|
+
NETWORK_ERROR = 'NETWORK_ERROR',
|
|
625
|
+
CONNECTION_TIMEOUT = 'CONNECTION_TIMEOUT',
|
|
626
|
+
DNS_RESOLUTION_FAILED = 'DNS_RESOLUTION_FAILED',
|
|
627
|
+
|
|
628
|
+
// Data errors
|
|
629
|
+
INVALID_OUTPUT = 'INVALID_OUTPUT',
|
|
630
|
+
MALFORMED_JSON = 'MALFORMED_JSON',
|
|
631
|
+
SCHEMA_VALIDATION_FAILED = 'SCHEMA_VALIDATION_FAILED',
|
|
632
|
+
}
|
|
633
|
+
|
|
634
|
+
export class CLIError extends Error {
|
|
635
|
+
constructor(
|
|
636
|
+
public type: CLIErrorType,
|
|
637
|
+
message: string,
|
|
638
|
+
public details?: {
|
|
639
|
+
command?: string;
|
|
640
|
+
args?: string[];
|
|
641
|
+
exitCode?: number | null;
|
|
642
|
+
signal?: NodeJS.Signals | null;
|
|
643
|
+
stdout?: string;
|
|
644
|
+
stderr?: string;
|
|
645
|
+
originalError?: Error;
|
|
646
|
+
}
|
|
647
|
+
) {
|
|
648
|
+
super(message);
|
|
649
|
+
this.name = 'CLIError';
|
|
650
|
+
}
|
|
651
|
+
|
|
652
|
+
/**
|
|
653
|
+
* Check if error is retryable
|
|
654
|
+
*/
|
|
655
|
+
isRetryable(): boolean {
|
|
656
|
+
return [
|
|
657
|
+
CLIErrorType.NETWORK_ERROR,
|
|
658
|
+
CLIErrorType.CONNECTION_TIMEOUT,
|
|
659
|
+
CLIErrorType.RATE_LIMITED,
|
|
660
|
+
CLIErrorType.TIMEOUT,
|
|
661
|
+
].includes(this.type);
|
|
662
|
+
}
|
|
663
|
+
|
|
664
|
+
/**
|
|
665
|
+
* Check if error requires user intervention
|
|
666
|
+
*/
|
|
667
|
+
requiresUserAction(): boolean {
|
|
668
|
+
return [
|
|
669
|
+
CLIErrorType.AUTH_REQUIRED,
|
|
670
|
+
CLIErrorType.AUTH_EXPIRED,
|
|
671
|
+
CLIErrorType.INVALID_TOKEN,
|
|
672
|
+
CLIErrorType.COMMAND_NOT_FOUND,
|
|
673
|
+
CLIErrorType.PERMISSION_DENIED,
|
|
674
|
+
].includes(this.type);
|
|
675
|
+
}
|
|
676
|
+
}
|
|
677
|
+
|
|
678
|
+
/**
|
|
679
|
+
* Detect and classify CLI errors
|
|
680
|
+
*/
|
|
681
|
+
function detectErrorType(error: any, result?: CLIResult): CLIError {
|
|
682
|
+
// Process spawn errors
|
|
683
|
+
if (error.code === 'ENOENT') {
|
|
684
|
+
return new CLIError(
|
|
685
|
+
CLIErrorType.COMMAND_NOT_FOUND,
|
|
686
|
+
`Command not found: ${error.path}`,
|
|
687
|
+
{ originalError: error }
|
|
688
|
+
);
|
|
689
|
+
}
|
|
690
|
+
|
|
691
|
+
if (error.code === 'EACCES') {
|
|
692
|
+
return new CLIError(
|
|
693
|
+
CLIErrorType.PERMISSION_DENIED,
|
|
694
|
+
`Permission denied: ${error.path}`,
|
|
695
|
+
{ originalError: error }
|
|
696
|
+
);
|
|
697
|
+
}
|
|
698
|
+
|
|
699
|
+
// Timeout errors
|
|
700
|
+
if (error.killed && error.signal === 'SIGTERM') {
|
|
701
|
+
return new CLIError(
|
|
702
|
+
CLIErrorType.TIMEOUT,
|
|
703
|
+
'Process timed out',
|
|
704
|
+
{ signal: error.signal, originalError: error }
|
|
705
|
+
);
|
|
706
|
+
}
|
|
707
|
+
|
|
708
|
+
// Check stderr for common error patterns
|
|
709
|
+
const stderr = result?.stderr?.toLowerCase() || '';
|
|
710
|
+
|
|
711
|
+
// Authentication errors
|
|
712
|
+
if (
|
|
713
|
+
stderr.includes('authentication') ||
|
|
714
|
+
stderr.includes('not logged in') ||
|
|
715
|
+
stderr.includes('please login') ||
|
|
716
|
+
stderr.includes('401 unauthorized')
|
|
717
|
+
) {
|
|
718
|
+
return new CLIError(
|
|
719
|
+
CLIErrorType.AUTH_REQUIRED,
|
|
720
|
+
'Authentication required',
|
|
721
|
+
{ stderr: result?.stderr }
|
|
722
|
+
);
|
|
723
|
+
}
|
|
724
|
+
|
|
725
|
+
if (stderr.includes('token expired') || stderr.includes('token invalid')) {
|
|
726
|
+
return new CLIError(
|
|
727
|
+
CLIErrorType.AUTH_EXPIRED,
|
|
728
|
+
'Authentication token expired',
|
|
729
|
+
{ stderr: result?.stderr }
|
|
730
|
+
);
|
|
731
|
+
}
|
|
732
|
+
|
|
733
|
+
// Rate limiting
|
|
734
|
+
if (
|
|
735
|
+
stderr.includes('rate limit') ||
|
|
736
|
+
stderr.includes('too many requests') ||
|
|
737
|
+
stderr.includes('429')
|
|
738
|
+
) {
|
|
739
|
+
return new CLIError(
|
|
740
|
+
CLIErrorType.RATE_LIMITED,
|
|
741
|
+
'Rate limit exceeded',
|
|
742
|
+
{ stderr: result?.stderr }
|
|
743
|
+
);
|
|
744
|
+
}
|
|
745
|
+
|
|
746
|
+
// Network errors
|
|
747
|
+
if (
|
|
748
|
+
stderr.includes('network') ||
|
|
749
|
+
stderr.includes('connection') ||
|
|
750
|
+
stderr.includes('timeout') ||
|
|
751
|
+
stderr.includes('econnrefused') ||
|
|
752
|
+
stderr.includes('enotfound')
|
|
753
|
+
) {
|
|
754
|
+
return new CLIError(
|
|
755
|
+
CLIErrorType.NETWORK_ERROR,
|
|
756
|
+
'Network error occurred',
|
|
757
|
+
{ stderr: result?.stderr }
|
|
758
|
+
);
|
|
759
|
+
}
|
|
760
|
+
|
|
761
|
+
// Generic non-zero exit
|
|
762
|
+
if (result?.exitCode !== null && result.exitCode !== 0) {
|
|
763
|
+
return new CLIError(
|
|
764
|
+
CLIErrorType.NON_ZERO_EXIT,
|
|
765
|
+
`Process exited with code ${result.exitCode}`,
|
|
766
|
+
{
|
|
767
|
+
exitCode: result.exitCode,
|
|
768
|
+
stderr: result.stderr,
|
|
769
|
+
stdout: result.stdout,
|
|
770
|
+
}
|
|
771
|
+
);
|
|
772
|
+
}
|
|
773
|
+
|
|
774
|
+
// Default error
|
|
775
|
+
return new CLIError(
|
|
776
|
+
CLIErrorType.SPAWN_FAILED,
|
|
777
|
+
error.message || 'Unknown error',
|
|
778
|
+
{ originalError: error }
|
|
779
|
+
);
|
|
780
|
+
}
|
|
781
|
+
|
|
782
|
+
/**
|
|
783
|
+
* Execute CLI with comprehensive error handling
|
|
784
|
+
*/
|
|
785
|
+
async function executeWithErrorHandling(
|
|
786
|
+
command: string,
|
|
787
|
+
args: string[],
|
|
788
|
+
options: SpawnOptions = {}
|
|
789
|
+
): Promise<CLIResult> {
|
|
790
|
+
try {
|
|
791
|
+
const result = await spawnCLI(command, args, options);
|
|
792
|
+
|
|
793
|
+
// Check for errors even on zero exit code
|
|
794
|
+
// Some CLIs output errors to stderr but still exit 0
|
|
795
|
+
if (result.stderr && !isExpectedStderr(result.stderr)) {
|
|
796
|
+
console.warn(`Warning from ${command}:`, result.stderr);
|
|
797
|
+
}
|
|
798
|
+
|
|
799
|
+
return result;
|
|
800
|
+
} catch (error) {
|
|
801
|
+
const cliError = detectErrorType(error);
|
|
802
|
+
|
|
803
|
+
// Log structured error for debugging
|
|
804
|
+
console.error('CLI Error:', {
|
|
805
|
+
type: cliError.type,
|
|
806
|
+
message: cliError.message,
|
|
807
|
+
retryable: cliError.isRetryable(),
|
|
808
|
+
requiresAction: cliError.requiresUserAction(),
|
|
809
|
+
details: cliError.details,
|
|
810
|
+
});
|
|
811
|
+
|
|
812
|
+
throw cliError;
|
|
813
|
+
}
|
|
814
|
+
}
|
|
815
|
+
|
|
816
|
+
/**
|
|
817
|
+
* Determine if stderr output is expected/informational
|
|
818
|
+
*/
|
|
819
|
+
function isExpectedStderr(stderr: string): boolean {
|
|
820
|
+
const lowerStderr = stderr.toLowerCase();
|
|
821
|
+
|
|
822
|
+
// Common informational messages
|
|
823
|
+
const expectedPatterns = [
|
|
824
|
+
'warning:',
|
|
825
|
+
'note:',
|
|
826
|
+
'info:',
|
|
827
|
+
'downloading',
|
|
828
|
+
'progress:',
|
|
829
|
+
'fetching',
|
|
830
|
+
];
|
|
831
|
+
|
|
832
|
+
return expectedPatterns.some((pattern) => lowerStderr.includes(pattern));
|
|
833
|
+
}
|
|
834
|
+
```
|
|
835
|
+
|
|
836
|
+
### Retry Logic with Exponential Backoff
|
|
837
|
+
|
|
838
|
+
```typescript
|
|
839
|
+
interface RetryOptions {
|
|
840
|
+
maxRetries?: number;
|
|
841
|
+
initialDelay?: number;
|
|
842
|
+
maxDelay?: number;
|
|
843
|
+
backoffFactor?: number;
|
|
844
|
+
shouldRetry?: (error: CLIError) => boolean;
|
|
845
|
+
}
|
|
846
|
+
|
|
847
|
+
/**
|
|
848
|
+
* Execute CLI command with retry logic
|
|
849
|
+
*/
|
|
850
|
+
async function executeWithRetry(
|
|
851
|
+
command: string,
|
|
852
|
+
args: string[],
|
|
853
|
+
spawnOptions: SpawnOptions = {},
|
|
854
|
+
retryOptions: RetryOptions = {}
|
|
855
|
+
): Promise<CLIResult> {
|
|
856
|
+
const {
|
|
857
|
+
maxRetries = 3,
|
|
858
|
+
initialDelay = 1000,
|
|
859
|
+
maxDelay = 30000,
|
|
860
|
+
backoffFactor = 2,
|
|
861
|
+
shouldRetry = (error) => error.isRetryable(),
|
|
862
|
+
} = retryOptions;
|
|
863
|
+
|
|
864
|
+
let lastError: CLIError;
|
|
865
|
+
let delay = initialDelay;
|
|
866
|
+
|
|
867
|
+
for (let attempt = 0; attempt <= maxRetries; attempt++) {
|
|
868
|
+
try {
|
|
869
|
+
return await executeWithErrorHandling(command, args, spawnOptions);
|
|
870
|
+
} catch (error) {
|
|
871
|
+
if (!(error instanceof CLIError)) {
|
|
872
|
+
throw error;
|
|
873
|
+
}
|
|
874
|
+
|
|
875
|
+
lastError = error;
|
|
876
|
+
|
|
877
|
+
// Don't retry if not retryable or max retries reached
|
|
878
|
+
if (!shouldRetry(error) || attempt === maxRetries) {
|
|
879
|
+
break;
|
|
880
|
+
}
|
|
881
|
+
|
|
882
|
+
console.log(`Retry attempt ${attempt + 1}/${maxRetries} after ${delay}ms`);
|
|
883
|
+
await new Promise((resolve) => setTimeout(resolve, delay));
|
|
884
|
+
|
|
885
|
+
// Exponential backoff
|
|
886
|
+
delay = Math.min(delay * backoffFactor, maxDelay);
|
|
887
|
+
}
|
|
888
|
+
}
|
|
889
|
+
|
|
890
|
+
throw lastError!;
|
|
891
|
+
}
|
|
892
|
+
|
|
893
|
+
// Example usage
|
|
894
|
+
async function callResilientCLI() {
|
|
895
|
+
return executeWithRetry(
|
|
896
|
+
'some-ai-cli',
|
|
897
|
+
['generate', '--prompt', 'Hello'],
|
|
898
|
+
{
|
|
899
|
+
timeout: 60000,
|
|
900
|
+
},
|
|
901
|
+
{
|
|
902
|
+
maxRetries: 3,
|
|
903
|
+
initialDelay: 2000,
|
|
904
|
+
shouldRetry: (error) => {
|
|
905
|
+
// Retry on network errors and rate limits
|
|
906
|
+
return [
|
|
907
|
+
CLIErrorType.NETWORK_ERROR,
|
|
908
|
+
CLIErrorType.RATE_LIMITED,
|
|
909
|
+
CLIErrorType.CONNECTION_TIMEOUT,
|
|
910
|
+
].includes(error.type);
|
|
911
|
+
},
|
|
912
|
+
}
|
|
913
|
+
);
|
|
914
|
+
}
|
|
915
|
+
```
|
|
916
|
+
|
|
917
|
+
---
|
|
918
|
+
|
|
919
|
+
## Timeout Handling
|
|
920
|
+
|
|
921
|
+
### Production-Ready Timeout Patterns
|
|
922
|
+
|
|
923
|
+
```typescript
|
|
924
|
+
/**
|
|
925
|
+
* Timeout handler with graceful degradation
|
|
926
|
+
*/
|
|
927
|
+
class TimeoutHandler {
|
|
928
|
+
private timeoutId?: NodeJS.Timeout;
|
|
929
|
+
private killTimeoutId?: NodeJS.Timeout;
|
|
930
|
+
private child?: ChildProcess;
|
|
931
|
+
|
|
932
|
+
constructor(
|
|
933
|
+
private readonly process: ChildProcess,
|
|
934
|
+
private readonly timeoutMs: number,
|
|
935
|
+
private readonly gracePeriodMs: number = 5000
|
|
936
|
+
) {
|
|
937
|
+
this.child = process;
|
|
938
|
+
this.start();
|
|
939
|
+
}
|
|
940
|
+
|
|
941
|
+
private start(): void {
|
|
942
|
+
this.timeoutId = setTimeout(() => {
|
|
943
|
+
this.handleTimeout();
|
|
944
|
+
}, this.timeoutMs);
|
|
945
|
+
}
|
|
946
|
+
|
|
947
|
+
private handleTimeout(): void {
|
|
948
|
+
if (!this.child) return;
|
|
949
|
+
|
|
950
|
+
console.warn(`Process timeout after ${this.timeoutMs}ms, sending SIGTERM`);
|
|
951
|
+
|
|
952
|
+
// First, try graceful termination
|
|
953
|
+
this.child.kill('SIGTERM');
|
|
954
|
+
|
|
955
|
+
// If process doesn't exit within grace period, force kill
|
|
956
|
+
this.killTimeoutId = setTimeout(() => {
|
|
957
|
+
if (this.child && this.child.exitCode === null) {
|
|
958
|
+
console.error('Process did not exit gracefully, sending SIGKILL');
|
|
959
|
+
this.child.kill('SIGKILL');
|
|
960
|
+
}
|
|
961
|
+
}, this.gracePeriodMs);
|
|
962
|
+
}
|
|
963
|
+
|
|
964
|
+
cancel(): void {
|
|
965
|
+
if (this.timeoutId) {
|
|
966
|
+
clearTimeout(this.timeoutId);
|
|
967
|
+
this.timeoutId = undefined;
|
|
968
|
+
}
|
|
969
|
+
if (this.killTimeoutId) {
|
|
970
|
+
clearTimeout(this.killTimeoutId);
|
|
971
|
+
this.killTimeoutId = undefined;
|
|
972
|
+
}
|
|
973
|
+
}
|
|
974
|
+
}
|
|
975
|
+
|
|
976
|
+
/**
|
|
977
|
+
* Spawn with advanced timeout handling
|
|
978
|
+
*/
|
|
979
|
+
async function spawnWithAdvancedTimeout(
|
|
980
|
+
command: string,
|
|
981
|
+
args: string[],
|
|
982
|
+
options: {
|
|
983
|
+
timeout: number;
|
|
984
|
+
gracePeriod?: number;
|
|
985
|
+
onTimeout?: () => void;
|
|
986
|
+
}
|
|
987
|
+
): Promise<CLIResult> {
|
|
988
|
+
return new Promise((resolve, reject) => {
|
|
989
|
+
const child = spawn(command, args, {
|
|
990
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
991
|
+
shell: false,
|
|
992
|
+
});
|
|
993
|
+
|
|
994
|
+
let stdout = '';
|
|
995
|
+
let stderr = '';
|
|
996
|
+
let timedOut = false;
|
|
997
|
+
|
|
998
|
+
// Create timeout handler
|
|
999
|
+
const timeoutHandler = new TimeoutHandler(
|
|
1000
|
+
child,
|
|
1001
|
+
options.timeout,
|
|
1002
|
+
options.gracePeriod
|
|
1003
|
+
);
|
|
1004
|
+
|
|
1005
|
+
child.stdout?.on('data', (chunk) => {
|
|
1006
|
+
stdout += chunk.toString('utf8');
|
|
1007
|
+
});
|
|
1008
|
+
|
|
1009
|
+
child.stderr?.on('data', (chunk) => {
|
|
1010
|
+
stderr += chunk.toString('utf8');
|
|
1011
|
+
});
|
|
1012
|
+
|
|
1013
|
+
child.on('close', (exitCode, signal) => {
|
|
1014
|
+
timeoutHandler.cancel();
|
|
1015
|
+
|
|
1016
|
+
if (timedOut) {
|
|
1017
|
+
reject(
|
|
1018
|
+
new CLIError(
|
|
1019
|
+
CLIErrorType.TIMEOUT,
|
|
1020
|
+
`Process timed out after ${options.timeout}ms`,
|
|
1021
|
+
{ exitCode, signal, stdout, stderr }
|
|
1022
|
+
)
|
|
1023
|
+
);
|
|
1024
|
+
return;
|
|
1025
|
+
}
|
|
1026
|
+
|
|
1027
|
+
resolve({ stdout, stderr, exitCode, signal });
|
|
1028
|
+
});
|
|
1029
|
+
|
|
1030
|
+
child.on('error', (error) => {
|
|
1031
|
+
timeoutHandler.cancel();
|
|
1032
|
+
reject(error);
|
|
1033
|
+
});
|
|
1034
|
+
|
|
1035
|
+
// Mark as timed out if SIGTERM was sent
|
|
1036
|
+
child.once('SIGTERM', () => {
|
|
1037
|
+
timedOut = true;
|
|
1038
|
+
options.onTimeout?.();
|
|
1039
|
+
});
|
|
1040
|
+
});
|
|
1041
|
+
}
|
|
1042
|
+
```
|
|
1043
|
+
|
|
1044
|
+
### Adaptive Timeouts Based on Operation Type
|
|
1045
|
+
|
|
1046
|
+
```typescript
|
|
1047
|
+
/**
|
|
1048
|
+
* Calculate adaptive timeout based on operation type and context
|
|
1049
|
+
*/
|
|
1050
|
+
function getAdaptiveTimeout(operation: {
|
|
1051
|
+
type: 'read' | 'write' | 'generate' | 'analyze';
|
|
1052
|
+
estimatedTokens?: number;
|
|
1053
|
+
complexity?: 'low' | 'medium' | 'high';
|
|
1054
|
+
}): number {
|
|
1055
|
+
const baseTimeouts = {
|
|
1056
|
+
read: 30000, // 30 seconds
|
|
1057
|
+
write: 60000, // 1 minute
|
|
1058
|
+
generate: 120000, // 2 minutes
|
|
1059
|
+
analyze: 180000, // 3 minutes
|
|
1060
|
+
};
|
|
1061
|
+
|
|
1062
|
+
let timeout = baseTimeouts[operation.type];
|
|
1063
|
+
|
|
1064
|
+
// Adjust for token count (AI operations)
|
|
1065
|
+
if (operation.estimatedTokens) {
|
|
1066
|
+
// ~100ms per 100 tokens (rough estimate)
|
|
1067
|
+
timeout += (operation.estimatedTokens / 100) * 100;
|
|
1068
|
+
}
|
|
1069
|
+
|
|
1070
|
+
// Adjust for complexity
|
|
1071
|
+
const complexityMultipliers = {
|
|
1072
|
+
low: 1,
|
|
1073
|
+
medium: 1.5,
|
|
1074
|
+
high: 2,
|
|
1075
|
+
};
|
|
1076
|
+
|
|
1077
|
+
if (operation.complexity) {
|
|
1078
|
+
timeout *= complexityMultipliers[operation.complexity];
|
|
1079
|
+
}
|
|
1080
|
+
|
|
1081
|
+
// Cap at 10 minutes
|
|
1082
|
+
return Math.min(timeout, 600000);
|
|
1083
|
+
}
|
|
1084
|
+
|
|
1085
|
+
// Example usage
|
|
1086
|
+
async function adaptiveExecute(prompt: string) {
|
|
1087
|
+
const timeout = getAdaptiveTimeout({
|
|
1088
|
+
type: 'generate',
|
|
1089
|
+
estimatedTokens: prompt.length * 2, // Rough estimate
|
|
1090
|
+
complexity: 'high',
|
|
1091
|
+
});
|
|
1092
|
+
|
|
1093
|
+
return spawnCLI('claude', ['--prompt', prompt], { timeout });
|
|
1094
|
+
}
|
|
1095
|
+
```
|
|
1096
|
+
|
|
1097
|
+
---
|
|
1098
|
+
|
|
1099
|
+
## Shell Escaping & Security
|
|
1100
|
+
|
|
1101
|
+
### Critical Security Principles
|
|
1102
|
+
|
|
1103
|
+
**🔴 NEVER use `exec()` with user input**
|
|
1104
|
+
**🟢 ALWAYS use `spawn()` or `execFile()` with argument arrays**
|
|
1105
|
+
|
|
1106
|
+
### Safe Argument Handling
|
|
1107
|
+
|
|
1108
|
+
```typescript
|
|
1109
|
+
/**
|
|
1110
|
+
* Validate and sanitize CLI arguments
|
|
1111
|
+
* NEVER trust user input directly
|
|
1112
|
+
*/
|
|
1113
|
+
class ArgumentValidator {
|
|
1114
|
+
/**
|
|
1115
|
+
* Validate argument against whitelist pattern
|
|
1116
|
+
*/
|
|
1117
|
+
static validate(
|
|
1118
|
+
arg: string,
|
|
1119
|
+
rules: {
|
|
1120
|
+
pattern?: RegExp;
|
|
1121
|
+
maxLength?: number;
|
|
1122
|
+
allowedChars?: RegExp;
|
|
1123
|
+
blockedPatterns?: RegExp[];
|
|
1124
|
+
}
|
|
1125
|
+
): boolean {
|
|
1126
|
+
// Check length
|
|
1127
|
+
if (rules.maxLength && arg.length > rules.maxLength) {
|
|
1128
|
+
return false;
|
|
1129
|
+
}
|
|
1130
|
+
|
|
1131
|
+
// Check against whitelist pattern
|
|
1132
|
+
if (rules.pattern && !rules.pattern.test(arg)) {
|
|
1133
|
+
return false;
|
|
1134
|
+
}
|
|
1135
|
+
|
|
1136
|
+
// Check allowed characters
|
|
1137
|
+
if (rules.allowedChars && !rules.allowedChars.test(arg)) {
|
|
1138
|
+
return false;
|
|
1139
|
+
}
|
|
1140
|
+
|
|
1141
|
+
// Check blocked patterns
|
|
1142
|
+
if (rules.blockedPatterns) {
|
|
1143
|
+
for (const blocked of rules.blockedPatterns) {
|
|
1144
|
+
if (blocked.test(arg)) {
|
|
1145
|
+
return false;
|
|
1146
|
+
}
|
|
1147
|
+
}
|
|
1148
|
+
}
|
|
1149
|
+
|
|
1150
|
+
return true;
|
|
1151
|
+
}
|
|
1152
|
+
|
|
1153
|
+
/**
|
|
1154
|
+
* Sanitize filename for use in CLI
|
|
1155
|
+
*/
|
|
1156
|
+
static sanitizeFilename(filename: string): string {
|
|
1157
|
+
// Remove directory traversal attempts
|
|
1158
|
+
const cleaned = filename.replace(/\.\.\//g, '').replace(/\.\.\\/g, '');
|
|
1159
|
+
|
|
1160
|
+
// Allow only alphanumeric, dash, underscore, dot
|
|
1161
|
+
const sanitized = cleaned.replace(/[^a-zA-Z0-9._-]/g, '_');
|
|
1162
|
+
|
|
1163
|
+
// Limit length
|
|
1164
|
+
return sanitized.slice(0, 255);
|
|
1165
|
+
}
|
|
1166
|
+
|
|
1167
|
+
/**
|
|
1168
|
+
* Validate that path is within allowed directory
|
|
1169
|
+
*/
|
|
1170
|
+
static isPathSafe(filePath: string, baseDir: string): boolean {
|
|
1171
|
+
const { resolve, relative } = require('path');
|
|
1172
|
+
|
|
1173
|
+
const resolvedBase = resolve(baseDir);
|
|
1174
|
+
const resolvedPath = resolve(baseDir, filePath);
|
|
1175
|
+
|
|
1176
|
+
// Check if resolved path is within base directory
|
|
1177
|
+
const relativePath = relative(resolvedBase, resolvedPath);
|
|
1178
|
+
|
|
1179
|
+
return (
|
|
1180
|
+
!relativePath.startsWith('..') &&
|
|
1181
|
+
!relativePath.startsWith('/') &&
|
|
1182
|
+
resolvedPath.startsWith(resolvedBase)
|
|
1183
|
+
);
|
|
1184
|
+
}
|
|
1185
|
+
}
|
|
1186
|
+
|
|
1187
|
+
/**
|
|
1188
|
+
* Example: Safe file operation
|
|
1189
|
+
*/
|
|
1190
|
+
async function safeFileOperation(
|
|
1191
|
+
userProvidedFilename: string,
|
|
1192
|
+
baseDir: string
|
|
1193
|
+
): Promise<void> {
|
|
1194
|
+
// Sanitize filename
|
|
1195
|
+
const sanitized = ArgumentValidator.sanitizeFilename(userProvidedFilename);
|
|
1196
|
+
|
|
1197
|
+
// Verify path is safe
|
|
1198
|
+
if (!ArgumentValidator.isPathSafe(sanitized, baseDir)) {
|
|
1199
|
+
throw new CLIError(
|
|
1200
|
+
CLIErrorType.PERMISSION_DENIED,
|
|
1201
|
+
'Invalid file path: directory traversal attempt detected'
|
|
1202
|
+
);
|
|
1203
|
+
}
|
|
1204
|
+
|
|
1205
|
+
// Use execFile with explicit arguments (safe)
|
|
1206
|
+
await safeExecute('cat', [sanitized], { cwd: baseDir });
|
|
1207
|
+
}
|
|
1208
|
+
```
|
|
1209
|
+
|
|
1210
|
+
### Shell Injection Prevention
|
|
1211
|
+
|
|
1212
|
+
```typescript
|
|
1213
|
+
/**
|
|
1214
|
+
* Common shell metacharacters that should raise red flags
|
|
1215
|
+
*/
|
|
1216
|
+
const SHELL_METACHARACTERS = [
|
|
1217
|
+
';', // Command separator
|
|
1218
|
+
'&', // Background execution
|
|
1219
|
+
'|', // Pipe
|
|
1220
|
+
'$', // Variable expansion
|
|
1221
|
+
'`', // Command substitution
|
|
1222
|
+
'(', ')', // Subshell
|
|
1223
|
+
'<', '>', // Redirection
|
|
1224
|
+
'\n', '\r', // Newlines
|
|
1225
|
+
'*', '?', '[', ']', // Globbing
|
|
1226
|
+
];
|
|
1227
|
+
|
|
1228
|
+
/**
|
|
1229
|
+
* Detect potential shell injection attempts
|
|
1230
|
+
*/
|
|
1231
|
+
function detectShellInjection(input: string): boolean {
|
|
1232
|
+
return SHELL_METACHARACTERS.some((char) => input.includes(char));
|
|
1233
|
+
}
|
|
1234
|
+
|
|
1235
|
+
/**
|
|
1236
|
+
* Safe CLI execution wrapper
|
|
1237
|
+
*/
|
|
1238
|
+
async function executeSafely(
|
|
1239
|
+
command: string,
|
|
1240
|
+
args: string[],
|
|
1241
|
+
options: {
|
|
1242
|
+
allowShellMetacharacters?: boolean;
|
|
1243
|
+
validateArgs?: boolean;
|
|
1244
|
+
} = {}
|
|
1245
|
+
): Promise<CLIResult> {
|
|
1246
|
+
const { allowShellMetacharacters = false, validateArgs = true } = options;
|
|
1247
|
+
|
|
1248
|
+
// Validate command name
|
|
1249
|
+
if (detectShellInjection(command)) {
|
|
1250
|
+
throw new CLIError(
|
|
1251
|
+
CLIErrorType.PERMISSION_DENIED,
|
|
1252
|
+
'Shell metacharacters detected in command name'
|
|
1253
|
+
);
|
|
1254
|
+
}
|
|
1255
|
+
|
|
1256
|
+
// Validate arguments
|
|
1257
|
+
if (validateArgs && !allowShellMetacharacters) {
|
|
1258
|
+
for (const arg of args) {
|
|
1259
|
+
if (detectShellInjection(arg)) {
|
|
1260
|
+
throw new CLIError(
|
|
1261
|
+
CLIErrorType.PERMISSION_DENIED,
|
|
1262
|
+
`Shell metacharacters detected in argument: ${arg}`
|
|
1263
|
+
);
|
|
1264
|
+
}
|
|
1265
|
+
}
|
|
1266
|
+
}
|
|
1267
|
+
|
|
1268
|
+
// Use spawn with explicit args (never shell)
|
|
1269
|
+
return spawnCLI(command, args, { ...options });
|
|
1270
|
+
}
|
|
1271
|
+
|
|
1272
|
+
// Example: Unsafe vs Safe
|
|
1273
|
+
async function unsafeExample(userInput: string) {
|
|
1274
|
+
// 🔴 DANGEROUS - Never do this!
|
|
1275
|
+
// const { exec } = require('child_process');
|
|
1276
|
+
// exec(`echo ${userInput}`); // Shell injection vulnerability!
|
|
1277
|
+
|
|
1278
|
+
// 🟢 SAFE - Use spawn with argument array
|
|
1279
|
+
return executeSafely('echo', [userInput], {
|
|
1280
|
+
validateArgs: true,
|
|
1281
|
+
});
|
|
1282
|
+
}
|
|
1283
|
+
```
|
|
1284
|
+
|
|
1285
|
+
### Secure Environment Variable Handling
|
|
1286
|
+
|
|
1287
|
+
```typescript
|
|
1288
|
+
/**
|
|
1289
|
+
* Create sanitized environment for child processes
|
|
1290
|
+
* Following principle of least privilege
|
|
1291
|
+
*/
|
|
1292
|
+
function createSecureEnv(
|
|
1293
|
+
additionalEnv: Record<string, string> = {}
|
|
1294
|
+
): NodeJS.ProcessEnv {
|
|
1295
|
+
// Start with minimal environment
|
|
1296
|
+
const secureEnv: NodeJS.ProcessEnv = {
|
|
1297
|
+
// Keep essential vars
|
|
1298
|
+
PATH: process.env.PATH,
|
|
1299
|
+
HOME: process.env.HOME,
|
|
1300
|
+
USER: process.env.USER,
|
|
1301
|
+
// Add any required vars
|
|
1302
|
+
...additionalEnv,
|
|
1303
|
+
};
|
|
1304
|
+
|
|
1305
|
+
// NEVER pass these sensitive vars to child processes
|
|
1306
|
+
const BLOCKED_VARS = [
|
|
1307
|
+
/^AWS_/,
|
|
1308
|
+
/^AZURE_/,
|
|
1309
|
+
/^GCP_/,
|
|
1310
|
+
/_SECRET$/,
|
|
1311
|
+
/_KEY$/,
|
|
1312
|
+
/_TOKEN$/,
|
|
1313
|
+
/_PASSWORD$/,
|
|
1314
|
+
/^DATABASE_/,
|
|
1315
|
+
/^DB_/,
|
|
1316
|
+
];
|
|
1317
|
+
|
|
1318
|
+
// Remove any accidentally included sensitive vars
|
|
1319
|
+
for (const key of Object.keys(secureEnv)) {
|
|
1320
|
+
if (BLOCKED_VARS.some((pattern) => pattern.test(key))) {
|
|
1321
|
+
delete secureEnv[key];
|
|
1322
|
+
console.warn(`Removed sensitive env var from child process: ${key}`);
|
|
1323
|
+
}
|
|
1324
|
+
}
|
|
1325
|
+
|
|
1326
|
+
return secureEnv;
|
|
1327
|
+
}
|
|
1328
|
+
|
|
1329
|
+
/**
|
|
1330
|
+
* Execute with minimal environment
|
|
1331
|
+
*/
|
|
1332
|
+
async function executeWithMinimalEnv(
|
|
1333
|
+
command: string,
|
|
1334
|
+
args: string[],
|
|
1335
|
+
requiredEnv: Record<string, string> = {}
|
|
1336
|
+
): Promise<CLIResult> {
|
|
1337
|
+
const env = createSecureEnv(requiredEnv);
|
|
1338
|
+
|
|
1339
|
+
return spawnCLI(command, args, { env });
|
|
1340
|
+
}
|
|
1341
|
+
|
|
1342
|
+
// Example usage
|
|
1343
|
+
async function callGitHubCLI() {
|
|
1344
|
+
// Only pass the specific token needed, not entire env
|
|
1345
|
+
return executeWithMinimalEnv('gh', ['api', 'user'], {
|
|
1346
|
+
GITHUB_TOKEN: process.env.GITHUB_TOKEN!, // Explicitly allowed
|
|
1347
|
+
});
|
|
1348
|
+
}
|
|
1349
|
+
```
|
|
1350
|
+
|
|
1351
|
+
---
|
|
1352
|
+
|
|
1353
|
+
## Buffer Management
|
|
1354
|
+
|
|
1355
|
+
### Handling Large Outputs
|
|
1356
|
+
|
|
1357
|
+
```typescript
|
|
1358
|
+
/**
|
|
1359
|
+
* Stream-based approach for large outputs
|
|
1360
|
+
* Avoids maxBuffer limitations by processing data incrementally
|
|
1361
|
+
*/
|
|
1362
|
+
async function handleLargeOutput(
|
|
1363
|
+
command: string,
|
|
1364
|
+
args: string[],
|
|
1365
|
+
processor: (chunk: string) => void | Promise<void>
|
|
1366
|
+
): Promise<void> {
|
|
1367
|
+
return new Promise((resolve, reject) => {
|
|
1368
|
+
const child = spawn(command, args, {
|
|
1369
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
1370
|
+
shell: false,
|
|
1371
|
+
});
|
|
1372
|
+
|
|
1373
|
+
// Process stdout chunks as they arrive
|
|
1374
|
+
// No buffer limits - data is processed and discarded
|
|
1375
|
+
child.stdout?.on('data', async (chunk: Buffer) => {
|
|
1376
|
+
const text = chunk.toString('utf8');
|
|
1377
|
+
try {
|
|
1378
|
+
await processor(text);
|
|
1379
|
+
} catch (error) {
|
|
1380
|
+
child.kill('SIGTERM');
|
|
1381
|
+
reject(error);
|
|
1382
|
+
}
|
|
1383
|
+
});
|
|
1384
|
+
|
|
1385
|
+
child.stderr?.on('data', (chunk: Buffer) => {
|
|
1386
|
+
console.error('stderr:', chunk.toString('utf8'));
|
|
1387
|
+
});
|
|
1388
|
+
|
|
1389
|
+
child.on('close', (exitCode) => {
|
|
1390
|
+
if (exitCode === 0) {
|
|
1391
|
+
resolve();
|
|
1392
|
+
} else {
|
|
1393
|
+
reject(new Error(`Process exited with code ${exitCode}`));
|
|
1394
|
+
}
|
|
1395
|
+
});
|
|
1396
|
+
|
|
1397
|
+
child.on('error', reject);
|
|
1398
|
+
});
|
|
1399
|
+
}
|
|
1400
|
+
|
|
1401
|
+
// Example: Process large file line by line
|
|
1402
|
+
async function processLargeFile(filePath: string) {
|
|
1403
|
+
let lineCount = 0;
|
|
1404
|
+
|
|
1405
|
+
await handleLargeOutput('cat', [filePath], (chunk) => {
|
|
1406
|
+
const lines = chunk.split('\n');
|
|
1407
|
+
lineCount += lines.length;
|
|
1408
|
+
|
|
1409
|
+
// Process each line without storing all in memory
|
|
1410
|
+
for (const line of lines) {
|
|
1411
|
+
if (line.trim()) {
|
|
1412
|
+
console.log(`Processing line ${lineCount}: ${line.slice(0, 50)}...`);
|
|
1413
|
+
}
|
|
1414
|
+
}
|
|
1415
|
+
});
|
|
1416
|
+
|
|
1417
|
+
console.log(`Processed ${lineCount} lines`);
|
|
1418
|
+
}
|
|
1419
|
+
```
|
|
1420
|
+
|
|
1421
|
+
### Dynamic Buffer Sizing
|
|
1422
|
+
|
|
1423
|
+
```typescript
|
|
1424
|
+
/**
|
|
1425
|
+
* Calculate appropriate buffer size based on operation
|
|
1426
|
+
*/
|
|
1427
|
+
function calculateBufferSize(options: {
|
|
1428
|
+
operationType: 'read' | 'write' | 'generate';
|
|
1429
|
+
estimatedSize?: number;
|
|
1430
|
+
safetyFactor?: number;
|
|
1431
|
+
}): number {
|
|
1432
|
+
const {
|
|
1433
|
+
operationType,
|
|
1434
|
+
estimatedSize,
|
|
1435
|
+
safetyFactor = 2, // 2x safety margin
|
|
1436
|
+
} = options;
|
|
1437
|
+
|
|
1438
|
+
// Base buffer sizes
|
|
1439
|
+
const baseBuffers = {
|
|
1440
|
+
read: 1024 * 1024, // 1MB
|
|
1441
|
+
write: 512 * 1024, // 512KB
|
|
1442
|
+
generate: 10 * 1024 * 1024, // 10MB for AI generation
|
|
1443
|
+
};
|
|
1444
|
+
|
|
1445
|
+
let bufferSize = baseBuffers[operationType];
|
|
1446
|
+
|
|
1447
|
+
// Adjust based on estimated size
|
|
1448
|
+
if (estimatedSize) {
|
|
1449
|
+
bufferSize = Math.max(bufferSize, estimatedSize * safetyFactor);
|
|
1450
|
+
}
|
|
1451
|
+
|
|
1452
|
+
// Cap at 100MB to prevent excessive memory usage
|
|
1453
|
+
return Math.min(bufferSize, 100 * 1024 * 1024);
|
|
1454
|
+
}
|
|
1455
|
+
|
|
1456
|
+
/**
|
|
1457
|
+
* Execute with dynamic buffer sizing
|
|
1458
|
+
*/
|
|
1459
|
+
async function executeDynamic(
|
|
1460
|
+
command: string,
|
|
1461
|
+
args: string[],
|
|
1462
|
+
options: {
|
|
1463
|
+
operationType: 'read' | 'write' | 'generate';
|
|
1464
|
+
estimatedSize?: number;
|
|
1465
|
+
}
|
|
1466
|
+
): Promise<CLIResult> {
|
|
1467
|
+
const maxBuffer = calculateBufferSize(options);
|
|
1468
|
+
|
|
1469
|
+
console.log(`Using buffer size: ${(maxBuffer / 1024 / 1024).toFixed(2)}MB`);
|
|
1470
|
+
|
|
1471
|
+
return spawnCLI(command, args, { maxBuffer });
|
|
1472
|
+
}
|
|
1473
|
+
```
|
|
1474
|
+
|
|
1475
|
+
### Write Stream to File Instead of Memory
|
|
1476
|
+
|
|
1477
|
+
```typescript
|
|
1478
|
+
import { createWriteStream } from 'fs';
|
|
1479
|
+
import { pipeline } from 'stream/promises';
|
|
1480
|
+
|
|
1481
|
+
/**
|
|
1482
|
+
* Pipe large CLI output directly to file
|
|
1483
|
+
* Avoids memory issues entirely
|
|
1484
|
+
*/
|
|
1485
|
+
async function pipeOutputToFile(
|
|
1486
|
+
command: string,
|
|
1487
|
+
args: string[],
|
|
1488
|
+
outputPath: string
|
|
1489
|
+
): Promise<void> {
|
|
1490
|
+
const child = spawn(command, args, {
|
|
1491
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
1492
|
+
shell: false,
|
|
1493
|
+
});
|
|
1494
|
+
|
|
1495
|
+
const writeStream = createWriteStream(outputPath);
|
|
1496
|
+
|
|
1497
|
+
try {
|
|
1498
|
+
// Pipe stdout directly to file
|
|
1499
|
+
await pipeline(child.stdout!, writeStream);
|
|
1500
|
+
} catch (error) {
|
|
1501
|
+
throw new Error(`Failed to pipe output to file: ${error}`);
|
|
1502
|
+
}
|
|
1503
|
+
|
|
1504
|
+
return new Promise((resolve, reject) => {
|
|
1505
|
+
child.on('close', (exitCode) => {
|
|
1506
|
+
if (exitCode === 0) {
|
|
1507
|
+
resolve();
|
|
1508
|
+
} else {
|
|
1509
|
+
reject(new Error(`Process exited with code ${exitCode}`));
|
|
1510
|
+
}
|
|
1511
|
+
});
|
|
1512
|
+
|
|
1513
|
+
child.on('error', reject);
|
|
1514
|
+
});
|
|
1515
|
+
}
|
|
1516
|
+
|
|
1517
|
+
// Example: Download large dataset
|
|
1518
|
+
async function downloadDataset(url: string, outputPath: string) {
|
|
1519
|
+
await pipeOutputToFile('curl', ['-L', url], outputPath);
|
|
1520
|
+
console.log(`Downloaded to ${outputPath}`);
|
|
1521
|
+
}
|
|
1522
|
+
```
|
|
1523
|
+
|
|
1524
|
+
---
|
|
1525
|
+
|
|
1526
|
+
## Exit Code Handling
|
|
1527
|
+
|
|
1528
|
+
### Standard Unix Exit Codes
|
|
1529
|
+
|
|
1530
|
+
```typescript
|
|
1531
|
+
/**
|
|
1532
|
+
* Standard POSIX exit codes
|
|
1533
|
+
*/
|
|
1534
|
+
export enum ExitCode {
|
|
1535
|
+
SUCCESS = 0,
|
|
1536
|
+
GENERAL_ERROR = 1,
|
|
1537
|
+
MISUSE_OF_SHELL_BUILTIN = 2,
|
|
1538
|
+
|
|
1539
|
+
// sysexits.h codes (64-78)
|
|
1540
|
+
USAGE_ERROR = 64, // Command line usage error
|
|
1541
|
+
DATA_ERROR = 65, // Data format error
|
|
1542
|
+
NO_INPUT = 66, // Cannot open input
|
|
1543
|
+
NO_USER = 67, // Addressee unknown
|
|
1544
|
+
NO_HOST = 68, // Host name unknown
|
|
1545
|
+
UNAVAILABLE = 69, // Service unavailable
|
|
1546
|
+
SOFTWARE_ERROR = 70, // Internal software error
|
|
1547
|
+
OS_ERROR = 71, // System error (e.g., can't fork)
|
|
1548
|
+
OS_FILE_ERROR = 72, // Critical OS file missing
|
|
1549
|
+
CANT_CREATE = 73, // Can't create (user) output file
|
|
1550
|
+
IO_ERROR = 74, // Input/output error
|
|
1551
|
+
TEMP_FAIL = 75, // Temp failure; user is invited to retry
|
|
1552
|
+
PROTOCOL_ERROR = 76, // Remote error in protocol
|
|
1553
|
+
NO_PERMISSION = 77, // Permission denied
|
|
1554
|
+
CONFIG_ERROR = 78, // Configuration error
|
|
1555
|
+
|
|
1556
|
+
// 126-165 are reserved
|
|
1557
|
+
COMMAND_NOT_EXECUTABLE = 126,
|
|
1558
|
+
COMMAND_NOT_FOUND = 127,
|
|
1559
|
+
|
|
1560
|
+
// 128+N = terminated by signal N
|
|
1561
|
+
TERMINATED_BY_SIGNAL_BASE = 128,
|
|
1562
|
+
|
|
1563
|
+
// Common signal terminations
|
|
1564
|
+
SIGINT = 130, // 128 + 2 (Ctrl+C)
|
|
1565
|
+
SIGTERM = 143, // 128 + 15
|
|
1566
|
+
SIGKILL = 137, // 128 + 9
|
|
1567
|
+
}
|
|
1568
|
+
|
|
1569
|
+
/**
|
|
1570
|
+
* Interpret exit code and provide human-readable message
|
|
1571
|
+
*/
|
|
1572
|
+
function interpretExitCode(exitCode: number, signal: NodeJS.Signals | null): {
|
|
1573
|
+
type: 'success' | 'error' | 'signal';
|
|
1574
|
+
message: string;
|
|
1575
|
+
retryable: boolean;
|
|
1576
|
+
} {
|
|
1577
|
+
// Success
|
|
1578
|
+
if (exitCode === ExitCode.SUCCESS) {
|
|
1579
|
+
return {
|
|
1580
|
+
type: 'success',
|
|
1581
|
+
message: 'Command completed successfully',
|
|
1582
|
+
retryable: false,
|
|
1583
|
+
};
|
|
1584
|
+
}
|
|
1585
|
+
|
|
1586
|
+
// Signal termination
|
|
1587
|
+
if (signal) {
|
|
1588
|
+
return {
|
|
1589
|
+
type: 'signal',
|
|
1590
|
+
message: `Process terminated by signal: ${signal}`,
|
|
1591
|
+
retryable: signal === 'SIGTERM' || signal === 'SIGINT',
|
|
1592
|
+
};
|
|
1593
|
+
}
|
|
1594
|
+
|
|
1595
|
+
// Exit code >= 128 indicates signal termination
|
|
1596
|
+
if (exitCode >= ExitCode.TERMINATED_BY_SIGNAL_BASE) {
|
|
1597
|
+
const signalNumber = exitCode - ExitCode.TERMINATED_BY_SIGNAL_BASE;
|
|
1598
|
+
return {
|
|
1599
|
+
type: 'signal',
|
|
1600
|
+
message: `Process terminated by signal ${signalNumber}`,
|
|
1601
|
+
retryable: true,
|
|
1602
|
+
};
|
|
1603
|
+
}
|
|
1604
|
+
|
|
1605
|
+
// Specific exit codes
|
|
1606
|
+
switch (exitCode) {
|
|
1607
|
+
case ExitCode.COMMAND_NOT_FOUND:
|
|
1608
|
+
return {
|
|
1609
|
+
type: 'error',
|
|
1610
|
+
message: 'Command not found',
|
|
1611
|
+
retryable: false,
|
|
1612
|
+
};
|
|
1613
|
+
|
|
1614
|
+
case ExitCode.COMMAND_NOT_EXECUTABLE:
|
|
1615
|
+
return {
|
|
1616
|
+
type: 'error',
|
|
1617
|
+
message: 'Command not executable (permission denied)',
|
|
1618
|
+
retryable: false,
|
|
1619
|
+
};
|
|
1620
|
+
|
|
1621
|
+
case ExitCode.USAGE_ERROR:
|
|
1622
|
+
return {
|
|
1623
|
+
type: 'error',
|
|
1624
|
+
message: 'Invalid command usage',
|
|
1625
|
+
retryable: false,
|
|
1626
|
+
};
|
|
1627
|
+
|
|
1628
|
+
case ExitCode.DATA_ERROR:
|
|
1629
|
+
return {
|
|
1630
|
+
type: 'error',
|
|
1631
|
+
message: 'Invalid data format',
|
|
1632
|
+
retryable: false,
|
|
1633
|
+
};
|
|
1634
|
+
|
|
1635
|
+
case ExitCode.TEMP_FAIL:
|
|
1636
|
+
return {
|
|
1637
|
+
type: 'error',
|
|
1638
|
+
message: 'Temporary failure (retry recommended)',
|
|
1639
|
+
retryable: true,
|
|
1640
|
+
};
|
|
1641
|
+
|
|
1642
|
+
case ExitCode.UNAVAILABLE:
|
|
1643
|
+
return {
|
|
1644
|
+
type: 'error',
|
|
1645
|
+
message: 'Service unavailable',
|
|
1646
|
+
retryable: true,
|
|
1647
|
+
};
|
|
1648
|
+
|
|
1649
|
+
case ExitCode.NO_PERMISSION:
|
|
1650
|
+
return {
|
|
1651
|
+
type: 'error',
|
|
1652
|
+
message: 'Permission denied',
|
|
1653
|
+
retryable: false,
|
|
1654
|
+
};
|
|
1655
|
+
|
|
1656
|
+
default:
|
|
1657
|
+
return {
|
|
1658
|
+
type: 'error',
|
|
1659
|
+
message: `Command failed with exit code ${exitCode}`,
|
|
1660
|
+
retryable: exitCode === ExitCode.GENERAL_ERROR, // Maybe retryable
|
|
1661
|
+
};
|
|
1662
|
+
}
|
|
1663
|
+
}
|
|
1664
|
+
|
|
1665
|
+
/**
|
|
1666
|
+
* Execute and handle exit codes intelligently
|
|
1667
|
+
*/
|
|
1668
|
+
async function executeWithExitCodeHandling(
|
|
1669
|
+
command: string,
|
|
1670
|
+
args: string[]
|
|
1671
|
+
): Promise<CLIResult> {
|
|
1672
|
+
const result = await spawnCLI(command, args);
|
|
1673
|
+
|
|
1674
|
+
const interpretation = interpretExitCode(result.exitCode, result.signal);
|
|
1675
|
+
|
|
1676
|
+
if (interpretation.type !== 'success') {
|
|
1677
|
+
throw new CLIError(
|
|
1678
|
+
interpretation.retryable ? CLIErrorType.TEMP_FAIL : CLIErrorType.NON_ZERO_EXIT,
|
|
1679
|
+
interpretation.message,
|
|
1680
|
+
{
|
|
1681
|
+
command,
|
|
1682
|
+
args,
|
|
1683
|
+
exitCode: result.exitCode,
|
|
1684
|
+
signal: result.signal,
|
|
1685
|
+
stdout: result.stdout,
|
|
1686
|
+
stderr: result.stderr,
|
|
1687
|
+
}
|
|
1688
|
+
);
|
|
1689
|
+
}
|
|
1690
|
+
|
|
1691
|
+
return result;
|
|
1692
|
+
}
|
|
1693
|
+
```
|
|
1694
|
+
|
|
1695
|
+
### CLI-Specific Exit Code Mappings
|
|
1696
|
+
|
|
1697
|
+
```typescript
|
|
1698
|
+
/**
|
|
1699
|
+
* Map CLI-specific exit codes to standard errors
|
|
1700
|
+
* Many CLI tools use custom exit code ranges
|
|
1701
|
+
*/
|
|
1702
|
+
const CLI_EXIT_CODE_MAPS: Record<string, Record<number, CLIErrorType>> = {
|
|
1703
|
+
// GitHub CLI
|
|
1704
|
+
gh: {
|
|
1705
|
+
1: CLIErrorType.GENERAL_ERROR,
|
|
1706
|
+
2: CLIErrorType.AUTH_REQUIRED,
|
|
1707
|
+
4: CLIErrorType.RATE_LIMITED,
|
|
1708
|
+
},
|
|
1709
|
+
|
|
1710
|
+
// Git
|
|
1711
|
+
git: {
|
|
1712
|
+
1: CLIErrorType.GENERAL_ERROR,
|
|
1713
|
+
128: CLIErrorType.INVALID_OUTPUT, // Git specific: invalid argument
|
|
1714
|
+
129: CLIErrorType.SIGNAL_TERMINATED, // SIGHUP
|
|
1715
|
+
},
|
|
1716
|
+
|
|
1717
|
+
// Claude CLI (example)
|
|
1718
|
+
claude: {
|
|
1719
|
+
1: CLIErrorType.GENERAL_ERROR,
|
|
1720
|
+
2: CLIErrorType.AUTH_REQUIRED,
|
|
1721
|
+
3: CLIErrorType.RATE_LIMITED,
|
|
1722
|
+
4: CLIErrorType.QUOTA_EXCEEDED,
|
|
1723
|
+
5: CLIErrorType.INVALID_OUTPUT,
|
|
1724
|
+
},
|
|
1725
|
+
};
|
|
1726
|
+
|
|
1727
|
+
/**
|
|
1728
|
+
* Get error type based on CLI-specific exit code
|
|
1729
|
+
*/
|
|
1730
|
+
function getErrorTypeFromExitCode(
|
|
1731
|
+
command: string,
|
|
1732
|
+
exitCode: number
|
|
1733
|
+
): CLIErrorType {
|
|
1734
|
+
const cliName = command.split('/').pop() || command;
|
|
1735
|
+
const cliMap = CLI_EXIT_CODE_MAPS[cliName];
|
|
1736
|
+
|
|
1737
|
+
if (cliMap && cliMap[exitCode]) {
|
|
1738
|
+
return cliMap[exitCode];
|
|
1739
|
+
}
|
|
1740
|
+
|
|
1741
|
+
// Default mapping
|
|
1742
|
+
if (exitCode === 0) {
|
|
1743
|
+
return CLIErrorType.NON_ZERO_EXIT; // Should never happen
|
|
1744
|
+
}
|
|
1745
|
+
|
|
1746
|
+
return CLIErrorType.GENERAL_ERROR;
|
|
1747
|
+
}
|
|
1748
|
+
```
|
|
1749
|
+
|
|
1750
|
+
---
|
|
1751
|
+
|
|
1752
|
+
## Environment Variables
|
|
1753
|
+
|
|
1754
|
+
### Secure Environment Variable Passing
|
|
1755
|
+
|
|
1756
|
+
```typescript
|
|
1757
|
+
/**
|
|
1758
|
+
* Environment variable manager for CLI tools
|
|
1759
|
+
* Implements principle of least privilege
|
|
1760
|
+
*/
|
|
1761
|
+
class EnvironmentManager {
|
|
1762
|
+
private static readonly SENSITIVE_PATTERNS = [
|
|
1763
|
+
/^AWS_/,
|
|
1764
|
+
/^AZURE_/,
|
|
1765
|
+
/^GCP_/,
|
|
1766
|
+
/_SECRET$/,
|
|
1767
|
+
/_KEY$/,
|
|
1768
|
+
/_TOKEN$/,
|
|
1769
|
+
/_PASSWORD$/,
|
|
1770
|
+
/^DATABASE_/,
|
|
1771
|
+
/^DB_/,
|
|
1772
|
+
/^OPENAI_/,
|
|
1773
|
+
/^ANTHROPIC_/,
|
|
1774
|
+
];
|
|
1775
|
+
|
|
1776
|
+
/**
|
|
1777
|
+
* Create minimal environment for command
|
|
1778
|
+
*/
|
|
1779
|
+
static createMinimalEnv(
|
|
1780
|
+
allowedVars: string[] = []
|
|
1781
|
+
): NodeJS.ProcessEnv {
|
|
1782
|
+
const minimalEnv: NodeJS.ProcessEnv = {
|
|
1783
|
+
// Essential system vars
|
|
1784
|
+
PATH: process.env.PATH,
|
|
1785
|
+
HOME: process.env.HOME,
|
|
1786
|
+
USER: process.env.USER,
|
|
1787
|
+
TMPDIR: process.env.TMPDIR,
|
|
1788
|
+
};
|
|
1789
|
+
|
|
1790
|
+
// Add explicitly allowed vars
|
|
1791
|
+
for (const varName of allowedVars) {
|
|
1792
|
+
if (process.env[varName]) {
|
|
1793
|
+
minimalEnv[varName] = process.env[varName];
|
|
1794
|
+
}
|
|
1795
|
+
}
|
|
1796
|
+
|
|
1797
|
+
return minimalEnv;
|
|
1798
|
+
}
|
|
1799
|
+
|
|
1800
|
+
/**
|
|
1801
|
+
* Create env with specific CLI tool requirements
|
|
1802
|
+
*/
|
|
1803
|
+
static createCLIEnv(
|
|
1804
|
+
cli: 'gh' | 'git' | 'claude' | 'openai',
|
|
1805
|
+
additionalVars: Record<string, string> = {}
|
|
1806
|
+
): NodeJS.ProcessEnv {
|
|
1807
|
+
const cliRequirements: Record<string, string[]> = {
|
|
1808
|
+
gh: ['GITHUB_TOKEN', 'GH_TOKEN'],
|
|
1809
|
+
git: ['GIT_AUTHOR_NAME', 'GIT_AUTHOR_EMAIL', 'GIT_COMMITTER_NAME', 'GIT_COMMITTER_EMAIL'],
|
|
1810
|
+
claude: ['ANTHROPIC_API_KEY', 'CLAUDE_API_KEY'],
|
|
1811
|
+
openai: ['OPENAI_API_KEY'],
|
|
1812
|
+
};
|
|
1813
|
+
|
|
1814
|
+
const allowedVars = cliRequirements[cli] || [];
|
|
1815
|
+
const env = this.createMinimalEnv(allowedVars);
|
|
1816
|
+
|
|
1817
|
+
// Add additional vars
|
|
1818
|
+
Object.assign(env, additionalVars);
|
|
1819
|
+
|
|
1820
|
+
return env;
|
|
1821
|
+
}
|
|
1822
|
+
|
|
1823
|
+
/**
|
|
1824
|
+
* Validate that no sensitive vars are accidentally exposed
|
|
1825
|
+
*/
|
|
1826
|
+
static validateEnv(env: NodeJS.ProcessEnv): void {
|
|
1827
|
+
for (const key of Object.keys(env)) {
|
|
1828
|
+
if (this.isSensitiveVar(key)) {
|
|
1829
|
+
console.warn(`Warning: Sensitive env var passed to child process: ${key}`);
|
|
1830
|
+
}
|
|
1831
|
+
}
|
|
1832
|
+
}
|
|
1833
|
+
|
|
1834
|
+
/**
|
|
1835
|
+
* Check if var name matches sensitive patterns
|
|
1836
|
+
*/
|
|
1837
|
+
static isSensitiveVar(varName: string): boolean {
|
|
1838
|
+
return this.SENSITIVE_PATTERNS.some((pattern) => pattern.test(varName));
|
|
1839
|
+
}
|
|
1840
|
+
|
|
1841
|
+
/**
|
|
1842
|
+
* Redact sensitive values for logging
|
|
1843
|
+
*/
|
|
1844
|
+
static redactForLogging(env: NodeJS.ProcessEnv): Record<string, string> {
|
|
1845
|
+
const redacted: Record<string, string> = {};
|
|
1846
|
+
|
|
1847
|
+
for (const [key, value] of Object.entries(env)) {
|
|
1848
|
+
if (this.isSensitiveVar(key)) {
|
|
1849
|
+
redacted[key] = '***REDACTED***';
|
|
1850
|
+
} else {
|
|
1851
|
+
redacted[key] = value || '';
|
|
1852
|
+
}
|
|
1853
|
+
}
|
|
1854
|
+
|
|
1855
|
+
return redacted;
|
|
1856
|
+
}
|
|
1857
|
+
}
|
|
1858
|
+
|
|
1859
|
+
/**
|
|
1860
|
+
* Execute with managed environment
|
|
1861
|
+
*/
|
|
1862
|
+
async function executeWithManagedEnv(
|
|
1863
|
+
command: string,
|
|
1864
|
+
args: string[],
|
|
1865
|
+
options: {
|
|
1866
|
+
cli?: 'gh' | 'git' | 'claude' | 'openai';
|
|
1867
|
+
additionalEnv?: Record<string, string>;
|
|
1868
|
+
allowedVars?: string[];
|
|
1869
|
+
} = {}
|
|
1870
|
+
): Promise<CLIResult> {
|
|
1871
|
+
let env: NodeJS.ProcessEnv;
|
|
1872
|
+
|
|
1873
|
+
if (options.cli) {
|
|
1874
|
+
env = EnvironmentManager.createCLIEnv(options.cli, options.additionalEnv);
|
|
1875
|
+
} else {
|
|
1876
|
+
env = EnvironmentManager.createMinimalEnv(options.allowedVars);
|
|
1877
|
+
Object.assign(env, options.additionalEnv);
|
|
1878
|
+
}
|
|
1879
|
+
|
|
1880
|
+
// Validate before execution
|
|
1881
|
+
EnvironmentManager.validateEnv(env);
|
|
1882
|
+
|
|
1883
|
+
// Log redacted environment (for debugging)
|
|
1884
|
+
console.debug('Executing with env:', EnvironmentManager.redactForLogging(env));
|
|
1885
|
+
|
|
1886
|
+
return spawnCLI(command, args, { env });
|
|
1887
|
+
}
|
|
1888
|
+
|
|
1889
|
+
// Example usage
|
|
1890
|
+
async function callGitHubCLI() {
|
|
1891
|
+
return executeWithManagedEnv('gh', ['api', 'user'], {
|
|
1892
|
+
cli: 'gh',
|
|
1893
|
+
});
|
|
1894
|
+
}
|
|
1895
|
+
|
|
1896
|
+
async function callCustomCLI() {
|
|
1897
|
+
return executeWithManagedEnv('custom-cli', ['process'], {
|
|
1898
|
+
allowedVars: ['CUSTOM_API_KEY'],
|
|
1899
|
+
additionalEnv: {
|
|
1900
|
+
LOG_LEVEL: 'debug',
|
|
1901
|
+
},
|
|
1902
|
+
});
|
|
1903
|
+
}
|
|
1904
|
+
```
|
|
1905
|
+
|
|
1906
|
+
### Alternative: File-Based Secrets
|
|
1907
|
+
|
|
1908
|
+
```typescript
|
|
1909
|
+
import { readFile } from 'fs/promises';
|
|
1910
|
+
import { join } from 'path';
|
|
1911
|
+
|
|
1912
|
+
/**
|
|
1913
|
+
* Load secrets from file instead of environment
|
|
1914
|
+
* More secure than env vars for sensitive data
|
|
1915
|
+
*/
|
|
1916
|
+
class SecretManager {
|
|
1917
|
+
private static cache = new Map<string, string>();
|
|
1918
|
+
|
|
1919
|
+
/**
|
|
1920
|
+
* Load secret from file
|
|
1921
|
+
*/
|
|
1922
|
+
static async loadSecret(secretName: string): Promise<string> {
|
|
1923
|
+
if (this.cache.has(secretName)) {
|
|
1924
|
+
return this.cache.get(secretName)!;
|
|
1925
|
+
}
|
|
1926
|
+
|
|
1927
|
+
const secretPath = join(process.env.HOME!, '.config', 'mdcontext', 'secrets', secretName);
|
|
1928
|
+
|
|
1929
|
+
try {
|
|
1930
|
+
const secret = await readFile(secretPath, 'utf-8');
|
|
1931
|
+
this.cache.set(secretName, secret.trim());
|
|
1932
|
+
return secret.trim();
|
|
1933
|
+
} catch (error) {
|
|
1934
|
+
throw new Error(`Failed to load secret '${secretName}': ${error}`);
|
|
1935
|
+
}
|
|
1936
|
+
}
|
|
1937
|
+
|
|
1938
|
+
/**
|
|
1939
|
+
* Pass secret to CLI via stdin instead of env
|
|
1940
|
+
*/
|
|
1941
|
+
static async executeWithStdinSecret(
|
|
1942
|
+
command: string,
|
|
1943
|
+
args: string[],
|
|
1944
|
+
secretName: string
|
|
1945
|
+
): Promise<CLIResult> {
|
|
1946
|
+
const secret = await this.loadSecret(secretName);
|
|
1947
|
+
|
|
1948
|
+
return new Promise((resolve, reject) => {
|
|
1949
|
+
const child = spawn(command, args, {
|
|
1950
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
1951
|
+
shell: false,
|
|
1952
|
+
});
|
|
1953
|
+
|
|
1954
|
+
// Write secret to stdin
|
|
1955
|
+
child.stdin?.write(secret);
|
|
1956
|
+
child.stdin?.end();
|
|
1957
|
+
|
|
1958
|
+
let stdout = '';
|
|
1959
|
+
let stderr = '';
|
|
1960
|
+
|
|
1961
|
+
child.stdout?.on('data', (chunk) => {
|
|
1962
|
+
stdout += chunk.toString('utf8');
|
|
1963
|
+
});
|
|
1964
|
+
|
|
1965
|
+
child.stderr?.on('data', (chunk) => {
|
|
1966
|
+
stderr += chunk.toString('utf8');
|
|
1967
|
+
});
|
|
1968
|
+
|
|
1969
|
+
child.on('close', (exitCode, signal) => {
|
|
1970
|
+
resolve({ stdout, stderr, exitCode, signal });
|
|
1971
|
+
});
|
|
1972
|
+
|
|
1973
|
+
child.on('error', reject);
|
|
1974
|
+
});
|
|
1975
|
+
}
|
|
1976
|
+
|
|
1977
|
+
/**
|
|
1978
|
+
* Clear secret cache (for security)
|
|
1979
|
+
*/
|
|
1980
|
+
static clearCache(): void {
|
|
1981
|
+
this.cache.clear();
|
|
1982
|
+
}
|
|
1983
|
+
}
|
|
1984
|
+
|
|
1985
|
+
// Example usage
|
|
1986
|
+
async function authenticatedCLICall() {
|
|
1987
|
+
// Secret is read from file, not env var
|
|
1988
|
+
return SecretManager.executeWithStdinSecret(
|
|
1989
|
+
'some-cli',
|
|
1990
|
+
['authenticate', '--stdin'],
|
|
1991
|
+
'api_token'
|
|
1992
|
+
);
|
|
1993
|
+
}
|
|
1994
|
+
```
|
|
1995
|
+
|
|
1996
|
+
---
|
|
1997
|
+
|
|
1998
|
+
## Testing Strategies
|
|
1999
|
+
|
|
2000
|
+
### Mocking Child Processes with Jest
|
|
2001
|
+
|
|
2002
|
+
```typescript
|
|
2003
|
+
// __tests__/cli-integration.test.ts
|
|
2004
|
+
import { EventEmitter } from 'events';
|
|
2005
|
+
import type { ChildProcess } from 'child_process';
|
|
2006
|
+
|
|
2007
|
+
/**
|
|
2008
|
+
* Create a mock child process for testing
|
|
2009
|
+
*/
|
|
2010
|
+
function createMockChildProcess(): ChildProcess {
|
|
2011
|
+
const mockProcess = new EventEmitter() as ChildProcess;
|
|
2012
|
+
|
|
2013
|
+
// Mock stdio streams
|
|
2014
|
+
mockProcess.stdout = new EventEmitter() as any;
|
|
2015
|
+
mockProcess.stderr = new EventEmitter() as any;
|
|
2016
|
+
mockProcess.stdin = new EventEmitter() as any;
|
|
2017
|
+
mockProcess.stdin.write = jest.fn();
|
|
2018
|
+
mockProcess.stdin.end = jest.fn();
|
|
2019
|
+
|
|
2020
|
+
// Mock methods
|
|
2021
|
+
mockProcess.kill = jest.fn().mockReturnValue(true);
|
|
2022
|
+
mockProcess.pid = 12345;
|
|
2023
|
+
mockProcess.exitCode = null;
|
|
2024
|
+
mockProcess.signalCode = null;
|
|
2025
|
+
|
|
2026
|
+
return mockProcess;
|
|
2027
|
+
}
|
|
2028
|
+
|
|
2029
|
+
/**
|
|
2030
|
+
* Test suite for CLI integration
|
|
2031
|
+
*/
|
|
2032
|
+
describe('CLI Integration', () => {
|
|
2033
|
+
let mockSpawn: jest.Mock;
|
|
2034
|
+
let mockChildProcess: ChildProcess;
|
|
2035
|
+
|
|
2036
|
+
beforeEach(() => {
|
|
2037
|
+
// Reset mocks
|
|
2038
|
+
jest.clearAllMocks();
|
|
2039
|
+
|
|
2040
|
+
// Create mock child process
|
|
2041
|
+
mockChildProcess = createMockChildProcess();
|
|
2042
|
+
|
|
2043
|
+
// Mock spawn function
|
|
2044
|
+
mockSpawn = jest.fn().mockReturnValue(mockChildProcess);
|
|
2045
|
+
|
|
2046
|
+
// Replace child_process.spawn with mock
|
|
2047
|
+
jest.mock('child_process', () => ({
|
|
2048
|
+
spawn: mockSpawn,
|
|
2049
|
+
}));
|
|
2050
|
+
});
|
|
2051
|
+
|
|
2052
|
+
afterEach(() => {
|
|
2053
|
+
jest.restoreAllMocks();
|
|
2054
|
+
});
|
|
2055
|
+
|
|
2056
|
+
test('should execute command successfully', async () => {
|
|
2057
|
+
// Arrange
|
|
2058
|
+
const expectedOutput = 'Hello, World!';
|
|
2059
|
+
|
|
2060
|
+
// Act
|
|
2061
|
+
const resultPromise = spawnCLI('echo', ['Hello, World!']);
|
|
2062
|
+
|
|
2063
|
+
// Simulate process execution
|
|
2064
|
+
setImmediate(() => {
|
|
2065
|
+
mockChildProcess.stdout?.emit('data', Buffer.from(expectedOutput));
|
|
2066
|
+
mockChildProcess.emit('close', 0, null);
|
|
2067
|
+
});
|
|
2068
|
+
|
|
2069
|
+
const result = await resultPromise;
|
|
2070
|
+
|
|
2071
|
+
// Assert
|
|
2072
|
+
expect(mockSpawn).toHaveBeenCalledWith('echo', ['Hello, World!'], expect.any(Object));
|
|
2073
|
+
expect(result.stdout).toBe(expectedOutput);
|
|
2074
|
+
expect(result.exitCode).toBe(0);
|
|
2075
|
+
});
|
|
2076
|
+
|
|
2077
|
+
test('should handle timeout', async () => {
|
|
2078
|
+
// Arrange
|
|
2079
|
+
jest.useFakeTimers();
|
|
2080
|
+
|
|
2081
|
+
// Act
|
|
2082
|
+
const resultPromise = spawnCLI('slow-command', [], { timeout: 1000 });
|
|
2083
|
+
|
|
2084
|
+
// Fast-forward time
|
|
2085
|
+
jest.advanceTimersByTime(1000);
|
|
2086
|
+
|
|
2087
|
+
// Assert
|
|
2088
|
+
await expect(resultPromise).rejects.toThrow('Process timed out');
|
|
2089
|
+
expect(mockChildProcess.kill).toHaveBeenCalledWith('SIGTERM');
|
|
2090
|
+
|
|
2091
|
+
jest.useRealTimers();
|
|
2092
|
+
});
|
|
2093
|
+
|
|
2094
|
+
test('should handle spawn errors', async () => {
|
|
2095
|
+
// Arrange
|
|
2096
|
+
const error = new Error('Command not found');
|
|
2097
|
+
(error as any).code = 'ENOENT';
|
|
2098
|
+
|
|
2099
|
+
// Act
|
|
2100
|
+
const resultPromise = spawnCLI('nonexistent-command', []);
|
|
2101
|
+
|
|
2102
|
+
// Simulate spawn error
|
|
2103
|
+
setImmediate(() => {
|
|
2104
|
+
mockChildProcess.emit('error', error);
|
|
2105
|
+
});
|
|
2106
|
+
|
|
2107
|
+
// Assert
|
|
2108
|
+
await expect(resultPromise).rejects.toThrow('Failed to spawn process');
|
|
2109
|
+
});
|
|
2110
|
+
|
|
2111
|
+
test('should handle non-zero exit codes', async () => {
|
|
2112
|
+
// Arrange
|
|
2113
|
+
const stderr = 'Error: Something went wrong';
|
|
2114
|
+
|
|
2115
|
+
// Act
|
|
2116
|
+
const resultPromise = spawnCLI('failing-command', []);
|
|
2117
|
+
|
|
2118
|
+
// Simulate process failure
|
|
2119
|
+
setImmediate(() => {
|
|
2120
|
+
mockChildProcess.stderr?.emit('data', Buffer.from(stderr));
|
|
2121
|
+
mockChildProcess.emit('close', 1, null);
|
|
2122
|
+
});
|
|
2123
|
+
|
|
2124
|
+
const result = await resultPromise;
|
|
2125
|
+
|
|
2126
|
+
// Assert
|
|
2127
|
+
expect(result.exitCode).toBe(1);
|
|
2128
|
+
expect(result.stderr).toBe(stderr);
|
|
2129
|
+
});
|
|
2130
|
+
|
|
2131
|
+
test('should handle buffer overflow', async () => {
|
|
2132
|
+
// Arrange
|
|
2133
|
+
const largeOutput = 'x'.repeat(11 * 1024 * 1024); // 11MB
|
|
2134
|
+
|
|
2135
|
+
// Act
|
|
2136
|
+
const resultPromise = spawnCLI('large-output-command', [], {
|
|
2137
|
+
maxBuffer: 10 * 1024 * 1024, // 10MB
|
|
2138
|
+
});
|
|
2139
|
+
|
|
2140
|
+
// Simulate large output
|
|
2141
|
+
setImmediate(() => {
|
|
2142
|
+
mockChildProcess.stdout?.emit('data', Buffer.from(largeOutput));
|
|
2143
|
+
});
|
|
2144
|
+
|
|
2145
|
+
// Assert
|
|
2146
|
+
await expect(resultPromise).rejects.toThrow('exceeded maxBuffer');
|
|
2147
|
+
expect(mockChildProcess.kill).toHaveBeenCalled();
|
|
2148
|
+
});
|
|
2149
|
+
});
|
|
2150
|
+
```
|
|
2151
|
+
|
|
2152
|
+
### Integration Testing with Real CLI Tools
|
|
2153
|
+
|
|
2154
|
+
```typescript
|
|
2155
|
+
// __tests__/integration/git-cli.integration.test.ts
|
|
2156
|
+
|
|
2157
|
+
/**
|
|
2158
|
+
* Integration tests with real git CLI
|
|
2159
|
+
* Use a temporary directory for isolation
|
|
2160
|
+
*/
|
|
2161
|
+
describe('Git CLI Integration', () => {
|
|
2162
|
+
let tempDir: string;
|
|
2163
|
+
|
|
2164
|
+
beforeEach(async () => {
|
|
2165
|
+
// Create temporary directory
|
|
2166
|
+
const { mkdtemp } = require('fs/promises');
|
|
2167
|
+
const { tmpdir } = require('os');
|
|
2168
|
+
const { join } = require('path');
|
|
2169
|
+
|
|
2170
|
+
tempDir = await mkdtemp(join(tmpdir(), 'git-test-'));
|
|
2171
|
+
|
|
2172
|
+
// Initialize git repo
|
|
2173
|
+
await safeExecute('git', ['init'], { cwd: tempDir });
|
|
2174
|
+
await safeExecute('git', ['config', 'user.name', 'Test User'], { cwd: tempDir });
|
|
2175
|
+
await safeExecute('git', ['config', 'user.email', 'test@example.com'], { cwd: tempDir });
|
|
2176
|
+
});
|
|
2177
|
+
|
|
2178
|
+
afterEach(async () => {
|
|
2179
|
+
// Clean up temporary directory
|
|
2180
|
+
const { rm } = require('fs/promises');
|
|
2181
|
+
await rm(tempDir, { recursive: true, force: true });
|
|
2182
|
+
});
|
|
2183
|
+
|
|
2184
|
+
test('should create commit successfully', async () => {
|
|
2185
|
+
// Arrange
|
|
2186
|
+
const { writeFile } = require('fs/promises');
|
|
2187
|
+
const { join } = require('path');
|
|
2188
|
+
|
|
2189
|
+
const testFile = join(tempDir, 'test.txt');
|
|
2190
|
+
await writeFile(testFile, 'Hello, World!');
|
|
2191
|
+
|
|
2192
|
+
// Act
|
|
2193
|
+
await safeExecute('git', ['add', 'test.txt'], { cwd: tempDir });
|
|
2194
|
+
await safeExecute('git', ['commit', '-m', 'Initial commit'], { cwd: tempDir });
|
|
2195
|
+
|
|
2196
|
+
// Assert
|
|
2197
|
+
const { stdout } = await safeExecute('git', ['log', '--oneline'], { cwd: tempDir });
|
|
2198
|
+
expect(stdout).toContain('Initial commit');
|
|
2199
|
+
});
|
|
2200
|
+
|
|
2201
|
+
test('should handle git errors gracefully', async () => {
|
|
2202
|
+
// Act & Assert
|
|
2203
|
+
await expect(
|
|
2204
|
+
safeExecute('git', ['commit', '-m', 'No changes'], { cwd: tempDir })
|
|
2205
|
+
).rejects.toThrow();
|
|
2206
|
+
});
|
|
2207
|
+
});
|
|
2208
|
+
```
|
|
2209
|
+
|
|
2210
|
+
### Snapshot Testing for CLI Outputs
|
|
2211
|
+
|
|
2212
|
+
```typescript
|
|
2213
|
+
// __tests__/snapshots/cli-outputs.test.ts
|
|
2214
|
+
|
|
2215
|
+
/**
|
|
2216
|
+
* Snapshot testing for consistent CLI outputs
|
|
2217
|
+
*/
|
|
2218
|
+
describe('CLI Output Snapshots', () => {
|
|
2219
|
+
test('gh issue view output matches snapshot', async () => {
|
|
2220
|
+
// Mock the CLI response
|
|
2221
|
+
const mockOutput = {
|
|
2222
|
+
number: 123,
|
|
2223
|
+
title: 'Example Issue',
|
|
2224
|
+
state: 'open',
|
|
2225
|
+
author: 'octocat',
|
|
2226
|
+
};
|
|
2227
|
+
|
|
2228
|
+
jest.spyOn(JSON, 'parse').mockReturnValue(mockOutput);
|
|
2229
|
+
|
|
2230
|
+
// Execute
|
|
2231
|
+
const result = await parseJSONOutput(
|
|
2232
|
+
'gh',
|
|
2233
|
+
['issue', 'view', '123', '--json', 'number,title,state,author'],
|
|
2234
|
+
z.any()
|
|
2235
|
+
);
|
|
2236
|
+
|
|
2237
|
+
// Assert against snapshot
|
|
2238
|
+
expect(result).toMatchSnapshot();
|
|
2239
|
+
});
|
|
2240
|
+
});
|
|
2241
|
+
```
|
|
2242
|
+
|
|
2243
|
+
### Property-Based Testing
|
|
2244
|
+
|
|
2245
|
+
```typescript
|
|
2246
|
+
import * as fc from 'fast-check';
|
|
2247
|
+
|
|
2248
|
+
/**
|
|
2249
|
+
* Property-based tests for argument validation
|
|
2250
|
+
*/
|
|
2251
|
+
describe('Argument Validation Properties', () => {
|
|
2252
|
+
test('sanitizeFilename should never contain directory traversal', () => {
|
|
2253
|
+
fc.assert(
|
|
2254
|
+
fc.property(fc.string(), (input) => {
|
|
2255
|
+
const sanitized = ArgumentValidator.sanitizeFilename(input);
|
|
2256
|
+
|
|
2257
|
+
// Should not contain ..
|
|
2258
|
+
expect(sanitized).not.toContain('..');
|
|
2259
|
+
|
|
2260
|
+
// Should only contain safe characters
|
|
2261
|
+
expect(sanitized).toMatch(/^[a-zA-Z0-9._-]*$/);
|
|
2262
|
+
|
|
2263
|
+
// Should not exceed 255 characters
|
|
2264
|
+
expect(sanitized.length).toBeLessThanOrEqual(255);
|
|
2265
|
+
})
|
|
2266
|
+
);
|
|
2267
|
+
});
|
|
2268
|
+
|
|
2269
|
+
test('detectShellInjection should catch all metacharacters', () => {
|
|
2270
|
+
fc.assert(
|
|
2271
|
+
fc.property(
|
|
2272
|
+
fc.constantFrom(...SHELL_METACHARACTERS),
|
|
2273
|
+
fc.string(),
|
|
2274
|
+
fc.string(),
|
|
2275
|
+
(metachar, before, after) => {
|
|
2276
|
+
const testString = before + metachar + after;
|
|
2277
|
+
expect(detectShellInjection(testString)).toBe(true);
|
|
2278
|
+
}
|
|
2279
|
+
)
|
|
2280
|
+
);
|
|
2281
|
+
});
|
|
2282
|
+
});
|
|
2283
|
+
```
|
|
2284
|
+
|
|
2285
|
+
---
|
|
2286
|
+
|
|
2287
|
+
## Production Examples
|
|
2288
|
+
|
|
2289
|
+
### GitHub CLI Integration
|
|
2290
|
+
|
|
2291
|
+
```typescript
|
|
2292
|
+
/**
|
|
2293
|
+
* Production-ready GitHub CLI wrapper
|
|
2294
|
+
*/
|
|
2295
|
+
class GitHubCLI {
|
|
2296
|
+
/**
|
|
2297
|
+
* Check if gh CLI is installed and authenticated
|
|
2298
|
+
*/
|
|
2299
|
+
static async checkAvailability(): Promise<boolean> {
|
|
2300
|
+
try {
|
|
2301
|
+
await safeExecute('gh', ['auth', 'status'], { timeout: 5000 });
|
|
2302
|
+
return true;
|
|
2303
|
+
} catch (error) {
|
|
2304
|
+
if (error instanceof CLIError) {
|
|
2305
|
+
if (error.type === CLIErrorType.COMMAND_NOT_FOUND) {
|
|
2306
|
+
throw new Error('GitHub CLI (gh) is not installed. Install from https://cli.github.com');
|
|
2307
|
+
}
|
|
2308
|
+
if (error.type === CLIErrorType.AUTH_REQUIRED) {
|
|
2309
|
+
throw new Error('GitHub CLI is not authenticated. Run: gh auth login');
|
|
2310
|
+
}
|
|
2311
|
+
}
|
|
2312
|
+
return false;
|
|
2313
|
+
}
|
|
2314
|
+
}
|
|
2315
|
+
|
|
2316
|
+
/**
|
|
2317
|
+
* Create an issue
|
|
2318
|
+
*/
|
|
2319
|
+
static async createIssue(options: {
|
|
2320
|
+
title: string;
|
|
2321
|
+
body: string;
|
|
2322
|
+
labels?: string[];
|
|
2323
|
+
assignees?: string[];
|
|
2324
|
+
}): Promise<{ number: number; url: string }> {
|
|
2325
|
+
const args = [
|
|
2326
|
+
'issue',
|
|
2327
|
+
'create',
|
|
2328
|
+
'--title',
|
|
2329
|
+
options.title,
|
|
2330
|
+
'--body',
|
|
2331
|
+
options.body,
|
|
2332
|
+
'--json',
|
|
2333
|
+
'number,url',
|
|
2334
|
+
];
|
|
2335
|
+
|
|
2336
|
+
if (options.labels) {
|
|
2337
|
+
args.push('--label', options.labels.join(','));
|
|
2338
|
+
}
|
|
2339
|
+
|
|
2340
|
+
if (options.assignees) {
|
|
2341
|
+
args.push('--assignee', options.assignees.join(','));
|
|
2342
|
+
}
|
|
2343
|
+
|
|
2344
|
+
const result = await executeWithRetry('gh', args, {}, {
|
|
2345
|
+
maxRetries: 3,
|
|
2346
|
+
shouldRetry: (error) => error.type === CLIErrorType.RATE_LIMITED,
|
|
2347
|
+
});
|
|
2348
|
+
|
|
2349
|
+
const parsed = JSON.parse(result.stdout);
|
|
2350
|
+
return {
|
|
2351
|
+
number: parsed.number,
|
|
2352
|
+
url: parsed.url,
|
|
2353
|
+
};
|
|
2354
|
+
}
|
|
2355
|
+
|
|
2356
|
+
/**
|
|
2357
|
+
* List pull requests
|
|
2358
|
+
*/
|
|
2359
|
+
static async listPullRequests(options: {
|
|
2360
|
+
state?: 'open' | 'closed' | 'all';
|
|
2361
|
+
limit?: number;
|
|
2362
|
+
} = {}): Promise<Array<{ number: number; title: string; state: string }>> {
|
|
2363
|
+
const args = [
|
|
2364
|
+
'pr',
|
|
2365
|
+
'list',
|
|
2366
|
+
'--json',
|
|
2367
|
+
'number,title,state',
|
|
2368
|
+
'--limit',
|
|
2369
|
+
(options.limit || 30).toString(),
|
|
2370
|
+
];
|
|
2371
|
+
|
|
2372
|
+
if (options.state) {
|
|
2373
|
+
args.push('--state', options.state);
|
|
2374
|
+
}
|
|
2375
|
+
|
|
2376
|
+
const result = await executeWithErrorHandling('gh', args);
|
|
2377
|
+
return JSON.parse(result.stdout);
|
|
2378
|
+
}
|
|
2379
|
+
}
|
|
2380
|
+
|
|
2381
|
+
// Example usage
|
|
2382
|
+
async function exampleGitHubWorkflow() {
|
|
2383
|
+
// Check availability
|
|
2384
|
+
await GitHubCLI.checkAvailability();
|
|
2385
|
+
|
|
2386
|
+
// Create issue
|
|
2387
|
+
const issue = await GitHubCLI.createIssue({
|
|
2388
|
+
title: 'Bug: CLI integration failing',
|
|
2389
|
+
body: 'Detailed description of the bug...',
|
|
2390
|
+
labels: ['bug', 'cli'],
|
|
2391
|
+
assignees: ['octocat'],
|
|
2392
|
+
});
|
|
2393
|
+
|
|
2394
|
+
console.log(`Created issue #${issue.number}: ${issue.url}`);
|
|
2395
|
+
|
|
2396
|
+
// List PRs
|
|
2397
|
+
const prs = await GitHubCLI.listPullRequests({ state: 'open', limit: 10 });
|
|
2398
|
+
console.log(`Found ${prs.length} open PRs`);
|
|
2399
|
+
}
|
|
2400
|
+
```
|
|
2401
|
+
|
|
2402
|
+
### Git CLI Integration
|
|
2403
|
+
|
|
2404
|
+
```typescript
|
|
2405
|
+
/**
|
|
2406
|
+
* Production-ready Git CLI wrapper
|
|
2407
|
+
*/
|
|
2408
|
+
class GitCLI {
|
|
2409
|
+
constructor(private readonly repoPath: string) {}
|
|
2410
|
+
|
|
2411
|
+
/**
|
|
2412
|
+
* Get current branch name
|
|
2413
|
+
*/
|
|
2414
|
+
async getCurrentBranch(): Promise<string> {
|
|
2415
|
+
const result = await safeExecute('git', ['rev-parse', '--abbrev-ref', 'HEAD'], {
|
|
2416
|
+
cwd: this.repoPath,
|
|
2417
|
+
});
|
|
2418
|
+
return result.stdout.trim();
|
|
2419
|
+
}
|
|
2420
|
+
|
|
2421
|
+
/**
|
|
2422
|
+
* Get commit history
|
|
2423
|
+
*/
|
|
2424
|
+
async getLog(options: { limit?: number; format?: string } = {}): Promise<GitCommit[]> {
|
|
2425
|
+
const args = [
|
|
2426
|
+
'log',
|
|
2427
|
+
'--pretty=format:%H|%an|%ae|%ad|%s',
|
|
2428
|
+
'--date=iso',
|
|
2429
|
+
];
|
|
2430
|
+
|
|
2431
|
+
if (options.limit) {
|
|
2432
|
+
args.push('-n', options.limit.toString());
|
|
2433
|
+
}
|
|
2434
|
+
|
|
2435
|
+
const result = await safeExecute('git', args, {
|
|
2436
|
+
cwd: this.repoPath,
|
|
2437
|
+
});
|
|
2438
|
+
|
|
2439
|
+
return result.stdout.split('\n').map((line) => {
|
|
2440
|
+
const [hash, authorName, authorEmail, date, message] = line.split('|');
|
|
2441
|
+
return { hash, authorName, authorEmail, date, message };
|
|
2442
|
+
});
|
|
2443
|
+
}
|
|
2444
|
+
|
|
2445
|
+
/**
|
|
2446
|
+
* Create commit
|
|
2447
|
+
*/
|
|
2448
|
+
async commit(message: string): Promise<string> {
|
|
2449
|
+
const result = await safeExecute('git', ['commit', '-m', message], {
|
|
2450
|
+
cwd: this.repoPath,
|
|
2451
|
+
});
|
|
2452
|
+
|
|
2453
|
+
// Extract commit hash from output
|
|
2454
|
+
const match = result.stdout.match(/\[.+ ([a-f0-9]+)\]/);
|
|
2455
|
+
return match ? match[1] : '';
|
|
2456
|
+
}
|
|
2457
|
+
|
|
2458
|
+
/**
|
|
2459
|
+
* Check if repo is clean
|
|
2460
|
+
*/
|
|
2461
|
+
async isClean(): Promise<boolean> {
|
|
2462
|
+
const result = await safeExecute('git', ['status', '--porcelain'], {
|
|
2463
|
+
cwd: this.repoPath,
|
|
2464
|
+
});
|
|
2465
|
+
return result.stdout.trim() === '';
|
|
2466
|
+
}
|
|
2467
|
+
}
|
|
2468
|
+
```
|
|
2469
|
+
|
|
2470
|
+
### Claude CLI Integration (Example)
|
|
2471
|
+
|
|
2472
|
+
```typescript
|
|
2473
|
+
/**
|
|
2474
|
+
* Example Claude CLI wrapper
|
|
2475
|
+
* (Adjust based on actual Claude CLI interface)
|
|
2476
|
+
*/
|
|
2477
|
+
class ClaudeCLI {
|
|
2478
|
+
/**
|
|
2479
|
+
* Generate text completion
|
|
2480
|
+
*/
|
|
2481
|
+
static async generateCompletion(options: {
|
|
2482
|
+
prompt: string;
|
|
2483
|
+
model?: string;
|
|
2484
|
+
maxTokens?: number;
|
|
2485
|
+
temperature?: number;
|
|
2486
|
+
}): Promise<{ content: string; tokens: number }> {
|
|
2487
|
+
const args = ['generate', '--prompt', options.prompt];
|
|
2488
|
+
|
|
2489
|
+
if (options.model) {
|
|
2490
|
+
args.push('--model', options.model);
|
|
2491
|
+
}
|
|
2492
|
+
|
|
2493
|
+
if (options.maxTokens) {
|
|
2494
|
+
args.push('--max-tokens', options.maxTokens.toString());
|
|
2495
|
+
}
|
|
2496
|
+
|
|
2497
|
+
if (options.temperature) {
|
|
2498
|
+
args.push('--temperature', options.temperature.toString());
|
|
2499
|
+
}
|
|
2500
|
+
|
|
2501
|
+
// Add JSON output flag
|
|
2502
|
+
args.push('--output', 'json');
|
|
2503
|
+
|
|
2504
|
+
// Use longer timeout for AI generation
|
|
2505
|
+
const timeout = getAdaptiveTimeout({
|
|
2506
|
+
type: 'generate',
|
|
2507
|
+
estimatedTokens: options.maxTokens || 1000,
|
|
2508
|
+
complexity: 'high',
|
|
2509
|
+
});
|
|
2510
|
+
|
|
2511
|
+
const result = await executeWithRetry(
|
|
2512
|
+
'claude',
|
|
2513
|
+
args,
|
|
2514
|
+
{ timeout },
|
|
2515
|
+
{
|
|
2516
|
+
maxRetries: 2,
|
|
2517
|
+
shouldRetry: (error) =>
|
|
2518
|
+
error.type === CLIErrorType.RATE_LIMITED ||
|
|
2519
|
+
error.type === CLIErrorType.NETWORK_ERROR,
|
|
2520
|
+
}
|
|
2521
|
+
);
|
|
2522
|
+
|
|
2523
|
+
const parsed = JSON.parse(result.stdout);
|
|
2524
|
+
return {
|
|
2525
|
+
content: parsed.content,
|
|
2526
|
+
tokens: parsed.usage.total_tokens,
|
|
2527
|
+
};
|
|
2528
|
+
}
|
|
2529
|
+
|
|
2530
|
+
/**
|
|
2531
|
+
* Stream generation with real-time output
|
|
2532
|
+
*/
|
|
2533
|
+
static async streamCompletion(
|
|
2534
|
+
prompt: string,
|
|
2535
|
+
onChunk: (chunk: string) => void
|
|
2536
|
+
): Promise<void> {
|
|
2537
|
+
await spawnWithStreaming(
|
|
2538
|
+
'claude',
|
|
2539
|
+
['generate', '--stream', '--prompt', prompt],
|
|
2540
|
+
{
|
|
2541
|
+
onStdout: onChunk,
|
|
2542
|
+
onStderr: (error) => {
|
|
2543
|
+
console.error('Claude error:', error);
|
|
2544
|
+
},
|
|
2545
|
+
}
|
|
2546
|
+
);
|
|
2547
|
+
}
|
|
2548
|
+
}
|
|
2549
|
+
```
|
|
2550
|
+
|
|
2551
|
+
---
|
|
2552
|
+
|
|
2553
|
+
## Common Pitfalls
|
|
2554
|
+
|
|
2555
|
+
### 1. Shell Injection (CRITICAL)
|
|
2556
|
+
|
|
2557
|
+
```typescript
|
|
2558
|
+
// ❌ DANGEROUS - Never do this!
|
|
2559
|
+
async function dangerousExample(userInput: string) {
|
|
2560
|
+
const { exec } = require('child_process');
|
|
2561
|
+
|
|
2562
|
+
// User could input: "; rm -rf /"
|
|
2563
|
+
exec(`echo ${userInput}`);
|
|
2564
|
+
}
|
|
2565
|
+
|
|
2566
|
+
// ✅ SAFE - Use spawn with argument array
|
|
2567
|
+
async function safeExample(userInput: string) {
|
|
2568
|
+
await spawnCLI('echo', [userInput]);
|
|
2569
|
+
}
|
|
2570
|
+
```
|
|
2571
|
+
|
|
2572
|
+
### 2. Buffer Overflow
|
|
2573
|
+
|
|
2574
|
+
```typescript
|
|
2575
|
+
// ❌ WRONG - Default maxBuffer (1MB) may be too small
|
|
2576
|
+
async function bufferOverflow() {
|
|
2577
|
+
await spawnCLI('cat', ['large-file.txt']); // May crash!
|
|
2578
|
+
}
|
|
2579
|
+
|
|
2580
|
+
// ✅ CORRECT - Use streaming or increase buffer
|
|
2581
|
+
async function handleLargeFile() {
|
|
2582
|
+
// Option 1: Stream the output
|
|
2583
|
+
await handleLargeOutput('cat', ['large-file.txt'], (chunk) => {
|
|
2584
|
+
console.log(chunk);
|
|
2585
|
+
});
|
|
2586
|
+
|
|
2587
|
+
// Option 2: Increase buffer
|
|
2588
|
+
await spawnCLI('cat', ['large-file.txt'], {
|
|
2589
|
+
maxBuffer: 100 * 1024 * 1024, // 100MB
|
|
2590
|
+
});
|
|
2591
|
+
}
|
|
2592
|
+
```
|
|
2593
|
+
|
|
2594
|
+
### 3. Hanging Processes
|
|
2595
|
+
|
|
2596
|
+
```typescript
|
|
2597
|
+
// ❌ WRONG - No timeout, process may hang forever
|
|
2598
|
+
async function hangingProcess() {
|
|
2599
|
+
await spawnCLI('some-unreliable-command', []);
|
|
2600
|
+
}
|
|
2601
|
+
|
|
2602
|
+
// ✅ CORRECT - Always set timeout
|
|
2603
|
+
async function timeoutProtected() {
|
|
2604
|
+
await spawnCLI('some-unreliable-command', [], {
|
|
2605
|
+
timeout: 30000, // 30 seconds
|
|
2606
|
+
});
|
|
2607
|
+
}
|
|
2608
|
+
```
|
|
2609
|
+
|
|
2610
|
+
### 4. Environment Variable Leakage
|
|
2611
|
+
|
|
2612
|
+
```typescript
|
|
2613
|
+
// ❌ DANGEROUS - Exposes all env vars to child process
|
|
2614
|
+
async function envLeakage() {
|
|
2615
|
+
await spawnCLI('untrusted-command', [], {
|
|
2616
|
+
env: process.env, // All secrets exposed!
|
|
2617
|
+
});
|
|
2618
|
+
}
|
|
2619
|
+
|
|
2620
|
+
// ✅ SAFE - Minimal environment
|
|
2621
|
+
async function minimalEnv() {
|
|
2622
|
+
await executeWithManagedEnv('untrusted-command', [], {
|
|
2623
|
+
allowedVars: ['PATH'],
|
|
2624
|
+
});
|
|
2625
|
+
}
|
|
2626
|
+
```
|
|
2627
|
+
|
|
2628
|
+
### 5. Race Conditions
|
|
2629
|
+
|
|
2630
|
+
```typescript
|
|
2631
|
+
// ❌ WRONG - Multiple writes may race
|
|
2632
|
+
async function racyWrites() {
|
|
2633
|
+
const child = spawn('command', []);
|
|
2634
|
+
child.stdin?.write('first\n');
|
|
2635
|
+
child.stdin?.write('second\n'); // May arrive out of order
|
|
2636
|
+
child.stdin?.end();
|
|
2637
|
+
}
|
|
2638
|
+
|
|
2639
|
+
// ✅ CORRECT - Wait for drain events
|
|
2640
|
+
async function sequentialWrites() {
|
|
2641
|
+
const child = spawn('command', []);
|
|
2642
|
+
|
|
2643
|
+
await new Promise<void>((resolve) => {
|
|
2644
|
+
child.stdin?.write('first\n', () => {
|
|
2645
|
+
child.stdin?.write('second\n', () => {
|
|
2646
|
+
child.stdin?.end();
|
|
2647
|
+
resolve();
|
|
2648
|
+
});
|
|
2649
|
+
});
|
|
2650
|
+
});
|
|
2651
|
+
}
|
|
2652
|
+
```
|
|
2653
|
+
|
|
2654
|
+
### 6. Platform-Specific Behavior
|
|
2655
|
+
|
|
2656
|
+
```typescript
|
|
2657
|
+
// ❌ WRONG - Assumes Unix paths
|
|
2658
|
+
async function unixOnly() {
|
|
2659
|
+
await spawnCLI('cat', ['/tmp/file.txt']); // Breaks on Windows
|
|
2660
|
+
}
|
|
2661
|
+
|
|
2662
|
+
// ✅ CORRECT - Use path module for cross-platform paths
|
|
2663
|
+
import { join, resolve } from 'path';
|
|
2664
|
+
import { tmpdir } from 'os';
|
|
2665
|
+
|
|
2666
|
+
async function crossPlatform() {
|
|
2667
|
+
const filePath = join(tmpdir(), 'file.txt');
|
|
2668
|
+
await spawnCLI('cat', [filePath]);
|
|
2669
|
+
}
|
|
2670
|
+
```
|
|
2671
|
+
|
|
2672
|
+
### 7. Ignoring stderr
|
|
2673
|
+
|
|
2674
|
+
```typescript
|
|
2675
|
+
// ❌ WRONG - Only checking stdout
|
|
2676
|
+
async function ignoringErrors() {
|
|
2677
|
+
const result = await spawnCLI('command', []);
|
|
2678
|
+
console.log(result.stdout); // stderr may contain important warnings!
|
|
2679
|
+
}
|
|
2680
|
+
|
|
2681
|
+
// ✅ CORRECT - Check both stdout and stderr
|
|
2682
|
+
async function checkingBoth() {
|
|
2683
|
+
const result = await spawnCLI('command', []);
|
|
2684
|
+
|
|
2685
|
+
if (result.stderr) {
|
|
2686
|
+
console.warn('Warnings/Errors:', result.stderr);
|
|
2687
|
+
}
|
|
2688
|
+
|
|
2689
|
+
console.log('Output:', result.stdout);
|
|
2690
|
+
}
|
|
2691
|
+
```
|
|
2692
|
+
|
|
2693
|
+
---
|
|
2694
|
+
|
|
2695
|
+
## Platform Differences
|
|
2696
|
+
|
|
2697
|
+
### Windows vs Unix/Linux/macOS
|
|
2698
|
+
|
|
2699
|
+
```typescript
|
|
2700
|
+
/**
|
|
2701
|
+
* Platform detection and handling
|
|
2702
|
+
*/
|
|
2703
|
+
class PlatformAdapter {
|
|
2704
|
+
static readonly isWindows = process.platform === 'win32';
|
|
2705
|
+
static readonly isMac = process.platform === 'darwin';
|
|
2706
|
+
static readonly isLinux = process.platform === 'linux';
|
|
2707
|
+
|
|
2708
|
+
/**
|
|
2709
|
+
* Get shell for current platform
|
|
2710
|
+
*/
|
|
2711
|
+
static getShell(): string {
|
|
2712
|
+
if (this.isWindows) {
|
|
2713
|
+
return process.env.COMSPEC || 'cmd.exe';
|
|
2714
|
+
}
|
|
2715
|
+
return process.env.SHELL || '/bin/sh';
|
|
2716
|
+
}
|
|
2717
|
+
|
|
2718
|
+
/**
|
|
2719
|
+
* Execute script with appropriate shell
|
|
2720
|
+
*/
|
|
2721
|
+
static async executeScript(
|
|
2722
|
+
scriptContent: string,
|
|
2723
|
+
extension: '.sh' | '.bat' | '.ps1'
|
|
2724
|
+
): Promise<CLIResult> {
|
|
2725
|
+
if (this.isWindows) {
|
|
2726
|
+
if (extension === '.ps1') {
|
|
2727
|
+
// PowerShell script
|
|
2728
|
+
return spawnCLI('powershell.exe', ['-Command', scriptContent]);
|
|
2729
|
+
} else {
|
|
2730
|
+
// Batch script
|
|
2731
|
+
return spawnCLI('cmd.exe', ['/c', scriptContent]);
|
|
2732
|
+
}
|
|
2733
|
+
} else {
|
|
2734
|
+
// Unix shell script
|
|
2735
|
+
return spawnCLI('/bin/sh', ['-c', scriptContent]);
|
|
2736
|
+
}
|
|
2737
|
+
}
|
|
2738
|
+
|
|
2739
|
+
/**
|
|
2740
|
+
* Convert command for platform
|
|
2741
|
+
*/
|
|
2742
|
+
static adaptCommand(command: string): { cmd: string; args: string[] } {
|
|
2743
|
+
if (this.isWindows) {
|
|
2744
|
+
// Windows may need .exe extension
|
|
2745
|
+
const windowsCommands: Record<string, string> = {
|
|
2746
|
+
node: 'node.exe',
|
|
2747
|
+
npm: 'npm.cmd',
|
|
2748
|
+
git: 'git.exe',
|
|
2749
|
+
};
|
|
2750
|
+
|
|
2751
|
+
const adapted = windowsCommands[command] || command;
|
|
2752
|
+
return { cmd: adapted, args: [] };
|
|
2753
|
+
}
|
|
2754
|
+
|
|
2755
|
+
return { cmd: command, args: [] };
|
|
2756
|
+
}
|
|
2757
|
+
|
|
2758
|
+
/**
|
|
2759
|
+
* Get appropriate path separator
|
|
2760
|
+
*/
|
|
2761
|
+
static getPathSeparator(): string {
|
|
2762
|
+
return this.isWindows ? ';' : ':';
|
|
2763
|
+
}
|
|
2764
|
+
|
|
2765
|
+
/**
|
|
2766
|
+
* Normalize path for platform
|
|
2767
|
+
*/
|
|
2768
|
+
static normalizePath(path: string): string {
|
|
2769
|
+
if (this.isWindows) {
|
|
2770
|
+
return path.replace(/\//g, '\\');
|
|
2771
|
+
}
|
|
2772
|
+
return path.replace(/\\/g, '/');
|
|
2773
|
+
}
|
|
2774
|
+
}
|
|
2775
|
+
|
|
2776
|
+
/**
|
|
2777
|
+
* Cross-platform command execution
|
|
2778
|
+
*/
|
|
2779
|
+
async function executeCrossPlatform(
|
|
2780
|
+
command: string,
|
|
2781
|
+
args: string[]
|
|
2782
|
+
): Promise<CLIResult> {
|
|
2783
|
+
const { cmd } = PlatformAdapter.adaptCommand(command);
|
|
2784
|
+
|
|
2785
|
+
// Use cross-spawn for better Windows compatibility
|
|
2786
|
+
const crossSpawn = require('cross-spawn');
|
|
2787
|
+
|
|
2788
|
+
return new Promise((resolve, reject) => {
|
|
2789
|
+
const child = crossSpawn(cmd, args, {
|
|
2790
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
2791
|
+
});
|
|
2792
|
+
|
|
2793
|
+
let stdout = '';
|
|
2794
|
+
let stderr = '';
|
|
2795
|
+
|
|
2796
|
+
child.stdout?.on('data', (chunk: Buffer) => {
|
|
2797
|
+
stdout += chunk.toString('utf8');
|
|
2798
|
+
});
|
|
2799
|
+
|
|
2800
|
+
child.stderr?.on('data', (chunk: Buffer) => {
|
|
2801
|
+
stderr += chunk.toString('utf8');
|
|
2802
|
+
});
|
|
2803
|
+
|
|
2804
|
+
child.on('close', (exitCode: number, signal: NodeJS.Signals) => {
|
|
2805
|
+
resolve({ stdout, stderr, exitCode, signal });
|
|
2806
|
+
});
|
|
2807
|
+
|
|
2808
|
+
child.on('error', reject);
|
|
2809
|
+
});
|
|
2810
|
+
}
|
|
2811
|
+
```
|
|
2812
|
+
|
|
2813
|
+
### Handling Line Endings
|
|
2814
|
+
|
|
2815
|
+
```typescript
|
|
2816
|
+
/**
|
|
2817
|
+
* Normalize line endings across platforms
|
|
2818
|
+
*/
|
|
2819
|
+
function normalizeLineEndings(text: string): string {
|
|
2820
|
+
// Convert all line endings to \n
|
|
2821
|
+
return text.replace(/\r\n/g, '\n').replace(/\r/g, '\n');
|
|
2822
|
+
}
|
|
2823
|
+
|
|
2824
|
+
/**
|
|
2825
|
+
* Convert to platform-specific line endings
|
|
2826
|
+
*/
|
|
2827
|
+
function toPlatformLineEndings(text: string): string {
|
|
2828
|
+
const normalized = normalizeLineEndings(text);
|
|
2829
|
+
|
|
2830
|
+
if (PlatformAdapter.isWindows) {
|
|
2831
|
+
return normalized.replace(/\n/g, '\r\n');
|
|
2832
|
+
}
|
|
2833
|
+
|
|
2834
|
+
return normalized;
|
|
2835
|
+
}
|
|
2836
|
+
```
|
|
2837
|
+
|
|
2838
|
+
---
|
|
2839
|
+
|
|
2840
|
+
## Performance Optimization
|
|
2841
|
+
|
|
2842
|
+
### Process Pooling for Repeated Calls
|
|
2843
|
+
|
|
2844
|
+
```typescript
|
|
2845
|
+
/**
|
|
2846
|
+
* Process pool for reusing long-running processes
|
|
2847
|
+
* Useful for CLI tools with high startup cost
|
|
2848
|
+
*/
|
|
2849
|
+
class ProcessPool {
|
|
2850
|
+
private processes: ChildProcess[] = [];
|
|
2851
|
+
private queue: Array<{
|
|
2852
|
+
args: string[];
|
|
2853
|
+
resolve: (result: CLIResult) => void;
|
|
2854
|
+
reject: (error: Error) => void;
|
|
2855
|
+
}> = [];
|
|
2856
|
+
|
|
2857
|
+
constructor(
|
|
2858
|
+
private readonly command: string,
|
|
2859
|
+
private readonly poolSize: number = 3
|
|
2860
|
+
) {}
|
|
2861
|
+
|
|
2862
|
+
/**
|
|
2863
|
+
* Execute command using pooled process
|
|
2864
|
+
*/
|
|
2865
|
+
async execute(args: string[]): Promise<CLIResult> {
|
|
2866
|
+
return new Promise((resolve, reject) => {
|
|
2867
|
+
this.queue.push({ args, resolve, reject });
|
|
2868
|
+
this.processQueue();
|
|
2869
|
+
});
|
|
2870
|
+
}
|
|
2871
|
+
|
|
2872
|
+
private processQueue(): void {
|
|
2873
|
+
if (this.queue.length === 0) return;
|
|
2874
|
+
if (this.processes.length >= this.poolSize) return;
|
|
2875
|
+
|
|
2876
|
+
const job = this.queue.shift();
|
|
2877
|
+
if (!job) return;
|
|
2878
|
+
|
|
2879
|
+
const child = spawn(this.command, job.args, {
|
|
2880
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
2881
|
+
shell: false,
|
|
2882
|
+
});
|
|
2883
|
+
|
|
2884
|
+
this.processes.push(child);
|
|
2885
|
+
|
|
2886
|
+
let stdout = '';
|
|
2887
|
+
let stderr = '';
|
|
2888
|
+
|
|
2889
|
+
child.stdout?.on('data', (chunk) => {
|
|
2890
|
+
stdout += chunk.toString('utf8');
|
|
2891
|
+
});
|
|
2892
|
+
|
|
2893
|
+
child.stderr?.on('data', (chunk) => {
|
|
2894
|
+
stderr += chunk.toString('utf8');
|
|
2895
|
+
});
|
|
2896
|
+
|
|
2897
|
+
child.on('close', (exitCode, signal) => {
|
|
2898
|
+
// Remove from pool
|
|
2899
|
+
this.processes = this.processes.filter((p) => p !== child);
|
|
2900
|
+
|
|
2901
|
+
job.resolve({ stdout, stderr, exitCode, signal });
|
|
2902
|
+
|
|
2903
|
+
// Process next job
|
|
2904
|
+
this.processQueue();
|
|
2905
|
+
});
|
|
2906
|
+
|
|
2907
|
+
child.on('error', (error) => {
|
|
2908
|
+
this.processes = this.processes.filter((p) => p !== child);
|
|
2909
|
+
job.reject(error);
|
|
2910
|
+
this.processQueue();
|
|
2911
|
+
});
|
|
2912
|
+
}
|
|
2913
|
+
|
|
2914
|
+
/**
|
|
2915
|
+
* Cleanup all processes
|
|
2916
|
+
*/
|
|
2917
|
+
async cleanup(): Promise<void> {
|
|
2918
|
+
for (const process of this.processes) {
|
|
2919
|
+
process.kill('SIGTERM');
|
|
2920
|
+
}
|
|
2921
|
+
this.processes = [];
|
|
2922
|
+
this.queue = [];
|
|
2923
|
+
}
|
|
2924
|
+
}
|
|
2925
|
+
|
|
2926
|
+
// Example usage
|
|
2927
|
+
const gitPool = new ProcessPool('git', 5);
|
|
2928
|
+
|
|
2929
|
+
async function parallelGitOperations() {
|
|
2930
|
+
const results = await Promise.all([
|
|
2931
|
+
gitPool.execute(['status']),
|
|
2932
|
+
gitPool.execute(['log', '-1']),
|
|
2933
|
+
gitPool.execute(['branch', '-a']),
|
|
2934
|
+
]);
|
|
2935
|
+
|
|
2936
|
+
console.log('All git operations completed:', results);
|
|
2937
|
+
}
|
|
2938
|
+
```
|
|
2939
|
+
|
|
2940
|
+
### Caching CLI Results
|
|
2941
|
+
|
|
2942
|
+
```typescript
|
|
2943
|
+
/**
|
|
2944
|
+
* Cache CLI results to avoid redundant executions
|
|
2945
|
+
*/
|
|
2946
|
+
class CLICache {
|
|
2947
|
+
private cache = new Map<string, { result: CLIResult; timestamp: number }>();
|
|
2948
|
+
|
|
2949
|
+
constructor(private readonly ttlMs: number = 60000) {} // 1 minute default
|
|
2950
|
+
|
|
2951
|
+
/**
|
|
2952
|
+
* Get cache key for command
|
|
2953
|
+
*/
|
|
2954
|
+
private getCacheKey(command: string, args: string[]): string {
|
|
2955
|
+
return `${command}:${args.join(':')}`;
|
|
2956
|
+
}
|
|
2957
|
+
|
|
2958
|
+
/**
|
|
2959
|
+
* Execute with caching
|
|
2960
|
+
*/
|
|
2961
|
+
async execute(
|
|
2962
|
+
command: string,
|
|
2963
|
+
args: string[],
|
|
2964
|
+
options: SpawnOptions = {}
|
|
2965
|
+
): Promise<CLIResult> {
|
|
2966
|
+
const key = this.getCacheKey(command, args);
|
|
2967
|
+
const cached = this.cache.get(key);
|
|
2968
|
+
|
|
2969
|
+
// Return cached if still valid
|
|
2970
|
+
if (cached && Date.now() - cached.timestamp < this.ttlMs) {
|
|
2971
|
+
console.debug('Cache hit:', key);
|
|
2972
|
+
return cached.result;
|
|
2973
|
+
}
|
|
2974
|
+
|
|
2975
|
+
// Execute and cache
|
|
2976
|
+
console.debug('Cache miss:', key);
|
|
2977
|
+
const result = await spawnCLI(command, args, options);
|
|
2978
|
+
|
|
2979
|
+
this.cache.set(key, {
|
|
2980
|
+
result,
|
|
2981
|
+
timestamp: Date.now(),
|
|
2982
|
+
});
|
|
2983
|
+
|
|
2984
|
+
return result;
|
|
2985
|
+
}
|
|
2986
|
+
|
|
2987
|
+
/**
|
|
2988
|
+
* Clear cache
|
|
2989
|
+
*/
|
|
2990
|
+
clear(): void {
|
|
2991
|
+
this.cache.clear();
|
|
2992
|
+
}
|
|
2993
|
+
|
|
2994
|
+
/**
|
|
2995
|
+
* Invalidate specific command
|
|
2996
|
+
*/
|
|
2997
|
+
invalidate(command: string, args?: string[]): void {
|
|
2998
|
+
if (args) {
|
|
2999
|
+
const key = this.getCacheKey(command, args);
|
|
3000
|
+
this.cache.delete(key);
|
|
3001
|
+
} else {
|
|
3002
|
+
// Invalidate all for this command
|
|
3003
|
+
for (const key of this.cache.keys()) {
|
|
3004
|
+
if (key.startsWith(`${command}:`)) {
|
|
3005
|
+
this.cache.delete(key);
|
|
3006
|
+
}
|
|
3007
|
+
}
|
|
3008
|
+
}
|
|
3009
|
+
}
|
|
3010
|
+
}
|
|
3011
|
+
|
|
3012
|
+
// Example usage
|
|
3013
|
+
const cache = new CLICache(5 * 60 * 1000); // 5 minute TTL
|
|
3014
|
+
|
|
3015
|
+
async function cachedGitStatus() {
|
|
3016
|
+
// First call executes git
|
|
3017
|
+
const result1 = await cache.execute('git', ['status']);
|
|
3018
|
+
|
|
3019
|
+
// Second call returns cached result
|
|
3020
|
+
const result2 = await cache.execute('git', ['status']);
|
|
3021
|
+
|
|
3022
|
+
// After mutation, invalidate cache
|
|
3023
|
+
await spawnCLI('git', ['add', 'file.txt']);
|
|
3024
|
+
cache.invalidate('git', ['status']);
|
|
3025
|
+
}
|
|
3026
|
+
```
|
|
3027
|
+
|
|
3028
|
+
---
|
|
3029
|
+
|
|
3030
|
+
## Best Practices Summary
|
|
3031
|
+
|
|
3032
|
+
### Security Checklist
|
|
3033
|
+
|
|
3034
|
+
- ✅ **ALWAYS** use `spawn()` or `execFile()` with argument arrays
|
|
3035
|
+
- ✅ **NEVER** use `exec()` with user input
|
|
3036
|
+
- ✅ **ALWAYS** validate and sanitize user input
|
|
3037
|
+
- ✅ **NEVER** trust user input in command construction
|
|
3038
|
+
- ✅ **ALWAYS** use minimal environment variables
|
|
3039
|
+
- ✅ **NEVER** pass sensitive env vars to untrusted processes
|
|
3040
|
+
- ✅ **ALWAYS** validate file paths against directory traversal
|
|
3041
|
+
- ✅ **NEVER** concatenate user input into commands
|
|
3042
|
+
|
|
3043
|
+
### Reliability Checklist
|
|
3044
|
+
|
|
3045
|
+
- ✅ **ALWAYS** set timeouts for CLI operations
|
|
3046
|
+
- ✅ **ALWAYS** handle buffer overflow scenarios
|
|
3047
|
+
- ✅ **ALWAYS** implement retry logic for transient errors
|
|
3048
|
+
- ✅ **ALWAYS** classify errors (auth, network, etc.)
|
|
3049
|
+
- ✅ **ALWAYS** check exit codes and stderr
|
|
3050
|
+
- ✅ **ALWAYS** clean up child processes on exit
|
|
3051
|
+
- ✅ **ALWAYS** test cross-platform compatibility
|
|
3052
|
+
|
|
3053
|
+
### Performance Checklist
|
|
3054
|
+
|
|
3055
|
+
- ✅ Use streaming for large outputs
|
|
3056
|
+
- ✅ Implement caching for repeated operations
|
|
3057
|
+
- ✅ Use process pooling for frequent calls
|
|
3058
|
+
- ✅ Set appropriate buffer sizes
|
|
3059
|
+
- ✅ Use adaptive timeouts based on operation type
|
|
3060
|
+
|
|
3061
|
+
---
|
|
3062
|
+
|
|
3063
|
+
## References & Sources
|
|
3064
|
+
|
|
3065
|
+
### Node.js Child Process Documentation
|
|
3066
|
+
|
|
3067
|
+
- [Child process | Node.js v25.3.0 Documentation](https://nodejs.org/api/child_process.html)
|
|
3068
|
+
- [How To Launch Child Processes in Node.js | DigitalOcean](https://www.digitalocean.com/community/tutorials/how-to-launch-child-processes-in-node-js)
|
|
3069
|
+
- [Node.js Child Processes: Everything you need to know](https://www.freecodecamp.org/news/node-js-child-processes-everything-you-need-to-know-e69498fe970a/)
|
|
3070
|
+
|
|
3071
|
+
### CLI Integration Patterns
|
|
3072
|
+
|
|
3073
|
+
- [Building CLI apps with TypeScript in 2026 - DEV Community](https://dev.to/hongminhee/building-cli-apps-with-typescript-in-2026-5c9d)
|
|
3074
|
+
- [CLI patterns cookbook | Optique](https://optique.dev/cookbook)
|
|
3075
|
+
- [Streamlining CLI Input with Async Generators](https://typescript.tv/hands-on/streamlining-cli-input-with-async-generators/)
|
|
3076
|
+
|
|
3077
|
+
### GitHub CLI Integration
|
|
3078
|
+
|
|
3079
|
+
- [GitHub CLI | Take GitHub to the command line](https://cli.github.com/manual/gh)
|
|
3080
|
+
- [Creating GitHub CLI extensions - GitHub Docs](https://docs.github.com/en/github-cli/github-cli/creating-github-cli-extensions)
|
|
3081
|
+
- [GitHub - cli/cli: GitHub's official command line tool](https://github.com/cli/cli)
|
|
3082
|
+
|
|
3083
|
+
### Security Best Practices
|
|
3084
|
+
|
|
3085
|
+
- [Preventing Command Injection Attacks in Node.js Apps](https://auth0.com/blog/preventing-command-injection-attacks-in-node-js-apps/)
|
|
3086
|
+
- [Secure JavaScript Coding Practices Against Command Injection Vulnerabilities](https://www.nodejs-security.com/blog/secure-javascript-coding-practices-against-command-injection-vulnerabilities)
|
|
3087
|
+
- [NodeJS Command Injection Guide: Examples and Prevention](https://www.stackhawk.com/blog/nodejs-command-injection-examples-and-prevention/)
|
|
3088
|
+
- [Do not use secrets in environment variables and here's how to do it better](https://www.nodejs-security.com/blog/do-not-use-secrets-in-environment-variables-and-here-is-how-to-do-it-better)
|
|
3089
|
+
|
|
3090
|
+
### Buffer & Timeout Handling
|
|
3091
|
+
|
|
3092
|
+
- [Handling Large Output in Node.js: Avoiding ERR_CHILD_PROCESS_STDIO_MAXBUFFER](https://runebook.dev/en/articles/node/errors/err_child_process_stdio_maxbuffer)
|
|
3093
|
+
- [maxBuffer default too small · Issue #9829 · nodejs/node](https://github.com/nodejs/node/issues/9829)
|
|
3094
|
+
|
|
3095
|
+
### Exit Codes
|
|
3096
|
+
|
|
3097
|
+
- [Standard Exit Status Codes in Linux | Baeldung on Linux](https://www.baeldung.com/linux/status-codes)
|
|
3098
|
+
- [Bash command line exit codes demystified](https://www.redhat.com/en/blog/exit-codes-demystified)
|
|
3099
|
+
- [Exit status - Wikipedia](https://en.wikipedia.org/wiki/Exit_status)
|
|
3100
|
+
|
|
3101
|
+
### Cross-Platform Development
|
|
3102
|
+
|
|
3103
|
+
- [Creating cross-platform shell scripts • Shell scripting with Node.js](https://exploringjs.com/nodejs-shell-scripting/ch_creating-shell-scripts.html)
|
|
3104
|
+
- [GitHub - bcoe/awesome-cross-platform-nodejs](https://github.com/bcoe/awesome-cross-platform-nodejs)
|
|
3105
|
+
- [Writing cross-platform Node.js | George Ornbo](https://shapeshed.com/writing-cross-platform-node/)
|
|
3106
|
+
|
|
3107
|
+
### Git CLI Integration
|
|
3108
|
+
|
|
3109
|
+
- [GitHub - steveukx/git-js: A light weight interface for running git commands](https://github.com/steveukx/git-js)
|
|
3110
|
+
- [isomorphic-git · A pure JavaScript implementation of git](https://isomorphic-git.org/)
|
|
3111
|
+
|
|
3112
|
+
### Streaming JSON
|
|
3113
|
+
|
|
3114
|
+
- [Process streaming JSON with Node.js | by Jake Burden | Medium](https://medium.com/@Jekrb/process-streaming-json-with-node-js-d6530cde72e9)
|
|
3115
|
+
- [GitHub - uhop/stream-json](https://github.com/uhop/stream-json)
|
|
3116
|
+
- [GitHub - max-mapper/ndjson](https://github.com/max-mapper/ndjson)
|
|
3117
|
+
|
|
3118
|
+
### Process Management
|
|
3119
|
+
|
|
3120
|
+
- [Killing process families with node | by Almenon | Medium](https://medium.com/@almenon214/killing-processes-with-node-772ffdd19aad)
|
|
3121
|
+
- [Handling signals/terminating child processes in Node.js](https://colinchjs.github.io/2023-10-10/08-49-38-631116-handling-signalsterminating-child-processes-in-nodejs/)
|
|
3122
|
+
- [Graceful Shutdown in Node.js | Dmitry Trunin](https://dtrunin.github.io//2022/04/05/nodejs-graceful-shutdown.html)
|
|
3123
|
+
|
|
3124
|
+
### Testing
|
|
3125
|
+
|
|
3126
|
+
- [Unit-testing a child process in a Node.js\\Typescript app | by Tzafrir Ben Ami | Medium](https://unhandledexception.dev/unit-testing-a-child-process-in-a-node-js-typescript-app-b7d89615e8e0)
|
|
3127
|
+
- [GitHub - gotwarlost/mock-spawn: Easy to use mock for child_process.spawn](https://github.com/gotwarlost/mock-spawn)
|
|
3128
|
+
- [Mocking node:child_process.spawn() using Jest + TypeScript · GitHub](https://gist.github.com/manekinekko/0aae4bbfdec4e47883f7c04310c40fa1)
|
|
3129
|
+
|
|
3130
|
+
### AI Integration
|
|
3131
|
+
|
|
3132
|
+
- [Vercel AI SDK by Vercel](https://ai-sdk.dev/docs/introduction)
|
|
3133
|
+
- [GitHub - ben-vargas/ai-sdk-provider-claude-code](https://github.com/ben-vargas/ai-sdk-provider-claude-code)
|
|
3134
|
+
- [Using Vercel Sandbox to run Claude's Agent SDK](https://vercel.com/kb/guide/using-vercel-sandbox-claude-agent-sdk)
|
|
3135
|
+
|
|
3136
|
+
---
|
|
3137
|
+
|
|
3138
|
+
## Conclusion
|
|
3139
|
+
|
|
3140
|
+
This document provides production-ready patterns for integrating with AI CLI tools in TypeScript/Node.js. The key principles are:
|
|
3141
|
+
|
|
3142
|
+
1. **Security First**: Never trust user input, always use argument arrays
|
|
3143
|
+
2. **Reliability**: Implement timeouts, retries, and comprehensive error handling
|
|
3144
|
+
3. **Performance**: Use streaming for large outputs, caching for repeated operations
|
|
3145
|
+
4. **Cross-Platform**: Test on Windows, macOS, and Linux
|
|
3146
|
+
5. **Testing**: Mock child processes, use integration tests with real CLI tools
|
|
3147
|
+
|
|
3148
|
+
For the **mdcontext** project, prioritize:
|
|
3149
|
+
- Using `spawn()` with explicit argument arrays for security
|
|
3150
|
+
- Implementing streaming for large codebase processing
|
|
3151
|
+
- Comprehensive error classification for AI CLI interactions
|
|
3152
|
+
- Adaptive timeouts based on operation complexity
|
|
3153
|
+
- Secure environment variable management
|
|
3154
|
+
|
|
3155
|
+
Remember: **Shell injection is the #1 vulnerability in CLI integration. Always use argument arrays, never string concatenation.**
|