cowork-cli 1.0.0 → 1.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/package.json +1 -1
- package/src/engine/tools/findDir.js +18 -10
- package/src/engine/tools/findFile.js +17 -10
- package/src/engine/tools/projectTree.js +15 -8
- package/src/engine/tools/readDir.js +11 -4
- package/src/engine/tools/readFile.js +13 -5
- package/src/engine/tools/readFileChunk.js +13 -5
- package/src/engine/tools/searchText.js +20 -13
- package/src/utils/fsUtils.js +270 -22
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import fs from 'fs/promises';
|
|
2
2
|
import path from 'path';
|
|
3
|
-
import { getIgnorePatterns,
|
|
3
|
+
import { getIgnorePatterns, isSafeEntry, loadNestedIgnores, safePath } from '../../utils/fsUtils.js';
|
|
4
4
|
|
|
5
5
|
/**
|
|
6
6
|
* findDir tool: Finds directories by name using regex.
|
|
@@ -13,10 +13,10 @@ import { getIgnorePatterns, shouldIgnore } from '../../utils/fsUtils.js';
|
|
|
13
13
|
export default async function findDir({ pattern, dirPath = '.', recursive = true, limit = 15 }) {
|
|
14
14
|
try {
|
|
15
15
|
if (!pattern) return "Error: Search pattern cannot be empty.";
|
|
16
|
-
|
|
16
|
+
|
|
17
17
|
// Enforce max limit of 15
|
|
18
18
|
const finalLimit = Math.min(limit, 15);
|
|
19
|
-
|
|
19
|
+
|
|
20
20
|
let regex;
|
|
21
21
|
try {
|
|
22
22
|
regex = new RegExp(pattern, 'i');
|
|
@@ -24,10 +24,17 @@ export default async function findDir({ pattern, dirPath = '.', recursive = true
|
|
|
24
24
|
return `Error: Invalid regex pattern '${pattern}': ${e.message}`;
|
|
25
25
|
}
|
|
26
26
|
|
|
27
|
-
|
|
27
|
+
let resolvedPath;
|
|
28
|
+
try {
|
|
29
|
+
resolvedPath = safePath(dirPath);
|
|
30
|
+
} catch (err) {
|
|
31
|
+
return `Error: ${err.message}`;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const rootIgnoreList = await getIgnorePatterns();
|
|
28
35
|
const results = [];
|
|
29
36
|
|
|
30
|
-
async function walk(currentPath) {
|
|
37
|
+
async function walk(currentPath, ignoreList) {
|
|
31
38
|
if (results.length >= finalLimit) return;
|
|
32
39
|
|
|
33
40
|
let items;
|
|
@@ -39,23 +46,24 @@ export default async function findDir({ pattern, dirPath = '.', recursive = true
|
|
|
39
46
|
|
|
40
47
|
for (const item of items) {
|
|
41
48
|
if (results.length >= finalLimit) break;
|
|
42
|
-
if (
|
|
49
|
+
if (!isSafeEntry(item, currentPath, ignoreList)) continue;
|
|
43
50
|
|
|
44
51
|
const fullPath = path.join(currentPath, item.name);
|
|
45
|
-
|
|
52
|
+
|
|
46
53
|
if (item.isDirectory()) {
|
|
47
54
|
if (regex.test(item.name)) {
|
|
48
55
|
results.push(path.relative(process.cwd(), fullPath));
|
|
49
56
|
}
|
|
50
|
-
|
|
57
|
+
|
|
51
58
|
if (recursive) {
|
|
52
|
-
await
|
|
59
|
+
const childIgnores = await loadNestedIgnores(fullPath, ignoreList);
|
|
60
|
+
await walk(fullPath, childIgnores);
|
|
53
61
|
}
|
|
54
62
|
}
|
|
55
63
|
}
|
|
56
64
|
}
|
|
57
65
|
|
|
58
|
-
await walk(
|
|
66
|
+
await walk(resolvedPath, rootIgnoreList);
|
|
59
67
|
|
|
60
68
|
if (results.length === 0) {
|
|
61
69
|
return `No directories found matching "${pattern}" in "${dirPath}".`;
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import fs from 'fs/promises';
|
|
2
2
|
import path from 'path';
|
|
3
|
-
import { getIgnorePatterns,
|
|
3
|
+
import { getIgnorePatterns, isSafeEntry, loadNestedIgnores, safePath } from '../../utils/fsUtils.js';
|
|
4
4
|
|
|
5
5
|
/**
|
|
6
6
|
* findFile tool: Finds files by name using regex.
|
|
@@ -13,10 +13,10 @@ import { getIgnorePatterns, shouldIgnore } from '../../utils/fsUtils.js';
|
|
|
13
13
|
export default async function findFile({ pattern, dirPath = '.', recursive = true, limit = 15 }) {
|
|
14
14
|
try {
|
|
15
15
|
if (!pattern) return "Error: Search pattern cannot be empty.";
|
|
16
|
-
|
|
16
|
+
|
|
17
17
|
// Enforce max limit of 15
|
|
18
18
|
const finalLimit = Math.min(limit, 15);
|
|
19
|
-
|
|
19
|
+
|
|
20
20
|
let regex;
|
|
21
21
|
try {
|
|
22
22
|
regex = new RegExp(pattern, 'i');
|
|
@@ -24,11 +24,17 @@ export default async function findFile({ pattern, dirPath = '.', recursive = tru
|
|
|
24
24
|
return `Error: Invalid regex pattern '${pattern}': ${e.message}`;
|
|
25
25
|
}
|
|
26
26
|
|
|
27
|
-
|
|
27
|
+
let resolvedPath;
|
|
28
|
+
try {
|
|
29
|
+
resolvedPath = safePath(dirPath);
|
|
30
|
+
} catch (err) {
|
|
31
|
+
return `Error: ${err.message}`;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const rootIgnoreList = await getIgnorePatterns();
|
|
28
35
|
const results = [];
|
|
29
|
-
let totalFound = 0;
|
|
30
36
|
|
|
31
|
-
async function walk(currentPath) {
|
|
37
|
+
async function walk(currentPath, ignoreList) {
|
|
32
38
|
if (results.length >= finalLimit) return;
|
|
33
39
|
|
|
34
40
|
let items;
|
|
@@ -40,13 +46,14 @@ export default async function findFile({ pattern, dirPath = '.', recursive = tru
|
|
|
40
46
|
|
|
41
47
|
for (const item of items) {
|
|
42
48
|
if (results.length >= finalLimit) break;
|
|
43
|
-
if (
|
|
49
|
+
if (!isSafeEntry(item, currentPath, ignoreList)) continue;
|
|
44
50
|
|
|
45
51
|
const fullPath = path.join(currentPath, item.name);
|
|
46
|
-
|
|
52
|
+
|
|
47
53
|
if (item.isDirectory()) {
|
|
48
54
|
if (recursive) {
|
|
49
|
-
await
|
|
55
|
+
const childIgnores = await loadNestedIgnores(fullPath, ignoreList);
|
|
56
|
+
await walk(fullPath, childIgnores);
|
|
50
57
|
}
|
|
51
58
|
} else if (item.isFile()) {
|
|
52
59
|
if (regex.test(item.name)) {
|
|
@@ -56,7 +63,7 @@ export default async function findFile({ pattern, dirPath = '.', recursive = tru
|
|
|
56
63
|
}
|
|
57
64
|
}
|
|
58
65
|
|
|
59
|
-
await walk(
|
|
66
|
+
await walk(resolvedPath, rootIgnoreList);
|
|
60
67
|
|
|
61
68
|
if (results.length === 0) {
|
|
62
69
|
return `No files found matching "${pattern}" in "${dirPath}".`;
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import fs from 'fs/promises';
|
|
2
2
|
import path from 'path';
|
|
3
|
-
import { getIgnorePatterns,
|
|
3
|
+
import { getIgnorePatterns, isSafeEntry, loadNestedIgnores, safePath } from '../../utils/fsUtils.js';
|
|
4
4
|
|
|
5
5
|
const MAX_DEPTH = 10;
|
|
6
6
|
const MAX_ITEMS = 500;
|
|
@@ -15,17 +15,23 @@ export default async function projectTree({ dirPath }) {
|
|
|
15
15
|
let itemCount = 0;
|
|
16
16
|
let isTruncated = false;
|
|
17
17
|
|
|
18
|
+
let absolutePath;
|
|
19
|
+
try {
|
|
20
|
+
absolutePath = safePath(dirPath);
|
|
21
|
+
} catch (err) {
|
|
22
|
+
return `Error: ${err.message}`;
|
|
23
|
+
}
|
|
24
|
+
|
|
18
25
|
try {
|
|
19
|
-
const absolutePath = path.resolve(dirPath);
|
|
20
26
|
const stats = await fs.stat(absolutePath);
|
|
21
|
-
|
|
27
|
+
|
|
22
28
|
if (!stats.isDirectory()) {
|
|
23
29
|
return `Error: '${dirPath}' is not a directory.`;
|
|
24
30
|
}
|
|
25
31
|
|
|
26
|
-
const
|
|
32
|
+
const rootIgnoreList = await getIgnorePatterns();
|
|
27
33
|
|
|
28
|
-
async function buildTree(currentDir, depth = 0, currentPrefix = '') {
|
|
34
|
+
async function buildTree(currentDir, depth = 0, currentPrefix = '', ignoreList = rootIgnoreList) {
|
|
29
35
|
if (depth > MAX_DEPTH || itemCount >= MAX_ITEMS) {
|
|
30
36
|
if (itemCount >= MAX_ITEMS) isTruncated = true;
|
|
31
37
|
return '';
|
|
@@ -40,7 +46,7 @@ export default async function projectTree({ dirPath }) {
|
|
|
40
46
|
}
|
|
41
47
|
|
|
42
48
|
const filteredItems = items
|
|
43
|
-
.filter(item =>
|
|
49
|
+
.filter(item => isSafeEntry(item, currentDir, ignoreList))
|
|
44
50
|
.sort((a, b) => {
|
|
45
51
|
if (a.isDirectory() && !b.isDirectory()) return -1;
|
|
46
52
|
if (!a.isDirectory() && b.isDirectory()) return 1;
|
|
@@ -64,7 +70,8 @@ export default async function projectTree({ dirPath }) {
|
|
|
64
70
|
|
|
65
71
|
if (item.isDirectory()) {
|
|
66
72
|
const fullPath = path.join(currentDir, item.name);
|
|
67
|
-
|
|
73
|
+
const childIgnores = await loadNestedIgnores(fullPath, ignoreList);
|
|
74
|
+
result += await buildTree(fullPath, depth + 1, currentPrefix + childPrefix, childIgnores);
|
|
68
75
|
}
|
|
69
76
|
}
|
|
70
77
|
return result;
|
|
@@ -72,7 +79,7 @@ export default async function projectTree({ dirPath }) {
|
|
|
72
79
|
|
|
73
80
|
const rootName = path.basename(absolutePath) || absolutePath;
|
|
74
81
|
const tree = await buildTree(absolutePath);
|
|
75
|
-
|
|
82
|
+
|
|
76
83
|
let finalOutput = `${rootName}/\n${tree || '└(empty)'}`;
|
|
77
84
|
finalOutput = finalOutput.trimEnd();
|
|
78
85
|
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import fs from 'fs/promises';
|
|
2
|
-
import { getIgnorePatterns,
|
|
2
|
+
import { getIgnorePatterns, isSafeEntry, safePath } from '../../utils/fsUtils.js';
|
|
3
3
|
|
|
4
4
|
/**
|
|
5
5
|
* Implementation of the readDir tool.
|
|
@@ -8,12 +8,19 @@ import { getIgnorePatterns, shouldIgnore } from '../../utils/fsUtils.js';
|
|
|
8
8
|
* @returns {Promise<string>} List of files and folders or error message.
|
|
9
9
|
*/
|
|
10
10
|
export default async function readDir({ dirPath }) {
|
|
11
|
+
let resolvedPath;
|
|
12
|
+
try {
|
|
13
|
+
resolvedPath = safePath(dirPath);
|
|
14
|
+
} catch (err) {
|
|
15
|
+
return `Error: ${err.message}`;
|
|
16
|
+
}
|
|
17
|
+
|
|
11
18
|
try {
|
|
12
19
|
const ignoreList = await getIgnorePatterns();
|
|
13
|
-
const items = await fs.readdir(
|
|
14
|
-
|
|
20
|
+
const items = await fs.readdir(resolvedPath, { withFileTypes: true });
|
|
21
|
+
|
|
15
22
|
const formattedItems = items
|
|
16
|
-
.filter(item =>
|
|
23
|
+
.filter(item => isSafeEntry(item, resolvedPath, ignoreList))
|
|
17
24
|
.map(item => {
|
|
18
25
|
const type = item.isDirectory() ? '[D]' : '[F]';
|
|
19
26
|
return `${type}${item.name}`;
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import fs from 'fs/promises';
|
|
2
2
|
import { Buffer } from 'buffer';
|
|
3
|
+
import { safePath } from '../../utils/fsUtils.js';
|
|
3
4
|
|
|
4
5
|
const MAX_FILE_SIZE = 1024 * 1024; // 1MB limit
|
|
5
6
|
|
|
@@ -10,9 +11,16 @@ const MAX_FILE_SIZE = 1024 * 1024; // 1MB limit
|
|
|
10
11
|
* @returns {Promise<string>} File content or error message.
|
|
11
12
|
*/
|
|
12
13
|
export default async function readFile({ filePath }) {
|
|
14
|
+
let resolvedPath;
|
|
13
15
|
try {
|
|
14
|
-
|
|
15
|
-
|
|
16
|
+
resolvedPath = safePath(filePath);
|
|
17
|
+
} catch (err) {
|
|
18
|
+
return `Error: ${err.message}`;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
try {
|
|
22
|
+
const stats = await fs.stat(resolvedPath);
|
|
23
|
+
|
|
16
24
|
if (!stats.isFile()) {
|
|
17
25
|
return `Error: '${filePath}' is not a file.`;
|
|
18
26
|
}
|
|
@@ -22,17 +30,17 @@ export default async function readFile({ filePath }) {
|
|
|
22
30
|
}
|
|
23
31
|
|
|
24
32
|
// Binary check: read first 1KB and look for null bytes
|
|
25
|
-
const handle = await fs.open(
|
|
33
|
+
const handle = await fs.open(resolvedPath, 'r');
|
|
26
34
|
const { bytesRead, buffer } = await handle.read(Buffer.alloc(1024), 0, 1024, 0);
|
|
27
35
|
await handle.close();
|
|
28
|
-
|
|
36
|
+
|
|
29
37
|
for (let i = 0; i < bytesRead; i++) {
|
|
30
38
|
if (buffer[i] === 0) {
|
|
31
39
|
return `Error: '${filePath}' appears to be a binary file. Reading binary files is not supported.`;
|
|
32
40
|
}
|
|
33
41
|
}
|
|
34
42
|
|
|
35
|
-
const content = await fs.readFile(
|
|
43
|
+
const content = await fs.readFile(resolvedPath, 'utf8');
|
|
36
44
|
return content;
|
|
37
45
|
} catch (err) {
|
|
38
46
|
if (err.code === 'ENOENT') return `Error: File not found at '${filePath}'.`;
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import fs from 'fs/promises';
|
|
2
2
|
import { Buffer } from 'buffer';
|
|
3
|
+
import { safePath } from '../../utils/fsUtils.js';
|
|
3
4
|
|
|
4
5
|
/**
|
|
5
6
|
* Implementation of the readFileChunk tool.
|
|
@@ -10,22 +11,29 @@ import { Buffer } from 'buffer';
|
|
|
10
11
|
* @returns {Promise<string>} File chunk or error message.
|
|
11
12
|
*/
|
|
12
13
|
export default async function readFileChunk({ filePath, startLine, endLine }) {
|
|
14
|
+
let resolvedPath;
|
|
13
15
|
try {
|
|
14
|
-
|
|
16
|
+
resolvedPath = safePath(filePath);
|
|
17
|
+
} catch (err) {
|
|
18
|
+
return `Error: ${err.message}`;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
try {
|
|
22
|
+
const stats = await fs.stat(resolvedPath);
|
|
15
23
|
if (!stats.isFile()) return `Error: '${filePath}' is not a file.`;
|
|
16
24
|
|
|
17
25
|
// Binary check: read first 1KB and look for null bytes
|
|
18
|
-
const handle = await fs.open(
|
|
26
|
+
const handle = await fs.open(resolvedPath, 'r');
|
|
19
27
|
const { bytesRead, buffer } = await handle.read(Buffer.alloc(1024), 0, 1024, 0);
|
|
20
28
|
await handle.close();
|
|
21
|
-
|
|
29
|
+
|
|
22
30
|
for (let i = 0; i < bytesRead; i++) {
|
|
23
31
|
if (buffer[i] === 0) return `Error: '${filePath}' appears to be a binary file. Reading binary files is not supported.`;
|
|
24
32
|
}
|
|
25
33
|
|
|
26
|
-
const data = await fs.readFile(
|
|
34
|
+
const data = await fs.readFile(resolvedPath, 'utf8');
|
|
27
35
|
const lines = data.split('\n');
|
|
28
|
-
|
|
36
|
+
|
|
29
37
|
// Boundary validation
|
|
30
38
|
const totalLines = lines.length;
|
|
31
39
|
const actualStart = Math.max(1, startLine);
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import fs from 'fs/promises';
|
|
2
2
|
import path from 'path';
|
|
3
3
|
import { Buffer } from 'buffer';
|
|
4
|
-
import { getIgnorePatterns,
|
|
4
|
+
import { getIgnorePatterns, isSafeEntry, loadNestedIgnores, safePath } from '../../utils/fsUtils.js';
|
|
5
5
|
|
|
6
6
|
const MAX_MATCHES_PER_FILE = 20;
|
|
7
7
|
const MAX_TOTAL_MATCHES = 100;
|
|
@@ -14,8 +14,15 @@ export default async function searchText({ pattern, path: searchPath, recursive
|
|
|
14
14
|
let totalMatches = 0;
|
|
15
15
|
let isTruncated = false;
|
|
16
16
|
|
|
17
|
+
let resolvedPath;
|
|
17
18
|
try {
|
|
18
|
-
|
|
19
|
+
resolvedPath = safePath(searchPath);
|
|
20
|
+
} catch (err) {
|
|
21
|
+
return `Error: ${err.message}`;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
try {
|
|
25
|
+
const stats = await fs.stat(resolvedPath);
|
|
19
26
|
if (!pattern) return "Error: Search pattern cannot be empty.";
|
|
20
27
|
|
|
21
28
|
let regex;
|
|
@@ -25,15 +32,15 @@ export default async function searchText({ pattern, path: searchPath, recursive
|
|
|
25
32
|
return `Error: Invalid regex pattern '${pattern}': ${e.message}`;
|
|
26
33
|
}
|
|
27
34
|
|
|
28
|
-
const
|
|
35
|
+
const rootIgnoreList = await getIgnorePatterns();
|
|
29
36
|
const results = [];
|
|
30
37
|
|
|
31
|
-
const walk = async (currentPath, depth = 0) => {
|
|
38
|
+
const walk = async (currentPath, depth = 0, ignoreList = rootIgnoreList) => {
|
|
32
39
|
if (totalMatches >= MAX_TOTAL_MATCHES) {
|
|
33
40
|
isTruncated = true;
|
|
34
41
|
return;
|
|
35
42
|
}
|
|
36
|
-
|
|
43
|
+
|
|
37
44
|
if (depth > MAX_DEPTH) return;
|
|
38
45
|
|
|
39
46
|
let items;
|
|
@@ -49,13 +56,14 @@ export default async function searchText({ pattern, path: searchPath, recursive
|
|
|
49
56
|
break;
|
|
50
57
|
}
|
|
51
58
|
|
|
52
|
-
if (
|
|
59
|
+
if (!isSafeEntry(item, currentPath, ignoreList)) continue;
|
|
53
60
|
|
|
54
61
|
const fullPath = path.join(currentPath, item.name);
|
|
55
62
|
|
|
56
63
|
if (item.isDirectory()) {
|
|
57
64
|
if (recursive || depth === 0) {
|
|
58
|
-
await
|
|
65
|
+
const childIgnores = await loadNestedIgnores(fullPath, ignoreList);
|
|
66
|
+
await walk(fullPath, depth + 1, childIgnores);
|
|
59
67
|
}
|
|
60
68
|
} else if (item.isFile()) {
|
|
61
69
|
const fileMatches = await searchInFile(fullPath, regex);
|
|
@@ -73,18 +81,18 @@ export default async function searchText({ pattern, path: searchPath, recursive
|
|
|
73
81
|
};
|
|
74
82
|
|
|
75
83
|
if (stats.isFile()) {
|
|
76
|
-
const fileMatches = await searchInFile(
|
|
84
|
+
const fileMatches = await searchInFile(resolvedPath, regex);
|
|
77
85
|
if (fileMatches.length > 0) {
|
|
78
86
|
const allowed = Math.min(fileMatches.length, MAX_TOTAL_MATCHES);
|
|
79
87
|
results.push({
|
|
80
|
-
file: path.relative(process.cwd(),
|
|
88
|
+
file: path.relative(process.cwd(), resolvedPath),
|
|
81
89
|
matches: fileMatches.slice(0, allowed)
|
|
82
90
|
});
|
|
83
91
|
totalMatches = allowed;
|
|
84
92
|
if (fileMatches.length > allowed) isTruncated = true;
|
|
85
93
|
}
|
|
86
94
|
} else {
|
|
87
|
-
await walk(
|
|
95
|
+
await walk(resolvedPath);
|
|
88
96
|
}
|
|
89
97
|
|
|
90
98
|
if (results.length === 0) return "No matches found.";
|
|
@@ -99,7 +107,6 @@ export default async function searchText({ pattern, path: searchPath, recursive
|
|
|
99
107
|
|
|
100
108
|
return output;
|
|
101
109
|
|
|
102
|
-
|
|
103
110
|
} catch (err) {
|
|
104
111
|
if (err.code === 'ENOENT') return `Error: Path not found at '${searchPath}'.`;
|
|
105
112
|
return `Error searching text: ${err.message}`;
|
|
@@ -111,7 +118,7 @@ async function searchInFile(filePath, regex) {
|
|
|
111
118
|
const handle = await fs.open(filePath, 'r');
|
|
112
119
|
const { bytesRead, buffer } = await handle.read(Buffer.alloc(1024), 0, 1024, 0);
|
|
113
120
|
await handle.close();
|
|
114
|
-
|
|
121
|
+
|
|
115
122
|
for (let i = 0; i < bytesRead; i++) {
|
|
116
123
|
if (buffer[i] === 0) return []; // Skip binary
|
|
117
124
|
}
|
|
@@ -119,7 +126,7 @@ async function searchInFile(filePath, regex) {
|
|
|
119
126
|
const content = await fs.readFile(filePath, 'utf8');
|
|
120
127
|
const lines = content.split('\n');
|
|
121
128
|
const matches = [];
|
|
122
|
-
|
|
129
|
+
|
|
123
130
|
for (let i = 0; i < lines.length; i++) {
|
|
124
131
|
if (regex.test(lines[i])) {
|
|
125
132
|
matches.push(`${i + 1}:${lines[i].trim()}`);
|
package/src/utils/fsUtils.js
CHANGED
|
@@ -1,37 +1,285 @@
|
|
|
1
1
|
import fs from 'fs/promises';
|
|
2
2
|
import path from 'path';
|
|
3
3
|
|
|
4
|
-
|
|
4
|
+
// ── Constants ────────────────────────────────────────────────────────────────
|
|
5
|
+
|
|
6
|
+
const DEFAULT_IGNORES = [
|
|
7
|
+
// VCS
|
|
8
|
+
'.git', '.svn', '.hg',
|
|
9
|
+
// Dependencies & build output
|
|
10
|
+
'node_modules', 'dist', 'build', '.npm',
|
|
11
|
+
// OS artifacts
|
|
12
|
+
'.DS_Store', 'Thumbs.db',
|
|
13
|
+
// Secrets — defense-in-depth even if .gitignore already covers them
|
|
14
|
+
'.env', '.env.*',
|
|
15
|
+
// Test / coverage artifacts
|
|
16
|
+
'coverage', '__pycache__', '.cache',
|
|
17
|
+
// IDE metadata
|
|
18
|
+
'.vscode', '.idea',
|
|
19
|
+
];
|
|
20
|
+
|
|
21
|
+
const MAX_GITIGNORE_SIZE = 64 * 1024; // 64 KB
|
|
22
|
+
|
|
23
|
+
// ── Cache ────────────────────────────────────────────────────────────────────
|
|
24
|
+
|
|
25
|
+
let _cachedPatterns = null;
|
|
26
|
+
|
|
27
|
+
// ── Internal: Glob-to-RegExp (zero dependencies) ────────────────────────────
|
|
5
28
|
|
|
6
29
|
/**
|
|
7
|
-
*
|
|
8
|
-
*
|
|
30
|
+
* Converts a gitignore-style glob pattern to a RegExp.
|
|
31
|
+
* Supports `*`, `**`, `?`, and bracket classes `[abc]`.
|
|
32
|
+
* @param {string} pattern
|
|
33
|
+
* @returns {RegExp}
|
|
9
34
|
*/
|
|
10
|
-
|
|
11
|
-
|
|
35
|
+
function globToRegex(pattern) {
|
|
36
|
+
let i = 0;
|
|
37
|
+
let re = '';
|
|
38
|
+
|
|
39
|
+
while (i < pattern.length) {
|
|
40
|
+
const c = pattern[i];
|
|
41
|
+
|
|
42
|
+
if (c === '*') {
|
|
43
|
+
if (pattern[i + 1] === '*') {
|
|
44
|
+
// `**` — match everything including path separators
|
|
45
|
+
re += '.*';
|
|
46
|
+
i += 2;
|
|
47
|
+
if (pattern[i] === '/') i++; // consume optional trailing `/`
|
|
48
|
+
continue;
|
|
49
|
+
}
|
|
50
|
+
re += '[^/]*'; // `*` — any non-separator chars
|
|
51
|
+
} else if (c === '?') {
|
|
52
|
+
re += '[^/]'; // `?` — single non-separator char
|
|
53
|
+
} else if (c === '[') {
|
|
54
|
+
// Bracket expression — locate the closing `]`
|
|
55
|
+
let j = i + 1;
|
|
56
|
+
if (j < pattern.length && pattern[j] === '!') j++;
|
|
57
|
+
if (j < pattern.length && pattern[j] === ']') j++;
|
|
58
|
+
while (j < pattern.length && pattern[j] !== ']') j++;
|
|
59
|
+
|
|
60
|
+
if (j >= pattern.length) {
|
|
61
|
+
re += '\\['; // no closing bracket → literal `[`
|
|
62
|
+
} else {
|
|
63
|
+
let cls = pattern.slice(i + 1, j).replace(/\\/g, '\\\\');
|
|
64
|
+
if (cls[0] === '!') cls = '^' + cls.slice(1);
|
|
65
|
+
re += `[${cls}]`;
|
|
66
|
+
i = j; // advance past `]`
|
|
67
|
+
}
|
|
68
|
+
} else if ('.+^${}()|\\'.includes(c)) {
|
|
69
|
+
re += '\\' + c; // escape regex meta chars
|
|
70
|
+
} else {
|
|
71
|
+
re += c;
|
|
72
|
+
}
|
|
73
|
+
i++;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
return new RegExp(`^${re}$`);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// ── Internal: .gitignore Parser ──────────────────────────────────────────────
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Parses raw `.gitignore` content into structured pattern objects.
|
|
83
|
+
* Handles: comments, blank lines, negation (`!`), directory-only (`/`),
|
|
84
|
+
* Windows `\r` line endings, and glob detection.
|
|
85
|
+
* @param {string} content
|
|
86
|
+
* @returns {Object[]}
|
|
87
|
+
*/
|
|
88
|
+
function parseGitignoreContent(content) {
|
|
89
|
+
const patterns = [];
|
|
90
|
+
|
|
91
|
+
for (const raw of content.split('\n')) {
|
|
92
|
+
let line = raw.replace(/\r$/, '').trim(); // strip Windows CR
|
|
93
|
+
if (!line || line.startsWith('#')) continue; // skip blanks & comments
|
|
94
|
+
|
|
95
|
+
// Negation
|
|
96
|
+
let negated = false;
|
|
97
|
+
if (line.startsWith('!')) {
|
|
98
|
+
negated = true;
|
|
99
|
+
line = line.slice(1).trim();
|
|
100
|
+
if (!line) continue;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// Directory-only marker
|
|
104
|
+
const dirOnly = line.endsWith('/');
|
|
105
|
+
if (dirOnly) line = line.slice(0, -1);
|
|
106
|
+
|
|
107
|
+
const hasGlob = /[*?[\]]/.test(line);
|
|
108
|
+
const hasSlash = line.includes('/');
|
|
109
|
+
|
|
110
|
+
patterns.push({
|
|
111
|
+
pattern: line,
|
|
112
|
+
negated,
|
|
113
|
+
dirOnly,
|
|
114
|
+
hasGlob,
|
|
115
|
+
hasSlash,
|
|
116
|
+
regex: hasGlob ? globToRegex(line) : null,
|
|
117
|
+
});
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
return patterns;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Builds structured pattern objects from a plain-string array
|
|
125
|
+
* (used for the hard-coded DEFAULT_IGNORES list).
|
|
126
|
+
* @param {string[]} list
|
|
127
|
+
* @returns {Object[]}
|
|
128
|
+
*/
|
|
129
|
+
function buildPatterns(list) {
|
|
130
|
+
return list.map(raw => {
|
|
131
|
+
const dirOnly = raw.endsWith('/');
|
|
132
|
+
const cleaned = dirOnly ? raw.slice(0, -1) : raw;
|
|
133
|
+
const hasGlob = /[*?[\]]/.test(cleaned);
|
|
134
|
+
return {
|
|
135
|
+
pattern: cleaned,
|
|
136
|
+
negated: false,
|
|
137
|
+
dirOnly,
|
|
138
|
+
hasGlob,
|
|
139
|
+
hasSlash: cleaned.includes('/'),
|
|
140
|
+
regex: hasGlob ? globToRegex(cleaned) : null,
|
|
141
|
+
};
|
|
142
|
+
});
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// ── Internal: .gitignore Loader ──────────────────────────────────────────────
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* Reads and parses the `.gitignore` file inside a directory.
|
|
149
|
+
* Returns an empty array if the file is missing, unreadable, or oversized.
|
|
150
|
+
* @param {string} dirPath Absolute directory path.
|
|
151
|
+
* @returns {Promise<Object[]>}
|
|
152
|
+
*/
|
|
153
|
+
async function loadGitignoreFromDir(dirPath) {
|
|
12
154
|
try {
|
|
13
|
-
const gitignorePath = path.join(
|
|
155
|
+
const gitignorePath = path.join(dirPath, '.gitignore');
|
|
156
|
+
const stats = await fs.stat(gitignorePath);
|
|
157
|
+
if (stats.size > MAX_GITIGNORE_SIZE) return [];
|
|
158
|
+
|
|
14
159
|
const content = await fs.readFile(gitignorePath, 'utf8');
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
ignoreList.push(...lines);
|
|
19
|
-
} catch (e) {
|
|
20
|
-
// Ignore if not found or unreadable
|
|
160
|
+
return parseGitignoreContent(content);
|
|
161
|
+
} catch {
|
|
162
|
+
return [];
|
|
21
163
|
}
|
|
22
|
-
return ignoreList;
|
|
23
164
|
}
|
|
24
165
|
|
|
166
|
+
// ── Public API ───────────────────────────────────────────────────────────────
|
|
167
|
+
|
|
25
168
|
/**
|
|
26
|
-
*
|
|
27
|
-
*
|
|
28
|
-
* @
|
|
29
|
-
* @returns {boolean}
|
|
169
|
+
* Loads ignore patterns from `DEFAULT_IGNORES` + the root `.gitignore`.
|
|
170
|
+
* Results are cached for the lifetime of the process.
|
|
171
|
+
* @returns {Promise<Object[]>} Structured pattern objects.
|
|
30
172
|
*/
|
|
31
|
-
export function
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
173
|
+
export async function getIgnorePatterns() {
|
|
174
|
+
if (_cachedPatterns) return _cachedPatterns;
|
|
175
|
+
|
|
176
|
+
const defaults = buildPatterns(DEFAULT_IGNORES);
|
|
177
|
+
const gitignore = await loadGitignoreFromDir(process.cwd());
|
|
178
|
+
|
|
179
|
+
_cachedPatterns = [...defaults, ...gitignore];
|
|
180
|
+
return _cachedPatterns;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* Loads additional `.gitignore` patterns from a nested directory and
|
|
185
|
+
* merges them **after** the parent list so they can override via negation.
|
|
186
|
+
* @param {string} dirPath Absolute path of the directory to inspect.
|
|
187
|
+
* @param {Object[]} parentList Patterns inherited from the parent scope.
|
|
188
|
+
* @returns {Promise<Object[]>} Merged pattern list (parent + nested).
|
|
189
|
+
*/
|
|
190
|
+
export async function loadNestedIgnores(dirPath, parentList) {
|
|
191
|
+
const nested = await loadGitignoreFromDir(dirPath);
|
|
192
|
+
if (nested.length === 0) return parentList;
|
|
193
|
+
return [...parentList, ...nested];
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/**
|
|
197
|
+
* Invalidates the cached ignore patterns.
|
|
198
|
+
*/
|
|
199
|
+
export function clearIgnoreCache() {
|
|
200
|
+
_cachedPatterns = null;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/**
|
|
204
|
+
* Checks if a name should be ignored.
|
|
205
|
+
*
|
|
206
|
+
* Pattern evaluation order matters — patterns are processed sequentially
|
|
207
|
+
* and the **last matching pattern wins**. Negation patterns (`!`) can
|
|
208
|
+
* therefore un-ignore a previously ignored name.
|
|
209
|
+
*
|
|
210
|
+
* @param {string} name Item basename.
|
|
211
|
+
* @param {Object[]} ignoreList Structured pattern objects.
|
|
212
|
+
* @param {Object} [options]
|
|
213
|
+
* @param {boolean} [options.isDirectory] Whether the item is a directory.
|
|
214
|
+
* When omitted, directory-only patterns match regardless
|
|
215
|
+
* (backward-compatible with existing callers).
|
|
216
|
+
* @returns {boolean} `true` if the item should be skipped.
|
|
217
|
+
*/
|
|
218
|
+
export function shouldIgnore(name, ignoreList, options = {}) {
|
|
219
|
+
const { isDirectory } = options;
|
|
220
|
+
let ignored = false;
|
|
221
|
+
|
|
222
|
+
for (const entry of ignoreList) {
|
|
223
|
+
// Path-containing patterns (e.g. `docs/internal`) require full
|
|
224
|
+
// relative-path matching which callers don't supply — skip them.
|
|
225
|
+
if (entry.hasSlash) continue;
|
|
226
|
+
|
|
227
|
+
// Directory-only patterns (`build/`) don't apply to files.
|
|
228
|
+
// When `isDirectory` is undefined the caller didn't say, so we match
|
|
229
|
+
// to preserve backward compat with callers that only pass basenames.
|
|
230
|
+
if (entry.dirOnly && isDirectory === false) continue;
|
|
231
|
+
|
|
232
|
+
const matches = entry.hasGlob && entry.regex
|
|
233
|
+
? entry.regex.test(name)
|
|
234
|
+
: name === entry.pattern;
|
|
235
|
+
|
|
236
|
+
if (matches) {
|
|
237
|
+
ignored = !entry.negated;
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
return ignored;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
/**
|
|
245
|
+
* Resolves a path and verifies it stays within `process.cwd()`.
|
|
246
|
+
* Prevents directory-traversal attacks (e.g. `../../etc/passwd`).
|
|
247
|
+
*
|
|
248
|
+
* @param {string} inputPath The user- or model-supplied path.
|
|
249
|
+
* @returns {string} Resolved absolute path guaranteed to be inside the project.
|
|
250
|
+
* @throws {Error} If the resolved path escapes the project root.
|
|
251
|
+
*/
|
|
252
|
+
export function safePath(inputPath) {
|
|
253
|
+
const root = process.cwd();
|
|
254
|
+
const resolved = path.resolve(root, inputPath);
|
|
255
|
+
if (resolved !== root && !resolved.startsWith(root + path.sep)) {
|
|
256
|
+
throw new Error(`Access denied: '${inputPath}' resolves outside the project directory.`);
|
|
35
257
|
}
|
|
36
|
-
return
|
|
258
|
+
return resolved;
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
/**
|
|
262
|
+
* Decides whether a directory entry is safe to traverse / read.
|
|
263
|
+
*
|
|
264
|
+
* Rejects:
|
|
265
|
+
* 1. Symbolic links (could escape the project sandbox).
|
|
266
|
+
* 2. Names matched by the ignore list.
|
|
267
|
+
* 3. Paths that resolve outside `process.cwd()`.
|
|
268
|
+
*
|
|
269
|
+
* @param {import('fs').Dirent} dirent Directory entry from `readdir`.
|
|
270
|
+
* @param {string} parentPath Absolute path of the parent dir.
|
|
271
|
+
* @param {Object[]} ignoreList Structured pattern objects.
|
|
272
|
+
* @returns {boolean} `true` when the entry is safe to process.
|
|
273
|
+
*/
|
|
274
|
+
export function isSafeEntry(dirent, parentPath, ignoreList) {
|
|
275
|
+
if (dirent.isSymbolicLink()) return false;
|
|
276
|
+
|
|
277
|
+
const isDir = dirent.isDirectory();
|
|
278
|
+
if (shouldIgnore(dirent.name, ignoreList, { isDirectory: isDir })) return false;
|
|
279
|
+
|
|
280
|
+
const resolved = path.resolve(parentPath, dirent.name);
|
|
281
|
+
const root = process.cwd();
|
|
282
|
+
if (resolved !== root && !resolved.startsWith(root + path.sep)) return false;
|
|
283
|
+
|
|
284
|
+
return true;
|
|
37
285
|
}
|