@thegitai/cli 1.0.0-beta.9 → 1.0.0-preview.2
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/README.md +36 -2
- package/dist/bin/ai.js +138 -18
- package/dist/parsers/NOTICE +18 -0
- package/dist/src/agent-mode.js +5 -0
- package/dist/src/api/auth.js +3 -3
- package/dist/src/api/browser-login.js +0 -16
- package/dist/src/api/chat.js +59 -11
- package/dist/src/api/http.js +49 -1
- package/dist/src/api/models.js +26 -20
- package/dist/src/artifact-policy.js +3 -0
- package/dist/src/background-jobs.js +410 -0
- package/dist/src/cli-args.js +0 -5
- package/dist/src/client-environment.js +2 -0
- package/dist/src/colors.js +50 -0
- package/dist/src/core/clipboard.js +19 -0
- package/dist/src/core/image-path-extractor.js +144 -0
- package/dist/src/executor.js +48 -12
- package/dist/src/help-text.js +11 -6
- package/dist/src/markdown-renderer.js +1 -1
- package/dist/src/patcher.js +97 -12
- package/dist/src/project-orientation.js +99 -0
- package/dist/src/scanner.js +50 -12
- package/dist/src/scratch-dir.js +75 -0
- package/dist/src/secret-preview.js +0 -10
- package/dist/src/session-safety.js +0 -19
- package/dist/src/session-store.js +0 -1
- package/dist/src/todo-list.js +106 -0
- package/dist/src/tool-executor.js +194 -21
- package/dist/src/tools/delete-file.js +23 -5
- package/dist/src/tools/index.js +6 -0
- package/dist/src/tools/patch-file.js +33 -7
- package/dist/src/tools/path-suggest.js +81 -8
- package/dist/src/tools/read-document.js +2 -2
- package/dist/src/tools/read-file.js +17 -8
- package/dist/src/tools/replace-document-text.js +10 -12
- package/dist/src/tools/restore-checkpoint.js +1 -1
- package/dist/src/tools/run-command.js +109 -24
- package/dist/src/tools/run-node-script.js +27 -5
- package/dist/src/tools/shell-job-kill.js +48 -0
- package/dist/src/tools/shell-job-output.js +51 -0
- package/dist/src/tools/str-replace.js +33 -7
- package/dist/src/tools/undo-edit.js +1 -1
- package/dist/src/tools/update-todos.js +27 -0
- package/dist/src/tools/write-file.js +26 -6
- package/dist/src/tree-sitter-runtime.js +8 -1
- package/dist/src/ui/repl.js +342 -23
- package/dist/src/ui/tui/bridge.js +0 -4
- package/dist/src/ui/tui/build-frame.js +220 -24
- package/dist/src/ui/tui/shell-input.js +33 -4
- package/dist/src/ui/tui/terminal-title.js +81 -0
- package/dist/src/version.js +0 -6
- package/dist/vendor/web-tree-sitter/LICENSE +21 -0
- package/dist/vendor/web-tree-sitter/NOTICE +13 -0
- package/dist/vendor/web-tree-sitter/web-tree-sitter.cjs +4063 -0
- package/dist/vendor/web-tree-sitter/web-tree-sitter.wasm +0 -0
- package/package.json +14 -15
package/dist/src/executor.js
CHANGED
|
@@ -1,11 +1,25 @@
|
|
|
1
|
-
import chalk from '
|
|
2
|
-
import * as pty from '@homebridge/node-pty-prebuilt-multiarch';
|
|
1
|
+
import chalk from './colors.js';
|
|
3
2
|
import { execFileSync, spawn } from 'child_process';
|
|
4
3
|
import { existsSync, statSync } from 'fs';
|
|
4
|
+
import { createRequire } from 'node:module';
|
|
5
5
|
import os from 'os';
|
|
6
6
|
import path from 'path';
|
|
7
7
|
import { ARTIFACT_INSPECT_BLOCK_DIRS, getBlockedArtifactInspectDir, relativeProjectPath, } from './artifact-policy.js';
|
|
8
8
|
import { emitCommandOutput, isTuiMode } from './runtime-mode.js';
|
|
9
|
+
import { ensureSessionScratchDir, isInsideTheGitAiScratch, sessionScratchDir, } from './scratch-dir.js';
|
|
10
|
+
const requireFromHere = createRequire(import.meta.url);
|
|
11
|
+
let nodePtyCache;
|
|
12
|
+
function loadNodePty() {
|
|
13
|
+
if (nodePtyCache !== undefined)
|
|
14
|
+
return nodePtyCache;
|
|
15
|
+
try {
|
|
16
|
+
nodePtyCache = requireFromHere('@lydell/node-pty');
|
|
17
|
+
}
|
|
18
|
+
catch {
|
|
19
|
+
nodePtyCache = null;
|
|
20
|
+
}
|
|
21
|
+
return nodePtyCache;
|
|
22
|
+
}
|
|
9
23
|
const COMMON_TOOLCHAIN_BIN_DIRS = ['/usr/local/go/bin'];
|
|
10
24
|
function detectVenvBin(dir) {
|
|
11
25
|
for (const name of ['.venv', 'venv', 'env']) {
|
|
@@ -204,6 +218,7 @@ function isExistingDirectory(absPath) {
|
|
|
204
218
|
return false;
|
|
205
219
|
}
|
|
206
220
|
}
|
|
221
|
+
const OS_TEMP_BLOCK_MARKER = 'os-temp:';
|
|
207
222
|
function getBlockedOsTempInspection(rawToken, rootDir) {
|
|
208
223
|
const token = normalizeToken(rawToken);
|
|
209
224
|
if (!token || !path.isAbsolute(token))
|
|
@@ -211,6 +226,8 @@ function getBlockedOsTempInspection(rawToken, rootDir) {
|
|
|
211
226
|
const resolved = path.resolve(token);
|
|
212
227
|
if (!isInsideOsTemp(resolved))
|
|
213
228
|
return null;
|
|
229
|
+
if (isInsideTheGitAiScratch(resolved))
|
|
230
|
+
return null;
|
|
214
231
|
if (rootDir) {
|
|
215
232
|
const resolvedRoot = path.resolve(rootDir);
|
|
216
233
|
if (resolved === resolvedRoot ||
|
|
@@ -221,7 +238,7 @@ function getBlockedOsTempInspection(rawToken, rootDir) {
|
|
|
221
238
|
if (resolved === path.resolve(os.tmpdir()) ||
|
|
222
239
|
hasPathGlob(token) ||
|
|
223
240
|
isExistingDirectory(resolved)) {
|
|
224
|
-
return path.basename(os.tmpdir()) || os.tmpdir()
|
|
241
|
+
return `${OS_TEMP_BLOCK_MARKER}${path.basename(os.tmpdir()) || os.tmpdir()}`;
|
|
225
242
|
}
|
|
226
243
|
return null;
|
|
227
244
|
}
|
|
@@ -237,6 +254,9 @@ function findBlockedDirForToken(rawToken, rootDir, baseDir, allowBareDirMatch =
|
|
|
237
254
|
const resolved = path.isAbsolute(token)
|
|
238
255
|
? path.resolve(token)
|
|
239
256
|
: path.resolve(baseDir ?? rootDir, token);
|
|
257
|
+
const osTempResolved = getBlockedOsTempInspection(resolved, rootDir);
|
|
258
|
+
if (osTempResolved)
|
|
259
|
+
return osTempResolved;
|
|
240
260
|
return getBlockedProjectPathDir(resolved, rootDir);
|
|
241
261
|
}
|
|
242
262
|
if (!allowBareDirMatch || !BLOCKED_PATH_INSPECT_DIRS.has(token)) {
|
|
@@ -373,11 +393,16 @@ function findBlockedDirInCommandTokens(command, rootDir, baseDir) {
|
|
|
373
393
|
}
|
|
374
394
|
return null;
|
|
375
395
|
}
|
|
396
|
+
function expandTempEnvRefs(command) {
|
|
397
|
+
return command
|
|
398
|
+
.replace(/\$\{THEGITAI_SCRATCH_DIR[^}]*\}|\$THEGITAI_SCRATCH_DIR\b/g, sessionScratchDir())
|
|
399
|
+
.replace(/\$\{TMPDIR[^}]*\}|\$TMPDIR\b/g, process.env.TMPDIR || os.tmpdir());
|
|
400
|
+
}
|
|
376
401
|
function findIgnoredPathInspection(command, rootDir) {
|
|
377
402
|
if (!FILE_INSPECTION_COMMAND_PATTERN.test(command)) {
|
|
378
403
|
return null;
|
|
379
404
|
}
|
|
380
|
-
const haystack = maskNonPathIgnoreDirTokens(maskHereDocumentBodies(command));
|
|
405
|
+
const haystack = maskNonPathIgnoreDirTokens(maskHereDocumentBodies(expandTempEnvRefs(command)));
|
|
381
406
|
const baseDir = getCommandBaseDir(haystack, rootDir);
|
|
382
407
|
const blockedDir = findBlockedDirInCommandTokens(haystack, rootDir, baseDir);
|
|
383
408
|
if (blockedDir) {
|
|
@@ -434,7 +459,7 @@ function shouldDropOutputLine(line, rootDir, baseDir) {
|
|
|
434
459
|
function isLsDirectoryHeader(line) {
|
|
435
460
|
return /^\.?\/?.+:$/.test(line) && !line.includes(' ');
|
|
436
461
|
}
|
|
437
|
-
function sanitizeCommandText(command, text, rootDir) {
|
|
462
|
+
export function sanitizeCommandText(command, text, rootDir) {
|
|
438
463
|
if (!text)
|
|
439
464
|
return '';
|
|
440
465
|
const lines = text.split('\n');
|
|
@@ -509,7 +534,7 @@ export function cancelActiveCommand() {
|
|
|
509
534
|
cancelled: true,
|
|
510
535
|
});
|
|
511
536
|
}
|
|
512
|
-
function terminateChild(child, signal) {
|
|
537
|
+
export function terminateChild(child, signal) {
|
|
513
538
|
if (!child?.pid)
|
|
514
539
|
return;
|
|
515
540
|
if (process.platform === 'win32') {
|
|
@@ -537,10 +562,17 @@ function terminateChild(child, signal) {
|
|
|
537
562
|
}
|
|
538
563
|
}
|
|
539
564
|
export function getBlockedPathInspectDir(command, rootDir) {
|
|
540
|
-
|
|
565
|
+
const blocked = findIgnoredPathInspection(command, rootDir);
|
|
566
|
+
return blocked?.startsWith(OS_TEMP_BLOCK_MARKER)
|
|
567
|
+
? blocked.slice(OS_TEMP_BLOCK_MARKER.length)
|
|
568
|
+
: blocked;
|
|
541
569
|
}
|
|
542
570
|
export function getBlockedCommandReason(command, hasTimeout, rootDir) {
|
|
543
571
|
const ignoredDir = findIgnoredPathInspection(command, rootDir);
|
|
572
|
+
if (ignoredDir?.startsWith(OS_TEMP_BLOCK_MARKER)) {
|
|
573
|
+
const tempName = ignoredDir.slice(OS_TEMP_BLOCK_MARKER.length);
|
|
574
|
+
return `Listing or scanning the shared OS temp directory (${tempName}) is blocked because it can contain other users' and processes' files. Use the session scratch directory ${ensureSessionScratchDir()} for temporary scripts and files — creating, running, and listing are all allowed there — or reference an exact file path.`;
|
|
575
|
+
}
|
|
544
576
|
if (ignoredDir) {
|
|
545
577
|
return `Command inspects an off-limits generated or dependency directory (${ignoredDir}). Avoid that path.`;
|
|
546
578
|
}
|
|
@@ -559,7 +591,7 @@ export function getBlockedCommandReason(command, hasTimeout, rootDir) {
|
|
|
559
591
|
}
|
|
560
592
|
return null;
|
|
561
593
|
}
|
|
562
|
-
function commandUsesSudo(command) {
|
|
594
|
+
export function commandUsesSudo(command) {
|
|
563
595
|
return /\bsudo\b/.test(getUnquotedShellText(command));
|
|
564
596
|
}
|
|
565
597
|
export function sudoPromptFromTail(text) {
|
|
@@ -587,7 +619,7 @@ function redactSecrets(text, secrets) {
|
|
|
587
619
|
}
|
|
588
620
|
return redacted;
|
|
589
621
|
}
|
|
590
|
-
function buildCommandEnv(cwd) {
|
|
622
|
+
export function buildCommandEnv(cwd) {
|
|
591
623
|
const venvBin = detectVenvBin(cwd);
|
|
592
624
|
const envPath = buildCommandPath(process.env.PATH, venvBin ? [venvBin] : []);
|
|
593
625
|
return {
|
|
@@ -602,12 +634,13 @@ function buildCommandEnv(cwd) {
|
|
|
602
634
|
npm_config_fund: 'false',
|
|
603
635
|
npm_config_audit: 'false',
|
|
604
636
|
NUXI_INIT_SKIP_PROMPT: 'true',
|
|
637
|
+
THEGITAI_SCRATCH_DIR: ensureSessionScratchDir(),
|
|
605
638
|
};
|
|
606
639
|
}
|
|
607
640
|
function sanitizePtyOutput(command, output, cwd, secrets) {
|
|
608
641
|
return sanitizeCommandText(command, stripSudoPromptText(redactSecrets(output, secrets)), cwd);
|
|
609
642
|
}
|
|
610
|
-
async function runPtyCommand(command, cwd, effectiveTimeout, exploratory, requestSudoPassword) {
|
|
643
|
+
async function runPtyCommand(command, cwd, effectiveTimeout, exploratory, requestSudoPassword, nodePty) {
|
|
611
644
|
return new Promise((resolve) => {
|
|
612
645
|
let output = '';
|
|
613
646
|
let timedOut = false;
|
|
@@ -623,7 +656,7 @@ async function runPtyCommand(command, cwd, effectiveTimeout, exploratory, reques
|
|
|
623
656
|
const args = process.platform === 'win32'
|
|
624
657
|
? ['/d', '/s', '/c', command]
|
|
625
658
|
: ['-lc', command];
|
|
626
|
-
const child =
|
|
659
|
+
const child = nodePty.spawn(shell, args, {
|
|
627
660
|
cols: 120,
|
|
628
661
|
rows: 30,
|
|
629
662
|
cwd,
|
|
@@ -776,7 +809,10 @@ export async function runCommand(command, cwd, { requestSudoPassword, timeout, }
|
|
|
776
809
|
}
|
|
777
810
|
const exploratory = isExploratoryCommand(command);
|
|
778
811
|
if (requestSudoPassword && commandUsesSudo(command)) {
|
|
779
|
-
|
|
812
|
+
const nodePty = loadNodePty();
|
|
813
|
+
if (nodePty) {
|
|
814
|
+
return runPtyCommand(command, cwd, effectiveTimeout, exploratory, requestSudoPassword, nodePty);
|
|
815
|
+
}
|
|
780
816
|
}
|
|
781
817
|
return new Promise((resolve) => {
|
|
782
818
|
let stdout = '';
|
package/dist/src/help-text.js
CHANGED
|
@@ -1,9 +1,5 @@
|
|
|
1
|
-
import chalk from '
|
|
1
|
+
import chalk from './colors.js';
|
|
2
2
|
import { getCliVersion, getPlatformTag } from './version.js';
|
|
3
|
-
// The bound keys (Enter/Esc/Ctrl+C/Tab/arrows) are identical across platforms in
|
|
4
|
-
// a terminal. The one thing that genuinely differs is the terminal's paste
|
|
5
|
-
// shortcut, so surface the one for the host OS (right-click paste works
|
|
6
|
-
// everywhere regardless).
|
|
7
3
|
function pasteShortcutForPlatform() {
|
|
8
4
|
switch (process.platform) {
|
|
9
5
|
case 'darwin':
|
|
@@ -77,6 +73,10 @@ const HELP_MARKDOWN = [
|
|
|
77
73
|
'- `/model` — list supported models and pick one',
|
|
78
74
|
'- `/model <id>` — switch the active model without clearing history',
|
|
79
75
|
'- `/resume` — resume a saved session for this repo',
|
|
76
|
+
'- `/jobs` — manage long-running commands like dev servers and watchers:',
|
|
77
|
+
' browse them, press Enter to expand one and read its output, k to stop it',
|
|
78
|
+
'- `/jobs output <id>` — print one job\'s full captured output',
|
|
79
|
+
'- `/jobs kill <id>` — stop one background job',
|
|
80
80
|
'- `/clear` — clear the current conversation history',
|
|
81
81
|
'- `/exit` — quit the session',
|
|
82
82
|
'',
|
|
@@ -89,6 +89,12 @@ const HELP_MARKDOWN = [
|
|
|
89
89
|
' command and keeps the password masked and local.',
|
|
90
90
|
'- `-y` / `--yes` at startup auto-approves every shell command and file',
|
|
91
91
|
' edit for the whole session — use with care.',
|
|
92
|
+
'- Long-running commands (e.g. dev servers) can run as managed background',
|
|
93
|
+
' jobs after the same approval as any other command. Background output',
|
|
94
|
+
' stays quiet once the model has responded; the footer shows only a compact',
|
|
95
|
+
' shell-running indicator, `/jobs` lists, inspects, and kills jobs, and',
|
|
96
|
+
' killed jobs disappear immediately. Every job is killed when the session',
|
|
97
|
+
' ends.',
|
|
92
98
|
'- File and shell operations are confined to the target repo root.',
|
|
93
99
|
'- Sensitive directories (`.git`, `node_modules`, build output) are',
|
|
94
100
|
' never indexed.',
|
|
@@ -104,7 +110,6 @@ const HELP_MARKDOWN = [
|
|
|
104
110
|
' message — there is no client-side debug mode by design.',
|
|
105
111
|
].join('\n');
|
|
106
112
|
export function formatAboutCard() {
|
|
107
|
-
// Fenced so the column alignment survives terminal markdown rendering.
|
|
108
113
|
return [
|
|
109
114
|
'```',
|
|
110
115
|
'TheGitAI',
|
package/dist/src/patcher.js
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
|
-
import chalk from '
|
|
2
|
-
import { existsSync, lstatSync, mkdirSync, readFileSync, unlinkSync, writeFileSync, } from 'fs';
|
|
1
|
+
import chalk from './colors.js';
|
|
2
|
+
import { chmodSync, closeSync, constants, existsSync, fchmodSync, fstatSync, ftruncateSync, lstatSync, mkdirSync, openSync, readFileSync, realpathSync, unlinkSync, writeFileSync, } from 'fs';
|
|
3
3
|
import path from 'path';
|
|
4
4
|
import { createInterface } from 'readline';
|
|
5
5
|
import { runCommand } from './executor.js';
|
|
6
|
+
import { ensureSessionScratchDir, isInsideTheGitAiScratch, isWithinSessionScratchDir, } from './scratch-dir.js';
|
|
6
7
|
import { isTuiMode } from './runtime-mode.js';
|
|
7
8
|
import { truncate } from './utils.js';
|
|
8
9
|
function parseUnifiedDiff(patchText) {
|
|
@@ -143,17 +144,92 @@ export function renderDiffPreview(filePath, patchText) {
|
|
|
143
144
|
function normalizeRoot(rootDir) {
|
|
144
145
|
return path.resolve(rootDir);
|
|
145
146
|
}
|
|
146
|
-
|
|
147
|
+
function expandScratchPath(filePath) {
|
|
148
|
+
const match = filePath.match(/^(?:\$THEGITAI_SCRATCH_DIR|\$\{THEGITAI_SCRATCH_DIR\})(?:[\\/](.*))?$/);
|
|
149
|
+
if (!match)
|
|
150
|
+
return filePath;
|
|
151
|
+
const root = ensureSessionScratchDir();
|
|
152
|
+
return match[1] ? path.join(root, match[1]) : root;
|
|
153
|
+
}
|
|
154
|
+
export function classifyProjectPath(rootDir, filePath) {
|
|
147
155
|
const absRoot = normalizeRoot(rootDir);
|
|
148
|
-
const absPath = path.resolve(absRoot, filePath);
|
|
156
|
+
const absPath = path.resolve(absRoot, expandScratchPath(filePath));
|
|
157
|
+
if (isWithinSessionScratchDir(absPath)) {
|
|
158
|
+
return absPath !== path.resolve(ensureSessionScratchDir()) &&
|
|
159
|
+
isInsideTheGitAiScratch(absPath)
|
|
160
|
+
? 'scratch'
|
|
161
|
+
: 'outside';
|
|
162
|
+
}
|
|
149
163
|
const relative = path.relative(absRoot, absPath);
|
|
150
|
-
if (relative.startsWith('..')
|
|
151
|
-
|
|
164
|
+
if (!relative.startsWith('..') && !path.isAbsolute(relative)) {
|
|
165
|
+
return 'project';
|
|
166
|
+
}
|
|
167
|
+
return 'outside';
|
|
168
|
+
}
|
|
169
|
+
export function resolveProjectPath(rootDir, filePath) {
|
|
170
|
+
const absRoot = normalizeRoot(rootDir);
|
|
171
|
+
const absPath = path.resolve(absRoot, expandScratchPath(filePath));
|
|
172
|
+
if (classifyProjectPath(rootDir, filePath) === 'outside') {
|
|
173
|
+
throw new Error(`Refusing to access path outside the project root: ${filePath}. Allowed locations are the project root and the session scratch directory ($THEGITAI_SCRATCH_DIR).`);
|
|
152
174
|
}
|
|
153
175
|
return absPath;
|
|
154
176
|
}
|
|
177
|
+
function mkdirForWrite(absPath, scratchPath) {
|
|
178
|
+
const parent = path.dirname(absPath);
|
|
179
|
+
if (!scratchPath) {
|
|
180
|
+
mkdirSync(parent, { recursive: true });
|
|
181
|
+
return;
|
|
182
|
+
}
|
|
183
|
+
const scratchRoot = path.resolve(ensureSessionScratchDir());
|
|
184
|
+
const relative = path.relative(scratchRoot, parent);
|
|
185
|
+
if (relative.startsWith('..') || path.isAbsolute(relative)) {
|
|
186
|
+
throw new Error(`Refusing to create a directory outside the session scratch root: ${parent}`);
|
|
187
|
+
}
|
|
188
|
+
let current = scratchRoot;
|
|
189
|
+
for (const segment of relative.split(path.sep).filter(Boolean)) {
|
|
190
|
+
current = path.join(current, segment);
|
|
191
|
+
try {
|
|
192
|
+
mkdirSync(current, { mode: 0o700 });
|
|
193
|
+
}
|
|
194
|
+
catch (error) {
|
|
195
|
+
if (error?.code !== 'EEXIST')
|
|
196
|
+
throw error;
|
|
197
|
+
}
|
|
198
|
+
const stat = lstatSync(current);
|
|
199
|
+
if (stat.isSymbolicLink() || !stat.isDirectory()) {
|
|
200
|
+
throw new Error(`Refusing to traverse unsafe scratch directory: ${current}`);
|
|
201
|
+
}
|
|
202
|
+
if (process.platform !== 'win32')
|
|
203
|
+
chmodSync(current, 0o700);
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
function writeScratchFile(absPath, content) {
|
|
207
|
+
const scratchRoot = realpathSync(ensureSessionScratchDir());
|
|
208
|
+
const parent = realpathSync(path.dirname(absPath));
|
|
209
|
+
const relativeParent = path.relative(scratchRoot, parent);
|
|
210
|
+
if (relativeParent.startsWith('..') || path.isAbsolute(relativeParent)) {
|
|
211
|
+
throw new Error(`Refusing to write through an unsafe scratch directory: ${absPath}`);
|
|
212
|
+
}
|
|
213
|
+
const verifiedPath = path.join(parent, path.basename(absPath));
|
|
214
|
+
const noFollow = typeof constants.O_NOFOLLOW === 'number' ? constants.O_NOFOLLOW : 0;
|
|
215
|
+
const fd = openSync(verifiedPath, constants.O_WRONLY | constants.O_CREAT | noFollow, 0o600);
|
|
216
|
+
try {
|
|
217
|
+
const stat = fstatSync(fd);
|
|
218
|
+
if (!stat.isFile() || stat.nlink > 1) {
|
|
219
|
+
throw new Error(`Refusing to write an unsafe scratch file: ${absPath}`);
|
|
220
|
+
}
|
|
221
|
+
ftruncateSync(fd, 0);
|
|
222
|
+
if (process.platform !== 'win32')
|
|
223
|
+
fchmodSync(fd, 0o600);
|
|
224
|
+
writeFileSync(fd, content);
|
|
225
|
+
}
|
|
226
|
+
finally {
|
|
227
|
+
closeSync(fd);
|
|
228
|
+
}
|
|
229
|
+
}
|
|
155
230
|
export function writeProjectFile(rootDir, filePath, content) {
|
|
156
231
|
const absPath = resolveProjectPath(rootDir, filePath);
|
|
232
|
+
const scratchPath = classifyProjectPath(rootDir, filePath) === 'scratch';
|
|
157
233
|
if (existsSync(absPath)) {
|
|
158
234
|
try {
|
|
159
235
|
const existingContent = readFileSync(absPath, 'utf-8');
|
|
@@ -162,15 +238,20 @@ export function writeProjectFile(rootDir, filePath, content) {
|
|
|
162
238
|
}
|
|
163
239
|
}
|
|
164
240
|
catch {
|
|
165
|
-
// If we can't read it for some reason, proceed with write
|
|
166
241
|
}
|
|
167
242
|
}
|
|
168
|
-
|
|
169
|
-
|
|
243
|
+
mkdirForWrite(absPath, scratchPath);
|
|
244
|
+
if (scratchPath) {
|
|
245
|
+
writeScratchFile(absPath, content);
|
|
246
|
+
}
|
|
247
|
+
else {
|
|
248
|
+
writeFileSync(absPath, content, 'utf-8');
|
|
249
|
+
}
|
|
170
250
|
return { absPath, changed: true };
|
|
171
251
|
}
|
|
172
252
|
export function writeProjectFileBuffer(rootDir, filePath, content) {
|
|
173
253
|
const absPath = resolveProjectPath(rootDir, filePath);
|
|
254
|
+
const scratchPath = classifyProjectPath(rootDir, filePath) === 'scratch';
|
|
174
255
|
if (existsSync(absPath)) {
|
|
175
256
|
try {
|
|
176
257
|
const existingContent = readFileSync(absPath);
|
|
@@ -179,11 +260,15 @@ export function writeProjectFileBuffer(rootDir, filePath, content) {
|
|
|
179
260
|
}
|
|
180
261
|
}
|
|
181
262
|
catch {
|
|
182
|
-
// If we can't read it for some reason, proceed with write
|
|
183
263
|
}
|
|
184
264
|
}
|
|
185
|
-
|
|
186
|
-
|
|
265
|
+
mkdirForWrite(absPath, scratchPath);
|
|
266
|
+
if (scratchPath) {
|
|
267
|
+
writeScratchFile(absPath, content);
|
|
268
|
+
}
|
|
269
|
+
else {
|
|
270
|
+
writeFileSync(absPath, content);
|
|
271
|
+
}
|
|
187
272
|
return { absPath, changed: true };
|
|
188
273
|
}
|
|
189
274
|
export function deleteProjectFile(rootDir, filePath) {
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import { closeSync, constants, fstatSync, lstatSync, openSync, readdirSync, readFileSync, } from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { shouldIgnoreArtifactPath } from './artifact-policy.js';
|
|
4
|
+
const MAX_TOP_LEVEL_ENTRIES = 80;
|
|
5
|
+
const MAX_DECLARED_TASKS = 40;
|
|
6
|
+
const MAX_NAME_CHARS = 120;
|
|
7
|
+
const MAX_PACKAGE_JSON_BYTES = 1024 * 1024;
|
|
8
|
+
function normalizeName(value) {
|
|
9
|
+
if (typeof value !== 'string')
|
|
10
|
+
return null;
|
|
11
|
+
const normalized = value
|
|
12
|
+
.replace(/[\u0000-\u001f\u007f]/g, ' ')
|
|
13
|
+
.replace(/\s+/g, ' ')
|
|
14
|
+
.trim()
|
|
15
|
+
.slice(0, MAX_NAME_CHARS);
|
|
16
|
+
return normalized || null;
|
|
17
|
+
}
|
|
18
|
+
function normalizeNames(value, limit) {
|
|
19
|
+
if (!Array.isArray(value))
|
|
20
|
+
return [];
|
|
21
|
+
const names = [];
|
|
22
|
+
const seen = new Set();
|
|
23
|
+
for (const item of value) {
|
|
24
|
+
const name = normalizeName(item);
|
|
25
|
+
if (!name || seen.has(name))
|
|
26
|
+
continue;
|
|
27
|
+
seen.add(name);
|
|
28
|
+
names.push(name);
|
|
29
|
+
if (names.length >= limit)
|
|
30
|
+
break;
|
|
31
|
+
}
|
|
32
|
+
return names;
|
|
33
|
+
}
|
|
34
|
+
function readPackageTasks(rootDir) {
|
|
35
|
+
const packagePath = path.join(rootDir, 'package.json');
|
|
36
|
+
let fd = null;
|
|
37
|
+
try {
|
|
38
|
+
const pathStat = lstatSync(packagePath);
|
|
39
|
+
if (pathStat.isSymbolicLink() ||
|
|
40
|
+
!pathStat.isFile() ||
|
|
41
|
+
pathStat.nlink > 1 ||
|
|
42
|
+
pathStat.size > MAX_PACKAGE_JSON_BYTES) {
|
|
43
|
+
return [];
|
|
44
|
+
}
|
|
45
|
+
const noFollow = typeof constants.O_NOFOLLOW === 'number' ? constants.O_NOFOLLOW : 0;
|
|
46
|
+
fd = openSync(packagePath, constants.O_RDONLY | noFollow);
|
|
47
|
+
const fileStat = fstatSync(fd);
|
|
48
|
+
if (!fileStat.isFile() ||
|
|
49
|
+
fileStat.nlink > 1 ||
|
|
50
|
+
fileStat.size > MAX_PACKAGE_JSON_BYTES ||
|
|
51
|
+
fileStat.dev !== pathStat.dev ||
|
|
52
|
+
fileStat.ino !== pathStat.ino) {
|
|
53
|
+
return [];
|
|
54
|
+
}
|
|
55
|
+
const parsed = JSON.parse(readFileSync(fd, 'utf8'));
|
|
56
|
+
if (!parsed?.scripts || typeof parsed.scripts !== 'object')
|
|
57
|
+
return [];
|
|
58
|
+
return normalizeNames(Object.keys(parsed.scripts), MAX_DECLARED_TASKS);
|
|
59
|
+
}
|
|
60
|
+
catch {
|
|
61
|
+
return [];
|
|
62
|
+
}
|
|
63
|
+
finally {
|
|
64
|
+
if (fd !== null)
|
|
65
|
+
closeSync(fd);
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
export function normalizeProjectOrientation(value) {
|
|
69
|
+
if (!value || typeof value !== 'object')
|
|
70
|
+
return null;
|
|
71
|
+
const record = value;
|
|
72
|
+
const topLevelEntries = normalizeNames(record.topLevelEntries, MAX_TOP_LEVEL_ENTRIES);
|
|
73
|
+
const packageScripts = normalizeNames(record.packageScripts, MAX_DECLARED_TASKS);
|
|
74
|
+
if (topLevelEntries.length === 0 && packageScripts.length === 0)
|
|
75
|
+
return null;
|
|
76
|
+
return {
|
|
77
|
+
topLevelEntries,
|
|
78
|
+
packageScripts,
|
|
79
|
+
truncated: record.truncated === true,
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
export function collectProjectOrientation(rootDir) {
|
|
83
|
+
try {
|
|
84
|
+
const entries = readdirSync(rootDir, { withFileTypes: true })
|
|
85
|
+
.filter((entry) => !shouldIgnoreArtifactPath(entry.name))
|
|
86
|
+
.sort((a, b) => a.name.localeCompare(b.name));
|
|
87
|
+
const topLevelEntries = entries
|
|
88
|
+
.slice(0, MAX_TOP_LEVEL_ENTRIES)
|
|
89
|
+
.map((entry) => `${entry.name}${entry.isDirectory() ? '/' : ''}`);
|
|
90
|
+
return normalizeProjectOrientation({
|
|
91
|
+
topLevelEntries,
|
|
92
|
+
packageScripts: readPackageTasks(rootDir),
|
|
93
|
+
truncated: entries.length > MAX_TOP_LEVEL_ENTRIES,
|
|
94
|
+
});
|
|
95
|
+
}
|
|
96
|
+
catch {
|
|
97
|
+
return null;
|
|
98
|
+
}
|
|
99
|
+
}
|
package/dist/src/scanner.js
CHANGED
|
@@ -1,14 +1,12 @@
|
|
|
1
1
|
import { execSync } from 'child_process';
|
|
2
|
-
import { readFileSync, statSync } from 'fs';
|
|
3
|
-
import { glob } from 'glob';
|
|
2
|
+
import { readdirSync, readFileSync, statSync } from 'fs';
|
|
4
3
|
import path from 'path';
|
|
5
4
|
import { getNodePrimarySignature, getStructuralChildren, parseRepoSource, } from './tree-sitter-runtime.js';
|
|
6
|
-
import {
|
|
5
|
+
import { ARTIFACT_IGNORE_DIRS, ARTIFACT_IGNORE_FILES, ARTIFACT_INSPECT_BLOCK_DIRS, BINARY_ARTIFACT_EXTENSIONS, isSensitiveProjectPath, shouldIgnoreArtifactPath, } from './artifact-policy.js';
|
|
7
6
|
const BINARY_EXTENSIONS = BINARY_ARTIFACT_EXTENSIONS;
|
|
8
7
|
const ALWAYS_IGNORE_FILES = ARTIFACT_IGNORE_FILES;
|
|
9
8
|
export const ALWAYS_IGNORE_DIRS = ARTIFACT_IGNORE_DIRS;
|
|
10
9
|
export const BLOCKED_PATH_INSPECT_DIRS = ARTIFACT_INSPECT_BLOCK_DIRS;
|
|
11
|
-
const FALLBACK_IGNORE = ARTIFACT_FALLBACK_IGNORE_GLOBS;
|
|
12
10
|
export const SCANNER_MAX_SOURCE_FILE_BYTES = 100 * 1024;
|
|
13
11
|
const MAX_FILE_SIZE = SCANNER_MAX_SOURCE_FILE_BYTES;
|
|
14
12
|
const MAX_CHUNKS = 2000;
|
|
@@ -29,16 +27,56 @@ function getFiles(rootDir, { limit = Infinity } = {}) {
|
|
|
29
27
|
return Number.isFinite(limit) ? files.slice(0, limit) : files;
|
|
30
28
|
}
|
|
31
29
|
catch {
|
|
32
|
-
return
|
|
33
|
-
.sync('**/*', {
|
|
34
|
-
cwd: rootDir,
|
|
35
|
-
nodir: true,
|
|
36
|
-
dot: false,
|
|
37
|
-
ignore: FALLBACK_IGNORE,
|
|
38
|
-
})
|
|
39
|
-
.slice(0, Number.isFinite(limit) ? limit : undefined);
|
|
30
|
+
return walkProjectFilesFallback(rootDir, Number.isFinite(limit) ? limit : Infinity);
|
|
40
31
|
}
|
|
41
32
|
}
|
|
33
|
+
const FALLBACK_LOCKFILES = new Set([
|
|
34
|
+
'package-lock.json',
|
|
35
|
+
'yarn.lock',
|
|
36
|
+
'pnpm-lock.yaml',
|
|
37
|
+
]);
|
|
38
|
+
function isFallbackIgnoredFile(relPath, fileName) {
|
|
39
|
+
if (shouldIgnorePath(relPath))
|
|
40
|
+
return true;
|
|
41
|
+
if (fileName.endsWith('.lock'))
|
|
42
|
+
return true;
|
|
43
|
+
return FALLBACK_LOCKFILES.has(fileName);
|
|
44
|
+
}
|
|
45
|
+
function walkProjectFilesFallback(rootDir, limit) {
|
|
46
|
+
const results = [];
|
|
47
|
+
const visit = (relDir) => {
|
|
48
|
+
if (results.length >= limit)
|
|
49
|
+
return;
|
|
50
|
+
let entries;
|
|
51
|
+
try {
|
|
52
|
+
entries = readdirSync(path.join(rootDir, relDir), {
|
|
53
|
+
withFileTypes: true,
|
|
54
|
+
});
|
|
55
|
+
}
|
|
56
|
+
catch {
|
|
57
|
+
return;
|
|
58
|
+
}
|
|
59
|
+
for (const entry of entries) {
|
|
60
|
+
if (results.length >= limit)
|
|
61
|
+
return;
|
|
62
|
+
const name = entry.name;
|
|
63
|
+
if (name.startsWith('.'))
|
|
64
|
+
continue;
|
|
65
|
+
const relPath = relDir ? `${relDir}/${name}` : name;
|
|
66
|
+
if (entry.isDirectory()) {
|
|
67
|
+
if (ALWAYS_IGNORE_DIRS.has(name) || shouldIgnoreArtifactPath(relPath)) {
|
|
68
|
+
continue;
|
|
69
|
+
}
|
|
70
|
+
visit(relPath);
|
|
71
|
+
}
|
|
72
|
+
else if (entry.isFile() && !isFallbackIgnoredFile(relPath, name)) {
|
|
73
|
+
results.push(relPath);
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
};
|
|
77
|
+
visit('');
|
|
78
|
+
return results;
|
|
79
|
+
}
|
|
42
80
|
function shouldSkipFile(relPath, stat) {
|
|
43
81
|
if (shouldIgnorePath(relPath))
|
|
44
82
|
return true;
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import { chmodSync, lstatSync, mkdtempSync } from 'node:fs';
|
|
2
|
+
import os from 'node:os';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
const scratchDirs = new Map();
|
|
5
|
+
let activeSessionId = 'default';
|
|
6
|
+
export function setScratchSession(sessionId) {
|
|
7
|
+
activeSessionId = String(sessionId ?? '').trim() || 'default';
|
|
8
|
+
}
|
|
9
|
+
function allocateSessionScratchDir() {
|
|
10
|
+
const dir = mkdtempSync(path.join(os.tmpdir(), 'thegitai-'));
|
|
11
|
+
scratchDirs.set(activeSessionId, dir);
|
|
12
|
+
return dir;
|
|
13
|
+
}
|
|
14
|
+
export function sessionScratchDir() {
|
|
15
|
+
return scratchDirs.get(activeSessionId) ?? allocateSessionScratchDir();
|
|
16
|
+
}
|
|
17
|
+
function isOwnedDirectory(dir) {
|
|
18
|
+
try {
|
|
19
|
+
const st = lstatSync(dir);
|
|
20
|
+
if (st.isSymbolicLink() || !st.isDirectory())
|
|
21
|
+
return false;
|
|
22
|
+
if (process.platform !== 'win32' &&
|
|
23
|
+
typeof process.getuid === 'function' &&
|
|
24
|
+
st.uid !== process.getuid()) {
|
|
25
|
+
return false;
|
|
26
|
+
}
|
|
27
|
+
return true;
|
|
28
|
+
}
|
|
29
|
+
catch {
|
|
30
|
+
return false;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
export function ensureSessionScratchDir() {
|
|
34
|
+
let dir = sessionScratchDir();
|
|
35
|
+
if (!isOwnedDirectory(dir)) {
|
|
36
|
+
dir = allocateSessionScratchDir();
|
|
37
|
+
}
|
|
38
|
+
if (process.platform !== 'win32') {
|
|
39
|
+
chmodSync(dir, 0o700);
|
|
40
|
+
}
|
|
41
|
+
return dir;
|
|
42
|
+
}
|
|
43
|
+
function hasUnsafeScratchComponent(root, relativePath) {
|
|
44
|
+
let current = root;
|
|
45
|
+
for (const segment of relativePath.split(path.sep).filter(Boolean)) {
|
|
46
|
+
current = path.join(current, segment);
|
|
47
|
+
try {
|
|
48
|
+
const stat = lstatSync(current);
|
|
49
|
+
if (stat.isSymbolicLink())
|
|
50
|
+
return true;
|
|
51
|
+
if (stat.isDirectory())
|
|
52
|
+
continue;
|
|
53
|
+
if (!stat.isFile() || stat.nlink > 1)
|
|
54
|
+
return true;
|
|
55
|
+
}
|
|
56
|
+
catch (error) {
|
|
57
|
+
return error?.code !== 'ENOENT';
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
return false;
|
|
61
|
+
}
|
|
62
|
+
export function isWithinSessionScratchDir(absPath) {
|
|
63
|
+
const root = path.resolve(ensureSessionScratchDir());
|
|
64
|
+
const resolved = path.resolve(absPath);
|
|
65
|
+
const relative = path.relative(root, resolved);
|
|
66
|
+
return !relative.startsWith('..') && !path.isAbsolute(relative);
|
|
67
|
+
}
|
|
68
|
+
export function isInsideTheGitAiScratch(absPath) {
|
|
69
|
+
const root = path.resolve(ensureSessionScratchDir());
|
|
70
|
+
const resolved = path.resolve(absPath);
|
|
71
|
+
const relative = path.relative(root, resolved);
|
|
72
|
+
if (!isWithinSessionScratchDir(resolved))
|
|
73
|
+
return false;
|
|
74
|
+
return !hasUnsafeScratchComponent(root, relative);
|
|
75
|
+
}
|
|
@@ -6,11 +6,7 @@ const PRIVATE_KEY_REDACTION = '[REDACTED: private key]';
|
|
|
6
6
|
const SENSITIVE_JSON_KEY_PATTERN = /^(?:private[_-]?key|secret|api[_-]?key|password|client_secret|refresh_token|access_token|id_token|auth_provider_x509_cert_url)$/i;
|
|
7
7
|
const PEM_BLOCK_PATTERN = /-----BEGIN [^-]*(?:PRIVATE KEY|SECRET KEY|OPENSSH PRIVATE KEY)[\s\S]*?-----END [^-]*(?:PRIVATE KEY|SECRET KEY|OPENSSH PRIVATE KEY)-----/gi;
|
|
8
8
|
const PEM_SECRET_PATH_PATTERN = /\.(?:pem|key)$/i;
|
|
9
|
-
// Password embedded in a connection-string URL, e.g.
|
|
10
|
-
// `postgresql://user:PASS@host`. Redacted from shell output so secrets in
|
|
11
|
-
// commands like `cat .env` do not leak into history or telemetry.
|
|
12
9
|
const URL_CREDENTIALS_PATTERN = /\b([a-z][a-z0-9+.-]*:\/\/[^\s:/@]+:)([^\s/@]+)(@)/gi;
|
|
13
|
-
/** Redact only userinfo passwords in connection-string URLs (zero false positives). */
|
|
14
10
|
export function redactConnectionStringCredentials(text) {
|
|
15
11
|
return text.replace(URL_CREDENTIALS_PATTERN, (_match, prefix, _password, at) => `${prefix}${VALUE_REDACTION}${at}`);
|
|
16
12
|
}
|
|
@@ -72,12 +68,6 @@ export function isDotenvLikePath(value) {
|
|
|
72
68
|
const base = path.posix.basename(text.replace(/\\/g, '/'));
|
|
73
69
|
return DOTENV_BASENAME_PATTERN.test(base);
|
|
74
70
|
}
|
|
75
|
-
/**
|
|
76
|
-
* True only for a clean dotenv file we can safely show with keys visible and
|
|
77
|
-
* values tokenized: no PEM block, not JSON, and every non-blank/non-comment line
|
|
78
|
-
* is a `KEY=VALUE` assignment. Anything ambiguous (a stray line that might be a
|
|
79
|
-
* raw secret) returns false so the caller keeps the opaque blackout instead.
|
|
80
|
-
*/
|
|
81
71
|
export function looksLikeEditableDotenv(content) {
|
|
82
72
|
PEM_BLOCK_PATTERN.lastIndex = 0;
|
|
83
73
|
if (PEM_BLOCK_PATTERN.test(content))
|