@thegitai/cli 1.0.0-beta.1
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/LICENSE +20 -0
- package/README.md +30 -0
- package/dist/bin/ai.js +438 -0
- package/dist/parsers/tree-sitter-c-sharp.wasm +0 -0
- package/dist/parsers/tree-sitter-c.wasm +0 -0
- package/dist/parsers/tree-sitter-cpp.wasm +0 -0
- package/dist/parsers/tree-sitter-css.wasm +0 -0
- package/dist/parsers/tree-sitter-go.wasm +0 -0
- package/dist/parsers/tree-sitter-html.wasm +0 -0
- package/dist/parsers/tree-sitter-java.wasm +0 -0
- package/dist/parsers/tree-sitter-javascript.wasm +0 -0
- package/dist/parsers/tree-sitter-objc.wasm +0 -0
- package/dist/parsers/tree-sitter-php.wasm +0 -0
- package/dist/parsers/tree-sitter-python.wasm +0 -0
- package/dist/parsers/tree-sitter-ruby.wasm +0 -0
- package/dist/parsers/tree-sitter-rust.wasm +0 -0
- package/dist/parsers/tree-sitter-tsx.wasm +0 -0
- package/dist/parsers/tree-sitter-typescript.wasm +0 -0
- package/dist/src/agent-mode.js +142 -0
- package/dist/src/api/auth.js +81 -0
- package/dist/src/api/browser-login.js +184 -0
- package/dist/src/api/chat.js +346 -0
- package/dist/src/api/contracts.js +1 -0
- package/dist/src/api/http.js +44 -0
- package/dist/src/api/index.js +11 -0
- package/dist/src/api/models.js +110 -0
- package/dist/src/api/sessions.js +72 -0
- package/dist/src/artifact-policy.js +207 -0
- package/dist/src/client-state.js +14 -0
- package/dist/src/core/clipboard.js +208 -0
- package/dist/src/core/open-url.js +32 -0
- package/dist/src/edit-journal.js +133 -0
- package/dist/src/executor.js +924 -0
- package/dist/src/extractors/cpp.js +18 -0
- package/dist/src/extractors/csharp.js +16 -0
- package/dist/src/extractors/css.js +12 -0
- package/dist/src/extractors/go.js +27 -0
- package/dist/src/extractors/index.js +52 -0
- package/dist/src/extractors/java.js +14 -0
- package/dist/src/extractors/javascript.js +33 -0
- package/dist/src/extractors/objc.js +14 -0
- package/dist/src/extractors/php.js +20 -0
- package/dist/src/extractors/python.js +11 -0
- package/dist/src/extractors/ruby.js +13 -0
- package/dist/src/extractors/rust.js +17 -0
- package/dist/src/extractors/utils.js +58 -0
- package/dist/src/help-text.js +125 -0
- package/dist/src/markdown-renderer.js +112 -0
- package/dist/src/patcher.js +279 -0
- package/dist/src/project-index.js +221 -0
- package/dist/src/repo-map-languages.js +100 -0
- package/dist/src/runtime-mode.js +35 -0
- package/dist/src/scanner.js +362 -0
- package/dist/src/secret-preview.js +137 -0
- package/dist/src/session-exit.js +17 -0
- package/dist/src/session-safety.js +1012 -0
- package/dist/src/session-store.js +266 -0
- package/dist/src/session.js +93 -0
- package/dist/src/tool-executor.js +188 -0
- package/dist/src/tools/code-intel.js +472 -0
- package/dist/src/tools/delete-file.js +27 -0
- package/dist/src/tools/exec-utils.js +17 -0
- package/dist/src/tools/find-symbol.js +70 -0
- package/dist/src/tools/get-diagnostics.js +22 -0
- package/dist/src/tools/grep-code.js +331 -0
- package/dist/src/tools/hover-symbol.js +95 -0
- package/dist/src/tools/index.js +73 -0
- package/dist/src/tools/list-checkpoints.js +11 -0
- package/dist/src/tools/list-directories.js +16 -0
- package/dist/src/tools/list-files.js +13 -0
- package/dist/src/tools/list-session-edits.js +9 -0
- package/dist/src/tools/list-symbols.js +55 -0
- package/dist/src/tools/patch-file.js +88 -0
- package/dist/src/tools/path-listing.js +83 -0
- package/dist/src/tools/read-document.js +111 -0
- package/dist/src/tools/read-file.js +109 -0
- package/dist/src/tools/restore-checkpoint.js +100 -0
- package/dist/src/tools/ripgrep.js +29 -0
- package/dist/src/tools/run-command.js +94 -0
- package/dist/src/tools/run-node-script.js +210 -0
- package/dist/src/tools/search-code.js +37 -0
- package/dist/src/tools/shell-diagnostics.js +707 -0
- package/dist/src/tools/signature-help.js +118 -0
- package/dist/src/tools/str-replace.js +193 -0
- package/dist/src/tools/types.js +1 -0
- package/dist/src/tools/undo-edit.js +202 -0
- package/dist/src/tools/write-file.js +59 -0
- package/dist/src/tree-sitter-runtime.js +135 -0
- package/dist/src/types.js +1 -0
- package/dist/src/ui/paste-collapse.js +22 -0
- package/dist/src/ui/prompt-history-store.js +96 -0
- package/dist/src/ui/repl.js +2238 -0
- package/dist/src/ui/tui/bridge.js +175 -0
- package/dist/src/ui/tui/build-frame.js +718 -0
- package/dist/src/ui/tui/markdown-render.js +455 -0
- package/dist/src/ui/tui/shell-input.js +488 -0
- package/dist/src/ui/tui/text.js +30 -0
- package/dist/src/ui/tui/types.js +1 -0
- package/dist/src/usage.js +47 -0
- package/dist/src/utils.js +38 -0
- package/package.json +38 -0
|
@@ -0,0 +1,924 @@
|
|
|
1
|
+
import chalk from 'chalk';
|
|
2
|
+
import * as pty from '@homebridge/node-pty-prebuilt-multiarch';
|
|
3
|
+
import { execFileSync, spawn } from 'child_process';
|
|
4
|
+
import { existsSync, statSync } from 'fs';
|
|
5
|
+
import os from 'os';
|
|
6
|
+
import path from 'path';
|
|
7
|
+
import { ARTIFACT_INSPECT_BLOCK_DIRS, getBlockedArtifactInspectDir, relativeProjectPath, } from './artifact-policy.js';
|
|
8
|
+
import { emitCommandOutput, isTuiMode } from './runtime-mode.js';
|
|
9
|
+
const COMMON_TOOLCHAIN_BIN_DIRS = ['/usr/local/go/bin'];
|
|
10
|
+
function detectVenvBin(dir) {
|
|
11
|
+
for (const name of ['.venv', 'venv', 'env']) {
|
|
12
|
+
const bin = path.join(dir, name, 'bin');
|
|
13
|
+
if (existsSync(path.join(bin, 'python')))
|
|
14
|
+
return bin;
|
|
15
|
+
}
|
|
16
|
+
return null;
|
|
17
|
+
}
|
|
18
|
+
const DEFAULT_TIMEOUT = 5 * 60 * 1000;
|
|
19
|
+
const MAX_OUTPUT_CHARS = 6000;
|
|
20
|
+
const MAX_CAPTURE_CHARS = 1024 * 1024;
|
|
21
|
+
const EXPLORATORY_COMMAND_PATTERN = /\b(ls|find|tree|fd|rg)\b/;
|
|
22
|
+
const FILE_INSPECTION_COMMAND_PATTERN = /\b(ls|find|tree|fd|rg|grep|cat|sed|head|tail|less|more)\b/;
|
|
23
|
+
const DETACHED_JOB_PATTERN = /\$!|\b(nohup|disown|setsid)\b/;
|
|
24
|
+
const LONG_RUNNING_SERVER_PATTERN = /\b((npm|pnpm|yarn)\s+run\s+(dev|start|preview)|next\s+dev|next\s+start|nuxi?\s+(dev|preview)|vite(\s+dev)?|webpack(-dev-server)?\s+serve)\b/;
|
|
25
|
+
const BLOCKED_PATH_INSPECT_DIRS = ARTIFACT_INSPECT_BLOCK_DIRS;
|
|
26
|
+
function getUnquotedShellText(command) {
|
|
27
|
+
let quote = null;
|
|
28
|
+
let escaped = false;
|
|
29
|
+
let text = '';
|
|
30
|
+
for (const char of String(command)) {
|
|
31
|
+
if (escaped) {
|
|
32
|
+
escaped = false;
|
|
33
|
+
text += ' ';
|
|
34
|
+
continue;
|
|
35
|
+
}
|
|
36
|
+
if (char === '\\' && quote !== "'") {
|
|
37
|
+
escaped = true;
|
|
38
|
+
text += ' ';
|
|
39
|
+
continue;
|
|
40
|
+
}
|
|
41
|
+
if (quote) {
|
|
42
|
+
if (char === quote)
|
|
43
|
+
quote = null;
|
|
44
|
+
text += ' ';
|
|
45
|
+
continue;
|
|
46
|
+
}
|
|
47
|
+
if (char === "'" || char === '"' || char === '`') {
|
|
48
|
+
quote = char;
|
|
49
|
+
text += ' ';
|
|
50
|
+
continue;
|
|
51
|
+
}
|
|
52
|
+
text += char;
|
|
53
|
+
}
|
|
54
|
+
return text;
|
|
55
|
+
}
|
|
56
|
+
function hasUnquotedBackgroundAmpersand(command) {
|
|
57
|
+
let quote = null;
|
|
58
|
+
let escaped = false;
|
|
59
|
+
const text = String(command);
|
|
60
|
+
for (let index = 0; index < text.length; index += 1) {
|
|
61
|
+
const char = text[index];
|
|
62
|
+
if (escaped) {
|
|
63
|
+
escaped = false;
|
|
64
|
+
continue;
|
|
65
|
+
}
|
|
66
|
+
if (char === '\\' && quote !== "'") {
|
|
67
|
+
escaped = true;
|
|
68
|
+
continue;
|
|
69
|
+
}
|
|
70
|
+
if (quote) {
|
|
71
|
+
if (char === quote)
|
|
72
|
+
quote = null;
|
|
73
|
+
continue;
|
|
74
|
+
}
|
|
75
|
+
if (char === "'" || char === '"' || char === '`') {
|
|
76
|
+
quote = char;
|
|
77
|
+
continue;
|
|
78
|
+
}
|
|
79
|
+
if (char !== '&')
|
|
80
|
+
continue;
|
|
81
|
+
const previous = text[index - 1] ?? '';
|
|
82
|
+
const next = text[index + 1] ?? '';
|
|
83
|
+
if (previous === '&' || next === '&')
|
|
84
|
+
continue;
|
|
85
|
+
if (previous === '>' || previous === '<' || next === '>')
|
|
86
|
+
continue;
|
|
87
|
+
return true;
|
|
88
|
+
}
|
|
89
|
+
return false;
|
|
90
|
+
}
|
|
91
|
+
function hasBackgroundOrDetachedShell(command) {
|
|
92
|
+
return (DETACHED_JOB_PATTERN.test(getUnquotedShellText(command)) ||
|
|
93
|
+
hasUnquotedBackgroundAmpersand(command));
|
|
94
|
+
}
|
|
95
|
+
function getBadGhPrCommandReason(command) {
|
|
96
|
+
const text = getUnquotedShellText(command);
|
|
97
|
+
if (/\bgh\s+pr\s+diff\s+\S+(?:\s+(?:\d?>&\d+|\d?>\S+|--[A-Za-z0-9-]+(?:=\S+)?))*\s*\|\s*(?:cat|head|tail|sed|awk)\b/i.test(text)) {
|
|
98
|
+
return 'Do not page or slice whole PR diffs through shell. Use gh pr view <number> --json files,statusCheckRollup for PR metadata, then git diff <base>...HEAD -- <path> for one changed file at a time.';
|
|
99
|
+
}
|
|
100
|
+
if (/\bgh\s+pr\s+diff\s+\S+\s+--\s+\S+/i.test(text)) {
|
|
101
|
+
return 'gh pr diff accepts only a PR number. For a file-scoped PR diff, use git diff <base>...HEAD -- <path>, or run gh pr diff <number> alone.';
|
|
102
|
+
}
|
|
103
|
+
if (/\bgh\s+pr\s+view\b(?=[^;&|]*--json\b)(?=[^;&|]*\bdiff\b)/i.test(text)) {
|
|
104
|
+
return 'gh pr view --json does not expose a diff field. Use gh pr diff <number> for diff text, or gh pr view <number> --json files,statusCheckRollup for structured PR metadata.';
|
|
105
|
+
}
|
|
106
|
+
return null;
|
|
107
|
+
}
|
|
108
|
+
function escapeRegex(text) {
|
|
109
|
+
return text.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
110
|
+
}
|
|
111
|
+
function maskNonPathIgnoreDirTokens(command) {
|
|
112
|
+
let s = String(command);
|
|
113
|
+
s = s.replace(/\b(npm|pnpm|yarn|npx)\s+run\s+[^\s;&|)]+/gi, '$1 run __pm_script__');
|
|
114
|
+
for (const dir of BLOCKED_PATH_INSPECT_DIRS) {
|
|
115
|
+
if (!/^[a-z0-9@._-]+$/i.test(dir))
|
|
116
|
+
continue;
|
|
117
|
+
s = s.replace(new RegExp(`\\byarn\\s+${escapeRegex(dir)}\\b`, 'gi'), 'yarn __pm_script__');
|
|
118
|
+
}
|
|
119
|
+
s = s.replace(/\b(nuxt|nuxi|next|vite|webpack|rollup)\s+build\b/gi, '$1 __cli_build__');
|
|
120
|
+
return s;
|
|
121
|
+
}
|
|
122
|
+
function maskHereDocumentBodies(command) {
|
|
123
|
+
const lines = String(command).split('\n');
|
|
124
|
+
const masked = [];
|
|
125
|
+
for (let i = 0; i < lines.length; i += 1) {
|
|
126
|
+
const line = lines[i] ?? '';
|
|
127
|
+
masked.push(line);
|
|
128
|
+
const markers = Array.from(line.matchAll(/<<-?\s*(?:"([^"]+)"|'([^']+)'|([A-Za-z0-9_]+))/g), (match) => match[1] ?? match[2] ?? match[3] ?? '').filter(Boolean);
|
|
129
|
+
for (const marker of markers) {
|
|
130
|
+
i += 1;
|
|
131
|
+
while (i < lines.length) {
|
|
132
|
+
const bodyLine = lines[i] ?? '';
|
|
133
|
+
if (bodyLine.trim() === marker) {
|
|
134
|
+
masked.push(bodyLine);
|
|
135
|
+
break;
|
|
136
|
+
}
|
|
137
|
+
masked.push('');
|
|
138
|
+
i += 1;
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
return masked.join('\n');
|
|
143
|
+
}
|
|
144
|
+
function splitShellishTokens(text) {
|
|
145
|
+
return text.match(/"[^"]*"|'[^']*'|`[^`]*`|\S+/g) ?? [];
|
|
146
|
+
}
|
|
147
|
+
function normalizeToken(raw) {
|
|
148
|
+
let token = String(raw).trim();
|
|
149
|
+
if (!token)
|
|
150
|
+
return '';
|
|
151
|
+
if (token.length >= 2 &&
|
|
152
|
+
((token.startsWith('"') && token.endsWith('"')) ||
|
|
153
|
+
(token.startsWith("'") && token.endsWith("'")) ||
|
|
154
|
+
(token.startsWith('`') && token.endsWith('`')))) {
|
|
155
|
+
token = token.slice(1, -1);
|
|
156
|
+
}
|
|
157
|
+
token = token.replace(/^[([{]+/, '').replace(/[)\]},;]+$/, '');
|
|
158
|
+
token = token.replace(/:(\d+)(?::\d+)?$/, '').replace(/:+$/, '');
|
|
159
|
+
return token;
|
|
160
|
+
}
|
|
161
|
+
function hasPathGlob(token) {
|
|
162
|
+
return /[*?[\]{}]/.test(token);
|
|
163
|
+
}
|
|
164
|
+
function hasExplicitPathShape(token) {
|
|
165
|
+
if (!token || token.startsWith('-'))
|
|
166
|
+
return false;
|
|
167
|
+
if (token === '.' || token === '..')
|
|
168
|
+
return true;
|
|
169
|
+
if (token.startsWith('/') ||
|
|
170
|
+
token.startsWith('./') ||
|
|
171
|
+
token.startsWith('../') ||
|
|
172
|
+
token.startsWith('~/')) {
|
|
173
|
+
return true;
|
|
174
|
+
}
|
|
175
|
+
return token.includes('/') || token.includes('\\');
|
|
176
|
+
}
|
|
177
|
+
function getCommandBaseDir(command, rootDir) {
|
|
178
|
+
if (!rootDir)
|
|
179
|
+
return null;
|
|
180
|
+
const match = String(command).match(/^\s*cd\s+((?:"[^"]*"|'[^']*'|`[^`]*`|\S+))\s*&&/);
|
|
181
|
+
if (!match)
|
|
182
|
+
return rootDir;
|
|
183
|
+
const token = normalizeToken(match[1] ?? '');
|
|
184
|
+
if (!token || hasPathGlob(token) || token.startsWith('~'))
|
|
185
|
+
return rootDir;
|
|
186
|
+
return path.isAbsolute(token)
|
|
187
|
+
? path.resolve(token)
|
|
188
|
+
: path.resolve(rootDir, token);
|
|
189
|
+
}
|
|
190
|
+
function getBlockedProjectPathDir(absPath, rootDir) {
|
|
191
|
+
const relPath = relativeProjectPath(rootDir, absPath);
|
|
192
|
+
return relPath ? getBlockedArtifactInspectDir(relPath) : null;
|
|
193
|
+
}
|
|
194
|
+
function isInsideOsTemp(absPath) {
|
|
195
|
+
const relPath = path.relative(path.resolve(os.tmpdir()), path.resolve(absPath));
|
|
196
|
+
return (relPath === '' ||
|
|
197
|
+
(!relPath.startsWith('..') && !path.isAbsolute(relPath)));
|
|
198
|
+
}
|
|
199
|
+
function isExistingDirectory(absPath) {
|
|
200
|
+
try {
|
|
201
|
+
return existsSync(absPath) && statSync(absPath).isDirectory();
|
|
202
|
+
}
|
|
203
|
+
catch {
|
|
204
|
+
return false;
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
function getBlockedOsTempInspection(rawToken, rootDir) {
|
|
208
|
+
const token = normalizeToken(rawToken);
|
|
209
|
+
if (!token || !path.isAbsolute(token))
|
|
210
|
+
return null;
|
|
211
|
+
const resolved = path.resolve(token);
|
|
212
|
+
if (!isInsideOsTemp(resolved))
|
|
213
|
+
return null;
|
|
214
|
+
if (rootDir) {
|
|
215
|
+
const resolvedRoot = path.resolve(rootDir);
|
|
216
|
+
if (resolved === resolvedRoot ||
|
|
217
|
+
relativeProjectPath(resolvedRoot, resolved) !== null) {
|
|
218
|
+
return null;
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
if (resolved === path.resolve(os.tmpdir()) ||
|
|
222
|
+
hasPathGlob(token) ||
|
|
223
|
+
isExistingDirectory(resolved)) {
|
|
224
|
+
return path.basename(os.tmpdir()) || os.tmpdir();
|
|
225
|
+
}
|
|
226
|
+
return null;
|
|
227
|
+
}
|
|
228
|
+
function findBlockedDirForToken(rawToken, rootDir, baseDir, allowBareDirMatch = false) {
|
|
229
|
+
const token = normalizeToken(rawToken);
|
|
230
|
+
if (!token || token.startsWith('~') || !rootDir) {
|
|
231
|
+
return null;
|
|
232
|
+
}
|
|
233
|
+
const osTempDir = getBlockedOsTempInspection(rawToken, rootDir);
|
|
234
|
+
if (osTempDir)
|
|
235
|
+
return osTempDir;
|
|
236
|
+
if (hasExplicitPathShape(token)) {
|
|
237
|
+
const resolved = path.isAbsolute(token)
|
|
238
|
+
? path.resolve(token)
|
|
239
|
+
: path.resolve(baseDir ?? rootDir, token);
|
|
240
|
+
return getBlockedProjectPathDir(resolved, rootDir);
|
|
241
|
+
}
|
|
242
|
+
if (!allowBareDirMatch || !BLOCKED_PATH_INSPECT_DIRS.has(token)) {
|
|
243
|
+
return null;
|
|
244
|
+
}
|
|
245
|
+
const resolved = path.resolve(baseDir ?? rootDir, token);
|
|
246
|
+
return getBlockedProjectPathDir(resolved, rootDir);
|
|
247
|
+
}
|
|
248
|
+
function isShellControlToken(token) {
|
|
249
|
+
return (token === '|' ||
|
|
250
|
+
token === '||' ||
|
|
251
|
+
token === '&&' ||
|
|
252
|
+
token === ';' ||
|
|
253
|
+
token === '(' ||
|
|
254
|
+
token === ')');
|
|
255
|
+
}
|
|
256
|
+
function getOptionValueRole(commandName, option) {
|
|
257
|
+
switch (commandName) {
|
|
258
|
+
case 'grep':
|
|
259
|
+
case 'rg':
|
|
260
|
+
if (option === '-e' || option === '--regexp')
|
|
261
|
+
return 'pattern';
|
|
262
|
+
if (option === '-f' ||
|
|
263
|
+
option === '--file' ||
|
|
264
|
+
option === '-g' ||
|
|
265
|
+
option === '--glob') {
|
|
266
|
+
return 'path';
|
|
267
|
+
}
|
|
268
|
+
return null;
|
|
269
|
+
case 'sed':
|
|
270
|
+
if (option === '-e')
|
|
271
|
+
return 'pattern';
|
|
272
|
+
if (option === '-f')
|
|
273
|
+
return 'path';
|
|
274
|
+
return null;
|
|
275
|
+
case 'find':
|
|
276
|
+
if (option === '-path' ||
|
|
277
|
+
option === '-ipath' ||
|
|
278
|
+
option === '-wholename' ||
|
|
279
|
+
option === '-iwholename') {
|
|
280
|
+
return 'path';
|
|
281
|
+
}
|
|
282
|
+
if (option === '-name' || option === '-iname')
|
|
283
|
+
return 'pattern';
|
|
284
|
+
return null;
|
|
285
|
+
default:
|
|
286
|
+
return null;
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
function splitOptionToken(token) {
|
|
290
|
+
const eqIndex = token.indexOf('=');
|
|
291
|
+
if (eqIndex === -1) {
|
|
292
|
+
return { optionName: token, inlineValue: null };
|
|
293
|
+
}
|
|
294
|
+
return {
|
|
295
|
+
optionName: token.slice(0, eqIndex),
|
|
296
|
+
inlineValue: token.slice(eqIndex + 1),
|
|
297
|
+
};
|
|
298
|
+
}
|
|
299
|
+
function shouldAllowBareBlockedDir(commandName, positionalCount) {
|
|
300
|
+
switch (commandName) {
|
|
301
|
+
case 'ls':
|
|
302
|
+
case 'tree':
|
|
303
|
+
case 'cat':
|
|
304
|
+
case 'head':
|
|
305
|
+
case 'tail':
|
|
306
|
+
case 'less':
|
|
307
|
+
case 'more':
|
|
308
|
+
case 'find':
|
|
309
|
+
return positionalCount >= 1;
|
|
310
|
+
case 'grep':
|
|
311
|
+
case 'rg':
|
|
312
|
+
case 'fd':
|
|
313
|
+
case 'sed':
|
|
314
|
+
return positionalCount >= 2;
|
|
315
|
+
default:
|
|
316
|
+
return false;
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
function findBlockedDirInCommandTokens(command, rootDir, baseDir) {
|
|
320
|
+
if (!rootDir)
|
|
321
|
+
return null;
|
|
322
|
+
const tokens = splitShellishTokens(command);
|
|
323
|
+
let currentCommand = '';
|
|
324
|
+
let positionalCount = 0;
|
|
325
|
+
let stopOptions = false;
|
|
326
|
+
let optionValueRole = null;
|
|
327
|
+
for (const rawToken of tokens) {
|
|
328
|
+
const token = normalizeToken(rawToken);
|
|
329
|
+
if (!token)
|
|
330
|
+
continue;
|
|
331
|
+
if (isShellControlToken(token)) {
|
|
332
|
+
currentCommand = '';
|
|
333
|
+
positionalCount = 0;
|
|
334
|
+
stopOptions = false;
|
|
335
|
+
optionValueRole = null;
|
|
336
|
+
continue;
|
|
337
|
+
}
|
|
338
|
+
if (!currentCommand) {
|
|
339
|
+
currentCommand = token.toLowerCase();
|
|
340
|
+
positionalCount = 0;
|
|
341
|
+
stopOptions = false;
|
|
342
|
+
optionValueRole = null;
|
|
343
|
+
continue;
|
|
344
|
+
}
|
|
345
|
+
if (optionValueRole) {
|
|
346
|
+
const blockedDir = findBlockedDirForToken(token, rootDir, baseDir, optionValueRole === 'path');
|
|
347
|
+
if (blockedDir)
|
|
348
|
+
return blockedDir;
|
|
349
|
+
optionValueRole = null;
|
|
350
|
+
continue;
|
|
351
|
+
}
|
|
352
|
+
if (!stopOptions && token === '--') {
|
|
353
|
+
stopOptions = true;
|
|
354
|
+
continue;
|
|
355
|
+
}
|
|
356
|
+
if (!stopOptions && token.startsWith('-') && token !== '-') {
|
|
357
|
+
const { optionName, inlineValue } = splitOptionToken(token);
|
|
358
|
+
const optionRole = getOptionValueRole(currentCommand, optionName);
|
|
359
|
+
if (inlineValue != null) {
|
|
360
|
+
const blockedDir = findBlockedDirForToken(inlineValue, rootDir, baseDir, optionRole === 'path');
|
|
361
|
+
if (blockedDir)
|
|
362
|
+
return blockedDir;
|
|
363
|
+
optionValueRole = null;
|
|
364
|
+
continue;
|
|
365
|
+
}
|
|
366
|
+
optionValueRole = optionRole;
|
|
367
|
+
continue;
|
|
368
|
+
}
|
|
369
|
+
positionalCount += 1;
|
|
370
|
+
const blockedDir = findBlockedDirForToken(token, rootDir, baseDir, shouldAllowBareBlockedDir(currentCommand, positionalCount));
|
|
371
|
+
if (blockedDir)
|
|
372
|
+
return blockedDir;
|
|
373
|
+
}
|
|
374
|
+
return null;
|
|
375
|
+
}
|
|
376
|
+
function findIgnoredPathInspection(command, rootDir) {
|
|
377
|
+
if (!FILE_INSPECTION_COMMAND_PATTERN.test(command)) {
|
|
378
|
+
return null;
|
|
379
|
+
}
|
|
380
|
+
const haystack = maskNonPathIgnoreDirTokens(maskHereDocumentBodies(command));
|
|
381
|
+
const baseDir = getCommandBaseDir(haystack, rootDir);
|
|
382
|
+
const blockedDir = findBlockedDirInCommandTokens(haystack, rootDir, baseDir);
|
|
383
|
+
if (blockedDir) {
|
|
384
|
+
return blockedDir;
|
|
385
|
+
}
|
|
386
|
+
if (!rootDir) {
|
|
387
|
+
for (const dir of BLOCKED_PATH_INSPECT_DIRS) {
|
|
388
|
+
const pattern = new RegExp(`(^|[\\s"'./])${escapeRegex(dir)}(?=$|[\\s"'./])`);
|
|
389
|
+
if (pattern.test(haystack)) {
|
|
390
|
+
return dir;
|
|
391
|
+
}
|
|
392
|
+
}
|
|
393
|
+
}
|
|
394
|
+
return null;
|
|
395
|
+
}
|
|
396
|
+
function trimOutput(text) {
|
|
397
|
+
if (!text)
|
|
398
|
+
return '';
|
|
399
|
+
if (text.length <= MAX_OUTPUT_CHARS)
|
|
400
|
+
return text;
|
|
401
|
+
return `${text.slice(0, MAX_OUTPUT_CHARS)}\n... (truncated)`;
|
|
402
|
+
}
|
|
403
|
+
function buildCommandPath(basePath, extraDirs = []) {
|
|
404
|
+
const seen = new Set();
|
|
405
|
+
const dirs = [];
|
|
406
|
+
for (const dir of [...extraDirs, ...COMMON_TOOLCHAIN_BIN_DIRS]) {
|
|
407
|
+
if (!dir || seen.has(dir) || !existsSync(dir))
|
|
408
|
+
continue;
|
|
409
|
+
seen.add(dir);
|
|
410
|
+
dirs.push(dir);
|
|
411
|
+
}
|
|
412
|
+
for (const dir of String(basePath ?? '').split(path.delimiter)) {
|
|
413
|
+
if (!dir || seen.has(dir))
|
|
414
|
+
continue;
|
|
415
|
+
seen.add(dir);
|
|
416
|
+
dirs.push(dir);
|
|
417
|
+
}
|
|
418
|
+
return dirs.join(path.delimiter);
|
|
419
|
+
}
|
|
420
|
+
function isExploratoryCommand(command) {
|
|
421
|
+
return EXPLORATORY_COMMAND_PATTERN.test(command);
|
|
422
|
+
}
|
|
423
|
+
function shouldDropOutputLine(line, rootDir, baseDir) {
|
|
424
|
+
const tokens = splitShellishTokens(line);
|
|
425
|
+
if (tokens.length === 1) {
|
|
426
|
+
return findBlockedDirForToken(tokens[0], rootDir, baseDir, true) != null;
|
|
427
|
+
}
|
|
428
|
+
for (const token of splitShellishTokens(line)) {
|
|
429
|
+
if (findBlockedDirForToken(token, rootDir, baseDir))
|
|
430
|
+
return true;
|
|
431
|
+
}
|
|
432
|
+
return false;
|
|
433
|
+
}
|
|
434
|
+
function isLsDirectoryHeader(line) {
|
|
435
|
+
return /^\.?\/?.+:$/.test(line) && !line.includes(' ');
|
|
436
|
+
}
|
|
437
|
+
function sanitizeCommandText(command, text, rootDir) {
|
|
438
|
+
if (!text)
|
|
439
|
+
return '';
|
|
440
|
+
const lines = text.split('\n');
|
|
441
|
+
const filtered = [];
|
|
442
|
+
let insideIgnoredDir = false;
|
|
443
|
+
const baseDir = getCommandBaseDir(command, rootDir);
|
|
444
|
+
for (const line of lines) {
|
|
445
|
+
const trimmed = line.trim();
|
|
446
|
+
if (isLsDirectoryHeader(trimmed)) {
|
|
447
|
+
const dirPath = trimmed.replace(/^\.\//, '').replace(/:$/, '');
|
|
448
|
+
if (shouldDropOutputLine(dirPath, rootDir, baseDir)) {
|
|
449
|
+
insideIgnoredDir = true;
|
|
450
|
+
continue;
|
|
451
|
+
}
|
|
452
|
+
insideIgnoredDir = false;
|
|
453
|
+
}
|
|
454
|
+
if (insideIgnoredDir)
|
|
455
|
+
continue;
|
|
456
|
+
if (shouldDropOutputLine(trimmed, rootDir, baseDir))
|
|
457
|
+
continue;
|
|
458
|
+
filtered.push(line);
|
|
459
|
+
}
|
|
460
|
+
return filtered.join('\n');
|
|
461
|
+
}
|
|
462
|
+
function appendCapturedText(current, chunk) {
|
|
463
|
+
if (!chunk || current.length >= MAX_CAPTURE_CHARS) {
|
|
464
|
+
return current;
|
|
465
|
+
}
|
|
466
|
+
const remaining = MAX_CAPTURE_CHARS - current.length;
|
|
467
|
+
return current + chunk.slice(0, remaining);
|
|
468
|
+
}
|
|
469
|
+
let activeCommandChild = null;
|
|
470
|
+
let activeCommandPty = null;
|
|
471
|
+
let activeCommandFinish = null;
|
|
472
|
+
function killPty(child, signal) {
|
|
473
|
+
try {
|
|
474
|
+
if (process.platform === 'win32') {
|
|
475
|
+
child.kill();
|
|
476
|
+
return;
|
|
477
|
+
}
|
|
478
|
+
child.kill(signal);
|
|
479
|
+
}
|
|
480
|
+
catch { }
|
|
481
|
+
}
|
|
482
|
+
export function cancelActiveCommand() {
|
|
483
|
+
const child = activeCommandChild;
|
|
484
|
+
const commandPty = activeCommandPty;
|
|
485
|
+
const finish = activeCommandFinish;
|
|
486
|
+
if ((!child && !commandPty) || !finish)
|
|
487
|
+
return;
|
|
488
|
+
let killEscalated = false;
|
|
489
|
+
const killTimer = setTimeout(() => {
|
|
490
|
+
if (!killEscalated) {
|
|
491
|
+
killEscalated = true;
|
|
492
|
+
if (child)
|
|
493
|
+
terminateChild(child, 'SIGKILL');
|
|
494
|
+
if (commandPty)
|
|
495
|
+
killPty(commandPty, 'SIGKILL');
|
|
496
|
+
}
|
|
497
|
+
}, 500);
|
|
498
|
+
killTimer.unref?.();
|
|
499
|
+
if (child)
|
|
500
|
+
terminateChild(child, 'SIGTERM');
|
|
501
|
+
if (commandPty)
|
|
502
|
+
killPty(commandPty, 'SIGTERM');
|
|
503
|
+
finish({
|
|
504
|
+
exitCode: 1,
|
|
505
|
+
stdout: '',
|
|
506
|
+
stderr: 'Command cancelled.',
|
|
507
|
+
output: 'Command cancelled.',
|
|
508
|
+
timedOut: false,
|
|
509
|
+
cancelled: true,
|
|
510
|
+
});
|
|
511
|
+
}
|
|
512
|
+
function terminateChild(child, signal) {
|
|
513
|
+
if (!child?.pid)
|
|
514
|
+
return;
|
|
515
|
+
if (process.platform === 'win32') {
|
|
516
|
+
try {
|
|
517
|
+
execFileSync('taskkill', ['/pid', String(child.pid), '/t', '/f'], {
|
|
518
|
+
stdio: 'ignore',
|
|
519
|
+
});
|
|
520
|
+
}
|
|
521
|
+
catch {
|
|
522
|
+
try {
|
|
523
|
+
child.kill(signal);
|
|
524
|
+
}
|
|
525
|
+
catch { }
|
|
526
|
+
}
|
|
527
|
+
return;
|
|
528
|
+
}
|
|
529
|
+
try {
|
|
530
|
+
process.kill(-child.pid, signal);
|
|
531
|
+
}
|
|
532
|
+
catch {
|
|
533
|
+
try {
|
|
534
|
+
child.kill(signal);
|
|
535
|
+
}
|
|
536
|
+
catch { }
|
|
537
|
+
}
|
|
538
|
+
}
|
|
539
|
+
export function getBlockedPathInspectDir(command, rootDir) {
|
|
540
|
+
return findIgnoredPathInspection(command, rootDir);
|
|
541
|
+
}
|
|
542
|
+
export function getBlockedCommandReason(command, hasTimeout, rootDir) {
|
|
543
|
+
const ignoredDir = findIgnoredPathInspection(command, rootDir);
|
|
544
|
+
if (ignoredDir) {
|
|
545
|
+
return `Command inspects an off-limits generated or dependency directory (${ignoredDir}). Avoid that path.`;
|
|
546
|
+
}
|
|
547
|
+
const badGhPrCommand = getBadGhPrCommandReason(command);
|
|
548
|
+
if (badGhPrCommand)
|
|
549
|
+
return badGhPrCommand;
|
|
550
|
+
if (hasBackgroundOrDetachedShell(command)) {
|
|
551
|
+
return 'Command contains an unquoted shell background operator (&). Quote URLs or arguments containing & and run one foreground command.';
|
|
552
|
+
}
|
|
553
|
+
if (!hasTimeout && LONG_RUNNING_SERVER_PATTERN.test(command)) {
|
|
554
|
+
return 'Dev/start/preview server commands require timeout_ms. Use a short timeout to capture startup output.';
|
|
555
|
+
}
|
|
556
|
+
if (/\bgit\s+(checkout|restore)\b/.test(command) &&
|
|
557
|
+
!/\bgit\s+checkout\s+-b\b/.test(command)) {
|
|
558
|
+
return 'Refusing git checkout or git restore because it can discard local edits. Create a branch with git checkout -b or ask before reverting.';
|
|
559
|
+
}
|
|
560
|
+
return null;
|
|
561
|
+
}
|
|
562
|
+
function commandUsesSudo(command) {
|
|
563
|
+
return /\bsudo\b/.test(getUnquotedShellText(command));
|
|
564
|
+
}
|
|
565
|
+
export function sudoPromptFromTail(text) {
|
|
566
|
+
const tail = text.slice(-1000).replace(/\x1b\[[0-9;?]*[A-Za-z]/g, '');
|
|
567
|
+
const match = tail.match(/(?:\[sudo\][^\r\n]*password[^\r\n]*: ?|sudo[^\r\n]*password[^\r\n]*: ?|password[^\r\n]*: ?)$/i);
|
|
568
|
+
return match?.[0] ?? null;
|
|
569
|
+
}
|
|
570
|
+
function isSudoPromptLine(text) {
|
|
571
|
+
const cleaned = text.replace(/\x1b\[[0-9;?]*[A-Za-z]/g, '').trim();
|
|
572
|
+
return /(?:\[sudo\][^\r\n]*password[^\r\n]*:|sudo[^\r\n]*password[^\r\n]*:|^password[^\r\n]*:)/i.test(cleaned);
|
|
573
|
+
}
|
|
574
|
+
export function stripSudoPromptText(text) {
|
|
575
|
+
return text
|
|
576
|
+
.replace(/\r\n?/g, '\n')
|
|
577
|
+
.split('\n')
|
|
578
|
+
.filter((line) => !isSudoPromptLine(line))
|
|
579
|
+
.join('\n');
|
|
580
|
+
}
|
|
581
|
+
function redactSecrets(text, secrets) {
|
|
582
|
+
let redacted = text;
|
|
583
|
+
for (const secret of secrets) {
|
|
584
|
+
if (!secret)
|
|
585
|
+
continue;
|
|
586
|
+
redacted = redacted.split(secret).join('[sudo password redacted]');
|
|
587
|
+
}
|
|
588
|
+
return redacted;
|
|
589
|
+
}
|
|
590
|
+
function buildCommandEnv(cwd) {
|
|
591
|
+
const venvBin = detectVenvBin(cwd);
|
|
592
|
+
const envPath = buildCommandPath(process.env.PATH, venvBin ? [venvBin] : []);
|
|
593
|
+
return {
|
|
594
|
+
...process.env,
|
|
595
|
+
PATH: envPath,
|
|
596
|
+
VIRTUAL_ENV: venvBin
|
|
597
|
+
? path.dirname(venvBin)
|
|
598
|
+
: process.env.VIRTUAL_ENV || '',
|
|
599
|
+
CI: 'true',
|
|
600
|
+
npm_config_yes: 'true',
|
|
601
|
+
npm_config_progress: 'false',
|
|
602
|
+
npm_config_fund: 'false',
|
|
603
|
+
npm_config_audit: 'false',
|
|
604
|
+
NUXI_INIT_SKIP_PROMPT: 'true',
|
|
605
|
+
};
|
|
606
|
+
}
|
|
607
|
+
function sanitizePtyOutput(command, output, cwd, secrets) {
|
|
608
|
+
return sanitizeCommandText(command, stripSudoPromptText(redactSecrets(output, secrets)), cwd);
|
|
609
|
+
}
|
|
610
|
+
async function runPtyCommand(command, cwd, effectiveTimeout, exploratory, requestSudoPassword) {
|
|
611
|
+
return new Promise((resolve) => {
|
|
612
|
+
let output = '';
|
|
613
|
+
let timedOut = false;
|
|
614
|
+
let settled = false;
|
|
615
|
+
let requestingPassword = false;
|
|
616
|
+
let pendingLiveText = '';
|
|
617
|
+
let killTimer = null;
|
|
618
|
+
const sudoPromptAbort = new AbortController();
|
|
619
|
+
const sudoSecrets = new Set();
|
|
620
|
+
const shell = process.platform === 'win32'
|
|
621
|
+
? (process.env.ComSpec || 'cmd.exe')
|
|
622
|
+
: (process.env.SHELL || '/bin/sh');
|
|
623
|
+
const args = process.platform === 'win32'
|
|
624
|
+
? ['/d', '/s', '/c', command]
|
|
625
|
+
: ['-lc', command];
|
|
626
|
+
const child = pty.spawn(shell, args, {
|
|
627
|
+
cols: 120,
|
|
628
|
+
rows: 30,
|
|
629
|
+
cwd,
|
|
630
|
+
env: buildCommandEnv(cwd),
|
|
631
|
+
name: 'xterm-256color',
|
|
632
|
+
});
|
|
633
|
+
activeCommandPty = child;
|
|
634
|
+
const timeoutTimer = setTimeout(() => {
|
|
635
|
+
timedOut = true;
|
|
636
|
+
killPty(child, 'SIGTERM');
|
|
637
|
+
killTimer = setTimeout(() => killPty(child, 'SIGKILL'), 2000);
|
|
638
|
+
killTimer.unref?.();
|
|
639
|
+
}, effectiveTimeout);
|
|
640
|
+
timeoutTimer.unref?.();
|
|
641
|
+
const finish = (result) => {
|
|
642
|
+
if (settled)
|
|
643
|
+
return;
|
|
644
|
+
settled = true;
|
|
645
|
+
sudoPromptAbort.abort();
|
|
646
|
+
if (activeCommandPty === child) {
|
|
647
|
+
activeCommandPty = null;
|
|
648
|
+
activeCommandFinish = null;
|
|
649
|
+
}
|
|
650
|
+
clearTimeout(timeoutTimer);
|
|
651
|
+
if (killTimer)
|
|
652
|
+
clearTimeout(killTimer);
|
|
653
|
+
resolve(result);
|
|
654
|
+
};
|
|
655
|
+
activeCommandFinish = finish;
|
|
656
|
+
const flushLiveText = () => {
|
|
657
|
+
if (!pendingLiveText.trim() || isSudoPromptLine(pendingLiveText)) {
|
|
658
|
+
pendingLiveText = '';
|
|
659
|
+
return;
|
|
660
|
+
}
|
|
661
|
+
emitCommandOutput(`${pendingLiveText}\n`);
|
|
662
|
+
pendingLiveText = '';
|
|
663
|
+
};
|
|
664
|
+
const tuiMode = isTuiMode();
|
|
665
|
+
const writeLiveText = (text) => {
|
|
666
|
+
if (exploratory)
|
|
667
|
+
return;
|
|
668
|
+
const visibleText = stripSudoPromptText(redactSecrets(text, sudoSecrets));
|
|
669
|
+
if (tuiMode) {
|
|
670
|
+
pendingLiveText += visibleText.replace(/\r\n?/g, '\n');
|
|
671
|
+
const liveLines = pendingLiveText.split('\n');
|
|
672
|
+
pendingLiveText = liveLines.pop() ?? '';
|
|
673
|
+
const complete = liveLines
|
|
674
|
+
.filter((line) => line.trim() && !isSudoPromptLine(line))
|
|
675
|
+
.join('\n');
|
|
676
|
+
if (complete)
|
|
677
|
+
emitCommandOutput(`${complete}\n`);
|
|
678
|
+
return;
|
|
679
|
+
}
|
|
680
|
+
if (visibleText) {
|
|
681
|
+
process.stdout.write(visibleText);
|
|
682
|
+
}
|
|
683
|
+
};
|
|
684
|
+
child.onData((text) => {
|
|
685
|
+
output = appendCapturedText(output, text);
|
|
686
|
+
writeLiveText(text);
|
|
687
|
+
const prompt = sudoPromptFromTail(output);
|
|
688
|
+
if (!prompt || requestingPassword || settled)
|
|
689
|
+
return;
|
|
690
|
+
requestingPassword = true;
|
|
691
|
+
void requestSudoPassword({
|
|
692
|
+
command,
|
|
693
|
+
prompt,
|
|
694
|
+
signal: sudoPromptAbort.signal,
|
|
695
|
+
})
|
|
696
|
+
.then((password) => {
|
|
697
|
+
requestingPassword = false;
|
|
698
|
+
if (settled)
|
|
699
|
+
return;
|
|
700
|
+
if (password == null) {
|
|
701
|
+
killPty(child, 'SIGTERM');
|
|
702
|
+
finish({
|
|
703
|
+
exitCode: 1,
|
|
704
|
+
stdout: '',
|
|
705
|
+
stderr: 'Sudo authentication cancelled.',
|
|
706
|
+
output: 'Sudo authentication cancelled.',
|
|
707
|
+
timedOut: false,
|
|
708
|
+
cancelled: true,
|
|
709
|
+
});
|
|
710
|
+
return;
|
|
711
|
+
}
|
|
712
|
+
sudoSecrets.add(password);
|
|
713
|
+
child.write(`${password}\r`);
|
|
714
|
+
})
|
|
715
|
+
.catch(() => {
|
|
716
|
+
requestingPassword = false;
|
|
717
|
+
if (settled)
|
|
718
|
+
return;
|
|
719
|
+
killPty(child, 'SIGTERM');
|
|
720
|
+
finish({
|
|
721
|
+
exitCode: 1,
|
|
722
|
+
stdout: '',
|
|
723
|
+
stderr: 'Sudo authentication failed.',
|
|
724
|
+
output: 'Sudo authentication failed.',
|
|
725
|
+
timedOut: false,
|
|
726
|
+
});
|
|
727
|
+
});
|
|
728
|
+
});
|
|
729
|
+
child.onExit(({ exitCode }) => {
|
|
730
|
+
flushLiveText();
|
|
731
|
+
const outputText = sanitizePtyOutput(command, output, cwd, sudoSecrets);
|
|
732
|
+
if (exploratory && !tuiMode && outputText.trim()) {
|
|
733
|
+
process.stdout.write(outputText);
|
|
734
|
+
if (!outputText.endsWith('\n'))
|
|
735
|
+
process.stdout.write('\n');
|
|
736
|
+
}
|
|
737
|
+
if (timedOut) {
|
|
738
|
+
finish({
|
|
739
|
+
exitCode: exitCode || 1,
|
|
740
|
+
stdout: outputText,
|
|
741
|
+
stderr: '',
|
|
742
|
+
output: trimOutput(outputText.trim()),
|
|
743
|
+
timedOut: true,
|
|
744
|
+
});
|
|
745
|
+
return;
|
|
746
|
+
}
|
|
747
|
+
finish({
|
|
748
|
+
exitCode,
|
|
749
|
+
stdout: outputText,
|
|
750
|
+
stderr: '',
|
|
751
|
+
output: trimOutput(outputText.trim()),
|
|
752
|
+
timedOut: false,
|
|
753
|
+
});
|
|
754
|
+
});
|
|
755
|
+
});
|
|
756
|
+
}
|
|
757
|
+
export async function runCommand(command, cwd, { requestSudoPassword, timeout, } = {}) {
|
|
758
|
+
if (!isTuiMode()) {
|
|
759
|
+
console.log(chalk.cyan(`\n ▶ Running: ${command}`));
|
|
760
|
+
console.log(chalk.dim(` in: ${cwd}\n`));
|
|
761
|
+
}
|
|
762
|
+
const hasExplicitTimeout = typeof timeout === 'number' && timeout > 0;
|
|
763
|
+
const effectiveTimeout = hasExplicitTimeout ? timeout : DEFAULT_TIMEOUT;
|
|
764
|
+
const blockedReason = getBlockedCommandReason(command, hasExplicitTimeout, cwd);
|
|
765
|
+
if (blockedReason) {
|
|
766
|
+
if (!isTuiMode())
|
|
767
|
+
console.log(chalk.red(` ✖ ${blockedReason}`));
|
|
768
|
+
return {
|
|
769
|
+
exitCode: 1,
|
|
770
|
+
stdout: '',
|
|
771
|
+
stderr: blockedReason,
|
|
772
|
+
output: blockedReason,
|
|
773
|
+
timedOut: false,
|
|
774
|
+
blocked: true,
|
|
775
|
+
};
|
|
776
|
+
}
|
|
777
|
+
const exploratory = isExploratoryCommand(command);
|
|
778
|
+
if (requestSudoPassword && commandUsesSudo(command)) {
|
|
779
|
+
return runPtyCommand(command, cwd, effectiveTimeout, exploratory, requestSudoPassword);
|
|
780
|
+
}
|
|
781
|
+
return new Promise((resolve) => {
|
|
782
|
+
let stdout = '';
|
|
783
|
+
let stderr = '';
|
|
784
|
+
let timedOut = false;
|
|
785
|
+
let settled = false;
|
|
786
|
+
let killTimer = null;
|
|
787
|
+
const child = spawn(command, {
|
|
788
|
+
cwd,
|
|
789
|
+
shell: true,
|
|
790
|
+
detached: process.platform !== 'win32',
|
|
791
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
792
|
+
env: buildCommandEnv(cwd),
|
|
793
|
+
});
|
|
794
|
+
activeCommandChild = child;
|
|
795
|
+
child.stdin?.end();
|
|
796
|
+
const timeoutTimer = setTimeout(() => {
|
|
797
|
+
timedOut = true;
|
|
798
|
+
terminateChild(child, 'SIGTERM');
|
|
799
|
+
killTimer = setTimeout(() => terminateChild(child, 'SIGKILL'), 2000);
|
|
800
|
+
killTimer.unref?.();
|
|
801
|
+
}, effectiveTimeout);
|
|
802
|
+
timeoutTimer.unref?.();
|
|
803
|
+
const finish = (result) => {
|
|
804
|
+
if (settled)
|
|
805
|
+
return;
|
|
806
|
+
settled = true;
|
|
807
|
+
if (activeCommandChild === child) {
|
|
808
|
+
activeCommandChild = null;
|
|
809
|
+
activeCommandFinish = null;
|
|
810
|
+
}
|
|
811
|
+
clearTimeout(timeoutTimer);
|
|
812
|
+
if (killTimer) {
|
|
813
|
+
clearTimeout(killTimer);
|
|
814
|
+
}
|
|
815
|
+
resolve(result);
|
|
816
|
+
};
|
|
817
|
+
activeCommandFinish = finish;
|
|
818
|
+
const tuiMode = isTuiMode();
|
|
819
|
+
child.stdout?.on('data', (chunk) => {
|
|
820
|
+
const text = chunk.toString('utf-8');
|
|
821
|
+
stdout = appendCapturedText(stdout, text);
|
|
822
|
+
if (exploratory || tuiMode) {
|
|
823
|
+
if (tuiMode && !exploratory)
|
|
824
|
+
emitCommandOutput(text);
|
|
825
|
+
return;
|
|
826
|
+
}
|
|
827
|
+
const ok = process.stdout.write(text);
|
|
828
|
+
if (!ok) {
|
|
829
|
+
child.stdout?.pause();
|
|
830
|
+
process.stdout.once('drain', () => child.stdout?.resume());
|
|
831
|
+
}
|
|
832
|
+
});
|
|
833
|
+
child.stderr?.on('data', (chunk) => {
|
|
834
|
+
const text = chunk.toString('utf-8');
|
|
835
|
+
stderr = appendCapturedText(stderr, text);
|
|
836
|
+
if (exploratory || tuiMode) {
|
|
837
|
+
if (tuiMode && !exploratory)
|
|
838
|
+
emitCommandOutput(text);
|
|
839
|
+
return;
|
|
840
|
+
}
|
|
841
|
+
const ok = process.stderr.write(text);
|
|
842
|
+
if (!ok) {
|
|
843
|
+
child.stderr?.pause();
|
|
844
|
+
process.stderr.once('drain', () => child.stderr?.resume());
|
|
845
|
+
}
|
|
846
|
+
});
|
|
847
|
+
child.on('error', (error) => {
|
|
848
|
+
const stdoutText = sanitizeCommandText(command, stdout, cwd);
|
|
849
|
+
const stderrText = sanitizeCommandText(command, appendCapturedText(stderr, error.message ? `${error.message}\n` : ''), cwd);
|
|
850
|
+
const output = trimOutput([stdoutText, stderrText].filter(Boolean).join('\n').trim());
|
|
851
|
+
if (exploratory && !tuiMode) {
|
|
852
|
+
if (stdoutText.trim()) {
|
|
853
|
+
process.stdout.write(stdoutText);
|
|
854
|
+
if (!stdoutText.endsWith('\n'))
|
|
855
|
+
process.stdout.write('\n');
|
|
856
|
+
}
|
|
857
|
+
if (stderrText.trim()) {
|
|
858
|
+
process.stderr.write(stderrText);
|
|
859
|
+
if (!stderrText.endsWith('\n'))
|
|
860
|
+
process.stderr.write('\n');
|
|
861
|
+
}
|
|
862
|
+
}
|
|
863
|
+
if (!tuiMode)
|
|
864
|
+
console.log(chalk.red('\n ✖ Command exited with code 1'));
|
|
865
|
+
finish({
|
|
866
|
+
exitCode: 1,
|
|
867
|
+
stdout: stdoutText,
|
|
868
|
+
stderr: stderrText,
|
|
869
|
+
output,
|
|
870
|
+
timedOut,
|
|
871
|
+
});
|
|
872
|
+
});
|
|
873
|
+
child.on('close', (code, signal) => {
|
|
874
|
+
const stdoutText = sanitizeCommandText(command, stdout, cwd);
|
|
875
|
+
const stderrText = sanitizeCommandText(command, stderr, cwd);
|
|
876
|
+
if (exploratory && !tuiMode) {
|
|
877
|
+
if (stdoutText.trim()) {
|
|
878
|
+
process.stdout.write(stdoutText);
|
|
879
|
+
if (!stdoutText.endsWith('\n'))
|
|
880
|
+
process.stdout.write('\n');
|
|
881
|
+
}
|
|
882
|
+
if (stderrText.trim()) {
|
|
883
|
+
process.stderr.write(stderrText);
|
|
884
|
+
if (!stderrText.endsWith('\n'))
|
|
885
|
+
process.stderr.write('\n');
|
|
886
|
+
}
|
|
887
|
+
}
|
|
888
|
+
if (timedOut || signal === 'SIGTERM' || signal === 'SIGKILL') {
|
|
889
|
+
if (!tuiMode && !settled) {
|
|
890
|
+
console.log(chalk.red(`\n ✖ Command timed out after ${effectiveTimeout / 1000}s`));
|
|
891
|
+
}
|
|
892
|
+
finish({
|
|
893
|
+
exitCode: code ?? 1,
|
|
894
|
+
stdout: stdoutText,
|
|
895
|
+
stderr: stderrText,
|
|
896
|
+
output: trimOutput([stdoutText, stderrText].filter(Boolean).join('\n').trim()),
|
|
897
|
+
timedOut: true,
|
|
898
|
+
});
|
|
899
|
+
return;
|
|
900
|
+
}
|
|
901
|
+
if (code === 0) {
|
|
902
|
+
if (!tuiMode)
|
|
903
|
+
console.log(chalk.green('\n ✓ Command exited with code 0'));
|
|
904
|
+
finish({
|
|
905
|
+
exitCode: 0,
|
|
906
|
+
stdout: stdoutText,
|
|
907
|
+
stderr: stderrText,
|
|
908
|
+
output: trimOutput(stdoutText.trim()),
|
|
909
|
+
timedOut: false,
|
|
910
|
+
});
|
|
911
|
+
return;
|
|
912
|
+
}
|
|
913
|
+
if (!tuiMode)
|
|
914
|
+
console.log(chalk.red(`\n ✖ Command exited with code ${code ?? 1}`));
|
|
915
|
+
finish({
|
|
916
|
+
exitCode: code ?? 1,
|
|
917
|
+
stdout: stdoutText,
|
|
918
|
+
stderr: stderrText,
|
|
919
|
+
output: trimOutput([stdoutText, stderrText].filter(Boolean).join('\n').trim()),
|
|
920
|
+
timedOut: false,
|
|
921
|
+
});
|
|
922
|
+
});
|
|
923
|
+
});
|
|
924
|
+
}
|