@devmarketplacenpm/devmp 0.1.1-beta.5
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 +208 -0
- package/bin/devmp.js +245 -0
- package/lib/api.js +231 -0
- package/lib/browser.js +21 -0
- package/lib/checkpoints.js +292 -0
- package/lib/command-runner.js +368 -0
- package/lib/commands.js +597 -0
- package/lib/completion.js +190 -0
- package/lib/config.js +172 -0
- package/lib/diff.js +216 -0
- package/lib/executor.js +208 -0
- package/lib/git.js +39 -0
- package/lib/instructions.js +69 -0
- package/lib/interactive-tunnel.js +420 -0
- package/lib/interactive.js +1269 -0
- package/lib/markdown.js +201 -0
- package/lib/mentions.js +68 -0
- package/lib/prompt.js +140 -0
- package/lib/routes.js +27 -0
- package/lib/session.js +107 -0
- package/lib/status.js +249 -0
- package/lib/tty/ansi.js +171 -0
- package/lib/tty/composer.js +680 -0
- package/lib/tty/screen.js +167 -0
- package/lib/ui.js +174 -0
- package/lib/version.js +134 -0
- package/lib/workspace.js +608 -0
- package/lib/ws-run.js +142 -0
- package/package.json +40 -0
package/lib/workspace.js
ADDED
|
@@ -0,0 +1,608 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
const fs = require("fs/promises");
|
|
4
|
+
const path = require("path");
|
|
5
|
+
|
|
6
|
+
// Always skipped, even without a .gitignore — junk that should never be sent as
|
|
7
|
+
// context or treated as source.
|
|
8
|
+
// Files this client will not read, send, or write, whatever .gitignore says.
|
|
9
|
+
//
|
|
10
|
+
// The agent runs on a server, so anything read here leaves the machine. A fresh
|
|
11
|
+
// project usually has no .gitignore yet, and that is exactly when a `.env` full
|
|
12
|
+
// of live keys is sitting in the working directory — leaving this to gitignore
|
|
13
|
+
// means the default is "upload the secrets" and the safe case is the one the
|
|
14
|
+
// user has to remember to configure.
|
|
15
|
+
//
|
|
16
|
+
// Sample files are the deliberate exception: `.env.example` exists to be read.
|
|
17
|
+
const SECRET_FILE_PATTERNS = [
|
|
18
|
+
/^\.env$/i,
|
|
19
|
+
/^\.env\.(?!example$|sample$|template$|dist$)[^/]+$/i,
|
|
20
|
+
/^\.npmrc$/i,
|
|
21
|
+
/^\.netrc$/i,
|
|
22
|
+
/^\.pgpass$/i,
|
|
23
|
+
/^id_(rsa|dsa|ecdsa|ed25519)$/i,
|
|
24
|
+
/^credentials(\.json)?$/i,
|
|
25
|
+
/\.(pem|key|p12|pfx|keystore|jks)$/i,
|
|
26
|
+
/^\.ssh\//i,
|
|
27
|
+
/^\.aws\/credentials$/i,
|
|
28
|
+
/^\.gnupg\//i,
|
|
29
|
+
/^service-account.*\.json$/i,
|
|
30
|
+
];
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Is this path one the client refuses to touch?
|
|
34
|
+
*
|
|
35
|
+
* Matched on the POSIX-relative path and on the basename, so a secret nested in
|
|
36
|
+
* a subdirectory is caught as well as one at the root.
|
|
37
|
+
*/
|
|
38
|
+
function isSecretPath(relPath) {
|
|
39
|
+
const rel = String(relPath).replace(/\\/g, "/").replace(/^\.\//, "");
|
|
40
|
+
const base = rel.split("/").pop() || rel;
|
|
41
|
+
return SECRET_FILE_PATTERNS.some((re) => re.test(rel) || re.test(base));
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const IGNORE_DIRS = new Set([
|
|
45
|
+
"node_modules",
|
|
46
|
+
".git",
|
|
47
|
+
".next",
|
|
48
|
+
"dist",
|
|
49
|
+
"build",
|
|
50
|
+
"out",
|
|
51
|
+
"coverage",
|
|
52
|
+
".cache",
|
|
53
|
+
".turbo",
|
|
54
|
+
".devmarketplace",
|
|
55
|
+
"vendor",
|
|
56
|
+
".venv",
|
|
57
|
+
"__pycache__",
|
|
58
|
+
]);
|
|
59
|
+
|
|
60
|
+
const MAX_SNAPSHOT_FILES = 60;
|
|
61
|
+
const MAX_FILE_BYTES = 100 * 1024; // 100 KB per file
|
|
62
|
+
const MAX_TOTAL_BYTES = 400 * 1024; // 400 KB total snapshot
|
|
63
|
+
|
|
64
|
+
function looksBinary(buffer) {
|
|
65
|
+
const len = Math.min(buffer.length, 4096);
|
|
66
|
+
for (let i = 0; i < len; i += 1) if (buffer[i] === 0) return true;
|
|
67
|
+
return false;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// ── minimal .gitignore support ──────────────────────────────────────────────
|
|
71
|
+
// A pragmatic subset of gitignore semantics: comments/blanks, `!` negation,
|
|
72
|
+
// trailing `/` (dir-only), leading `/` or an embedded `/` (anchored to root),
|
|
73
|
+
// and `*` / `?` / `**` globs. Nested .gitignore files are not read (root only).
|
|
74
|
+
|
|
75
|
+
function globToRegex(glob) {
|
|
76
|
+
let re = "";
|
|
77
|
+
for (let i = 0; i < glob.length; i += 1) {
|
|
78
|
+
const c = glob[i];
|
|
79
|
+
if (c === "*") {
|
|
80
|
+
if (glob[i + 1] === "*") {
|
|
81
|
+
re += ".*";
|
|
82
|
+
i += 1;
|
|
83
|
+
} else {
|
|
84
|
+
re += "[^/]*";
|
|
85
|
+
}
|
|
86
|
+
} else if (c === "?") {
|
|
87
|
+
re += "[^/]";
|
|
88
|
+
} else if ("\\^$.|+()[]{}".includes(c)) {
|
|
89
|
+
re += `\\${c}`;
|
|
90
|
+
} else {
|
|
91
|
+
re += c;
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
return new RegExp(`^${re}$`);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function compilePattern(raw) {
|
|
98
|
+
let pat = raw.replace(/\r$/, "").trim();
|
|
99
|
+
if (!pat || pat.startsWith("#")) return null;
|
|
100
|
+
let negate = false;
|
|
101
|
+
if (pat.startsWith("!")) {
|
|
102
|
+
negate = true;
|
|
103
|
+
pat = pat.slice(1);
|
|
104
|
+
}
|
|
105
|
+
let dirOnly = false;
|
|
106
|
+
if (pat.endsWith("/")) {
|
|
107
|
+
dirOnly = true;
|
|
108
|
+
pat = pat.slice(0, -1);
|
|
109
|
+
}
|
|
110
|
+
const rootAnchored = pat.startsWith("/");
|
|
111
|
+
if (rootAnchored) pat = pat.slice(1);
|
|
112
|
+
const anchored = rootAnchored || pat.includes("/");
|
|
113
|
+
if (!pat) return null;
|
|
114
|
+
return { negate, dirOnly, anchored, regex: globToRegex(pat) };
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
async function loadGitignore(rootDir) {
|
|
118
|
+
let text;
|
|
119
|
+
try {
|
|
120
|
+
text = await fs.readFile(path.join(rootDir, ".gitignore"), "utf8");
|
|
121
|
+
} catch {
|
|
122
|
+
return () => false;
|
|
123
|
+
}
|
|
124
|
+
const patterns = text.split("\n").map(compilePattern).filter(Boolean);
|
|
125
|
+
|
|
126
|
+
return function isIgnored(relPath, isDir) {
|
|
127
|
+
const base = relPath.split("/").pop();
|
|
128
|
+
let ignored = false;
|
|
129
|
+
for (const p of patterns) {
|
|
130
|
+
if (p.dirOnly && !isDir) continue;
|
|
131
|
+
const target = p.anchored ? relPath : base;
|
|
132
|
+
if (p.regex.test(target)) ignored = !p.negate;
|
|
133
|
+
}
|
|
134
|
+
return ignored;
|
|
135
|
+
};
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* Read the caller's current text files (the "snapshot") so the agent can read
|
|
140
|
+
* and extend real code. Skips the built-in junk dirs, anything matched by the
|
|
141
|
+
* project's .gitignore, binary/large files, and caps the total so we never ship
|
|
142
|
+
* a whole node_modules. Returns [{ path, content }] with POSIX-relative paths.
|
|
143
|
+
*/
|
|
144
|
+
async function readSnapshot(rootDir) {
|
|
145
|
+
const isIgnored = await loadGitignore(rootDir);
|
|
146
|
+
const files = [];
|
|
147
|
+
let totalBytes = 0;
|
|
148
|
+
let truncated = false;
|
|
149
|
+
let ignoredCount = 0;
|
|
150
|
+
// Counted, not silent: a file the agent never sees is the likeliest reason
|
|
151
|
+
// for it to answer as though the file does not exist.
|
|
152
|
+
let oversizedCount = 0;
|
|
153
|
+
|
|
154
|
+
async function walk(dir) {
|
|
155
|
+
if (truncated) return;
|
|
156
|
+
let entries;
|
|
157
|
+
try {
|
|
158
|
+
entries = await fs.readdir(dir, { withFileTypes: true });
|
|
159
|
+
} catch {
|
|
160
|
+
return;
|
|
161
|
+
}
|
|
162
|
+
for (const entry of entries) {
|
|
163
|
+
if (truncated) return;
|
|
164
|
+
const full = path.join(dir, entry.name);
|
|
165
|
+
const rel = path.relative(rootDir, full).split(path.sep).join("/");
|
|
166
|
+
|
|
167
|
+
if (entry.isDirectory()) {
|
|
168
|
+
if (IGNORE_DIRS.has(entry.name)) continue;
|
|
169
|
+
if (isIgnored(rel, true)) {
|
|
170
|
+
ignoredCount += 1;
|
|
171
|
+
continue;
|
|
172
|
+
}
|
|
173
|
+
await walk(full);
|
|
174
|
+
} else if (entry.isFile()) {
|
|
175
|
+
// Never leaves the machine, regardless of .gitignore.
|
|
176
|
+
if (isSecretPath(rel)) continue;
|
|
177
|
+
if (isIgnored(rel, false)) {
|
|
178
|
+
ignoredCount += 1;
|
|
179
|
+
continue;
|
|
180
|
+
}
|
|
181
|
+
let buf;
|
|
182
|
+
try {
|
|
183
|
+
buf = await fs.readFile(full);
|
|
184
|
+
} catch {
|
|
185
|
+
continue;
|
|
186
|
+
}
|
|
187
|
+
if (looksBinary(buf)) continue;
|
|
188
|
+
if (buf.length > MAX_FILE_BYTES) {
|
|
189
|
+
oversizedCount += 1;
|
|
190
|
+
continue;
|
|
191
|
+
}
|
|
192
|
+
if (
|
|
193
|
+
files.length >= MAX_SNAPSHOT_FILES ||
|
|
194
|
+
totalBytes + buf.length > MAX_TOTAL_BYTES
|
|
195
|
+
) {
|
|
196
|
+
truncated = true;
|
|
197
|
+
return;
|
|
198
|
+
}
|
|
199
|
+
totalBytes += buf.length;
|
|
200
|
+
files.push({ path: rel, content: buf.toString("utf8") });
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
await walk(rootDir);
|
|
206
|
+
return { files, truncated, ignoredCount, oversizedCount };
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/** Resolve a client-relative path to an absolute one, or null if it escapes
|
|
210
|
+
* the workspace root (path-traversal guard; defense in depth). */
|
|
211
|
+
function resolveInside(rootDir, relPath) {
|
|
212
|
+
const normalized = String(relPath).replace(/^[/\\]+/, "");
|
|
213
|
+
const target = path.resolve(rootDir, normalized);
|
|
214
|
+
const rootWithSep = path.resolve(rootDir) + path.sep;
|
|
215
|
+
if (target !== path.resolve(rootDir) && !target.startsWith(rootWithSep)) {
|
|
216
|
+
return null;
|
|
217
|
+
}
|
|
218
|
+
return target;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/**
|
|
222
|
+
* Write a file the agent produced to local disk, sandboxed to rootDir. Returns
|
|
223
|
+
* 'created' or 'updated', or null when refused (outside the workspace).
|
|
224
|
+
*/
|
|
225
|
+
async function applyFile(rootDir, relPath, content) {
|
|
226
|
+
const target = resolveInside(rootDir, relPath);
|
|
227
|
+
if (!target || (await isUnsafeFileTarget(rootDir, target))) return null;
|
|
228
|
+
let existed = true;
|
|
229
|
+
try {
|
|
230
|
+
await fs.access(target);
|
|
231
|
+
} catch {
|
|
232
|
+
existed = false;
|
|
233
|
+
}
|
|
234
|
+
await fs.mkdir(path.dirname(target), { recursive: true });
|
|
235
|
+
await fs.writeFile(target, content, "utf8");
|
|
236
|
+
return existed ? "updated" : "created";
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
// ── on-demand disk ops for the live tunnel (all sandboxed to rootDir) ────────
|
|
240
|
+
|
|
241
|
+
async function readFileRaw(rootDir, relPath) {
|
|
242
|
+
if (isSecretPath(relPath)) return null;
|
|
243
|
+
const target = resolveInside(rootDir, relPath);
|
|
244
|
+
if (!target || (await isUnsafeFileTarget(rootDir, target))) return null;
|
|
245
|
+
try {
|
|
246
|
+
return await fs.readFile(target, "utf8");
|
|
247
|
+
} catch {
|
|
248
|
+
return null;
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
async function writeFileRaw(rootDir, relPath, content) {
|
|
253
|
+
if (isSecretPath(relPath)) return null;
|
|
254
|
+
const target = resolveInside(rootDir, relPath);
|
|
255
|
+
if (!target || (await isUnsafeFileTarget(rootDir, target))) return false;
|
|
256
|
+
await fs.mkdir(path.dirname(target), { recursive: true });
|
|
257
|
+
await fs.writeFile(target, content, "utf8");
|
|
258
|
+
return true;
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
async function appendFileRaw(rootDir, relPath, content) {
|
|
262
|
+
if (isSecretPath(relPath)) return null;
|
|
263
|
+
const target = resolveInside(rootDir, relPath);
|
|
264
|
+
if (!target || (await isUnsafeFileTarget(rootDir, target))) return false;
|
|
265
|
+
await fs.mkdir(path.dirname(target), { recursive: true });
|
|
266
|
+
await fs.appendFile(target, content, "utf8");
|
|
267
|
+
return true;
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
async function editFileRaw(rootDir, relPath, oldString, newString) {
|
|
271
|
+
if (isSecretPath(relPath)) return null;
|
|
272
|
+
const target = resolveInside(rootDir, relPath);
|
|
273
|
+
if (!target || (await isUnsafeFileTarget(rootDir, target))) {
|
|
274
|
+
return { refused: true, replaced: false };
|
|
275
|
+
}
|
|
276
|
+
let current;
|
|
277
|
+
try {
|
|
278
|
+
current = await fs.readFile(target, "utf8");
|
|
279
|
+
} catch {
|
|
280
|
+
return { replaced: false };
|
|
281
|
+
}
|
|
282
|
+
const idx = current.indexOf(oldString);
|
|
283
|
+
if (idx < 0) return { replaced: false };
|
|
284
|
+
const next =
|
|
285
|
+
current.slice(0, idx) + newString + current.slice(idx + oldString.length);
|
|
286
|
+
await fs.writeFile(target, next, "utf8");
|
|
287
|
+
return { replaced: true };
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
async function hasSymlinkAncestor(rootDir, target) {
|
|
291
|
+
const root = path.resolve(rootDir);
|
|
292
|
+
let current = path.dirname(target);
|
|
293
|
+
while (current !== root && current.startsWith(`${root}${path.sep}`)) {
|
|
294
|
+
try {
|
|
295
|
+
if ((await fs.lstat(current)).isSymbolicLink()) return true;
|
|
296
|
+
} catch (error) {
|
|
297
|
+
if (error?.code !== "ENOENT") throw error;
|
|
298
|
+
}
|
|
299
|
+
current = path.dirname(current);
|
|
300
|
+
}
|
|
301
|
+
return false;
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
async function isUnsafeFileTarget(rootDir, target) {
|
|
305
|
+
if (await hasSymlinkAncestor(rootDir, target)) return true;
|
|
306
|
+
try {
|
|
307
|
+
const stat = await fs.lstat(target);
|
|
308
|
+
return stat.isSymbolicLink() || !stat.isFile();
|
|
309
|
+
} catch (error) {
|
|
310
|
+
if (error?.code === "ENOENT") return false;
|
|
311
|
+
throw error;
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
/** Destructive operations are stricter than ordinary writes: only regular
|
|
316
|
+
* files are accepted and symlink traversal is refused. */
|
|
317
|
+
async function deleteFileRaw(rootDir, relPath) {
|
|
318
|
+
if (isSecretPath(relPath)) return null;
|
|
319
|
+
const target = resolveInside(rootDir, relPath);
|
|
320
|
+
if (!target || (await hasSymlinkAncestor(rootDir, target))) {
|
|
321
|
+
return { refused: true, deleted: false };
|
|
322
|
+
}
|
|
323
|
+
try {
|
|
324
|
+
const stat = await fs.lstat(target);
|
|
325
|
+
if (!stat.isFile() || stat.isSymbolicLink()) {
|
|
326
|
+
return { refused: true, deleted: false };
|
|
327
|
+
}
|
|
328
|
+
await fs.unlink(target);
|
|
329
|
+
return { deleted: true };
|
|
330
|
+
} catch (error) {
|
|
331
|
+
if (error?.code === "ENOENT") return { deleted: false };
|
|
332
|
+
throw error;
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
async function moveFileRaw(rootDir, fromPath, toPath) {
|
|
337
|
+
if (isSecretPath(fromPath) || isSecretPath(toPath)) return null;
|
|
338
|
+
const source = resolveInside(rootDir, fromPath);
|
|
339
|
+
const target = resolveInside(rootDir, toPath);
|
|
340
|
+
if (
|
|
341
|
+
!source ||
|
|
342
|
+
!target ||
|
|
343
|
+
source === target ||
|
|
344
|
+
(await hasSymlinkAncestor(rootDir, source)) ||
|
|
345
|
+
(await hasSymlinkAncestor(rootDir, target))
|
|
346
|
+
) {
|
|
347
|
+
return { refused: true, moved: false };
|
|
348
|
+
}
|
|
349
|
+
try {
|
|
350
|
+
const sourceStat = await fs.lstat(source);
|
|
351
|
+
if (!sourceStat.isFile() || sourceStat.isSymbolicLink()) {
|
|
352
|
+
return { refused: true, moved: false };
|
|
353
|
+
}
|
|
354
|
+
} catch (error) {
|
|
355
|
+
if (error?.code === "ENOENT") return { moved: false };
|
|
356
|
+
throw error;
|
|
357
|
+
}
|
|
358
|
+
try {
|
|
359
|
+
const targetStat = await fs.lstat(target);
|
|
360
|
+
if (!targetStat.isFile() || targetStat.isSymbolicLink()) {
|
|
361
|
+
return { refused: true, moved: false };
|
|
362
|
+
}
|
|
363
|
+
} catch (error) {
|
|
364
|
+
if (error?.code !== "ENOENT") throw error;
|
|
365
|
+
}
|
|
366
|
+
await fs.mkdir(path.dirname(target), { recursive: true });
|
|
367
|
+
await fs.rename(source, target);
|
|
368
|
+
return { moved: true };
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
/** List local file paths for the agent (gitignore-aware, junk-dir pruned,
|
|
372
|
+
* size-capped). Names only — the agent reads what it needs via `read`. */
|
|
373
|
+
async function listFiles(rootDir) {
|
|
374
|
+
const isIgnored = await loadGitignore(rootDir);
|
|
375
|
+
const out = [];
|
|
376
|
+
const MAX = 500;
|
|
377
|
+
|
|
378
|
+
async function walk(dir) {
|
|
379
|
+
if (out.length >= MAX) return;
|
|
380
|
+
let entries;
|
|
381
|
+
try {
|
|
382
|
+
entries = await fs.readdir(dir, { withFileTypes: true });
|
|
383
|
+
} catch {
|
|
384
|
+
return;
|
|
385
|
+
}
|
|
386
|
+
for (const entry of entries) {
|
|
387
|
+
if (out.length >= MAX) return;
|
|
388
|
+
const full = path.join(dir, entry.name);
|
|
389
|
+
const rel = path.relative(rootDir, full).split(path.sep).join("/");
|
|
390
|
+
if (entry.isDirectory()) {
|
|
391
|
+
if (IGNORE_DIRS.has(entry.name)) continue;
|
|
392
|
+
if (isIgnored(rel, true)) continue;
|
|
393
|
+
await walk(full);
|
|
394
|
+
} else if (entry.isFile()) {
|
|
395
|
+
// Not merely unreadable — invisible. A path the agent can see is a path
|
|
396
|
+
// it will ask for, and the refusal would read as a bug rather than a
|
|
397
|
+
// boundary.
|
|
398
|
+
if (isSecretPath(rel)) continue;
|
|
399
|
+
if (isIgnored(rel, false)) continue;
|
|
400
|
+
let st;
|
|
401
|
+
try {
|
|
402
|
+
st = await fs.stat(full);
|
|
403
|
+
} catch {
|
|
404
|
+
continue;
|
|
405
|
+
}
|
|
406
|
+
if (st.size > MAX_FILE_BYTES) continue;
|
|
407
|
+
out.push(rel);
|
|
408
|
+
}
|
|
409
|
+
}
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
await walk(rootDir);
|
|
413
|
+
return out;
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
// ── search ─────────────────────────────────────────────────────────────────
|
|
417
|
+
// `list_files` caps at 500 paths and `read_file` opens one file at a time, so
|
|
418
|
+
// without this the agent is effectively blind on a real repo: it has to guess
|
|
419
|
+
// which files matter. Searching happens HERE, on the user's machine — only the
|
|
420
|
+
// matching lines cross the socket, never the repo.
|
|
421
|
+
|
|
422
|
+
const MAX_SEARCH_FILES = 5000;
|
|
423
|
+
const MAX_SEARCH_FILE_BYTES = 2 * 1024 * 1024;
|
|
424
|
+
const MAX_MATCH_LINE_CHARS = 400;
|
|
425
|
+
|
|
426
|
+
/** Translate a glob (`*.ts`, `src/**' + '/*.tsx`) into an anchored RegExp. */
|
|
427
|
+
function globToRegExp(glob) {
|
|
428
|
+
let out = "";
|
|
429
|
+
for (let i = 0; i < glob.length; i += 1) {
|
|
430
|
+
const char = glob[i];
|
|
431
|
+
if (char === "*") {
|
|
432
|
+
if (glob[i + 1] === "*") {
|
|
433
|
+
// `**/` may match zero directories, so the slash is optional.
|
|
434
|
+
if (glob[i + 2] === "/") {
|
|
435
|
+
out += "(?:.*/)?";
|
|
436
|
+
i += 2;
|
|
437
|
+
} else {
|
|
438
|
+
out += ".*";
|
|
439
|
+
i += 1;
|
|
440
|
+
}
|
|
441
|
+
} else {
|
|
442
|
+
out += "[^/]*";
|
|
443
|
+
}
|
|
444
|
+
continue;
|
|
445
|
+
}
|
|
446
|
+
if (char === "?") {
|
|
447
|
+
out += "[^/]";
|
|
448
|
+
continue;
|
|
449
|
+
}
|
|
450
|
+
out += char.replace(/[.+^${}()|[\]\\]/g, "\\$&");
|
|
451
|
+
}
|
|
452
|
+
return new RegExp(`^${out}$`);
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
function escapeRegExp(value) {
|
|
456
|
+
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
/** Walk the workspace, gitignore-aware, collecting relative file paths. */
|
|
460
|
+
async function collectFiles(rootDir, max) {
|
|
461
|
+
const isIgnored = await loadGitignore(rootDir);
|
|
462
|
+
const out = [];
|
|
463
|
+
|
|
464
|
+
async function walk(dir) {
|
|
465
|
+
if (out.length >= max) return;
|
|
466
|
+
let entries;
|
|
467
|
+
try {
|
|
468
|
+
entries = await fs.readdir(dir, { withFileTypes: true });
|
|
469
|
+
} catch {
|
|
470
|
+
return;
|
|
471
|
+
}
|
|
472
|
+
for (const entry of entries) {
|
|
473
|
+
if (out.length >= max) return;
|
|
474
|
+
const full = path.join(dir, entry.name);
|
|
475
|
+
const rel = path.relative(rootDir, full).split(path.sep).join("/");
|
|
476
|
+
if (entry.isDirectory()) {
|
|
477
|
+
if (IGNORE_DIRS.has(entry.name)) continue;
|
|
478
|
+
if (isIgnored(rel, true)) continue;
|
|
479
|
+
await walk(full);
|
|
480
|
+
} else if (entry.isFile()) {
|
|
481
|
+
if (isSecretPath(rel)) continue;
|
|
482
|
+
if (isIgnored(rel, false)) continue;
|
|
483
|
+
out.push(rel);
|
|
484
|
+
}
|
|
485
|
+
}
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
await walk(rootDir);
|
|
489
|
+
return out;
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
/**
|
|
493
|
+
* Grep the workspace. With `filesOnly` it behaves as a glob (find files by
|
|
494
|
+
* name); otherwise it returns matching lines with their line numbers.
|
|
495
|
+
*/
|
|
496
|
+
async function searchFiles(
|
|
497
|
+
rootDir,
|
|
498
|
+
{
|
|
499
|
+
pattern,
|
|
500
|
+
include,
|
|
501
|
+
filesOnly = false,
|
|
502
|
+
caseSensitive = false,
|
|
503
|
+
literal = false,
|
|
504
|
+
maxResults = 100,
|
|
505
|
+
} = {}
|
|
506
|
+
) {
|
|
507
|
+
const limit = Math.min(Math.max(1, Number(maxResults) || 100), 500);
|
|
508
|
+
const includeRe = include ? globToRegExp(include) : null;
|
|
509
|
+
let candidates = await collectFiles(rootDir, MAX_SEARCH_FILES);
|
|
510
|
+
const scannedAll = candidates.length < MAX_SEARCH_FILES;
|
|
511
|
+
if (includeRe) candidates = candidates.filter((rel) => includeRe.test(rel));
|
|
512
|
+
|
|
513
|
+
if (filesOnly && !pattern) {
|
|
514
|
+
return {
|
|
515
|
+
matches: candidates.slice(0, limit).map((rel) => ({ path: rel })),
|
|
516
|
+
truncated: candidates.length > limit || !scannedAll,
|
|
517
|
+
scannedFiles: candidates.length,
|
|
518
|
+
};
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
let regex;
|
|
522
|
+
try {
|
|
523
|
+
regex = new RegExp(
|
|
524
|
+
literal ? escapeRegExp(String(pattern)) : String(pattern),
|
|
525
|
+
caseSensitive ? "" : "i"
|
|
526
|
+
);
|
|
527
|
+
} catch (error) {
|
|
528
|
+
throw new Error(`Invalid search pattern: ${error.message}`);
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
const matches = [];
|
|
532
|
+
let scanned = 0;
|
|
533
|
+
for (const rel of candidates) {
|
|
534
|
+
if (matches.length >= limit) break;
|
|
535
|
+
const target = resolveInside(rootDir, rel);
|
|
536
|
+
if (!target || (await isUnsafeFileTarget(rootDir, target))) continue;
|
|
537
|
+
|
|
538
|
+
let buffer;
|
|
539
|
+
try {
|
|
540
|
+
const stat = await fs.stat(target);
|
|
541
|
+
if (stat.size > MAX_SEARCH_FILE_BYTES) continue;
|
|
542
|
+
buffer = await fs.readFile(target);
|
|
543
|
+
} catch {
|
|
544
|
+
continue;
|
|
545
|
+
}
|
|
546
|
+
// Binary files produce noise, not answers.
|
|
547
|
+
if (looksBinary(buffer)) continue;
|
|
548
|
+
scanned += 1;
|
|
549
|
+
|
|
550
|
+
if (filesOnly) {
|
|
551
|
+
if (regex.test(rel)) matches.push({ path: rel });
|
|
552
|
+
continue;
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
const lines = buffer.toString("utf8").split("\n");
|
|
556
|
+
for (let index = 0; index < lines.length; index += 1) {
|
|
557
|
+
if (matches.length >= limit) break;
|
|
558
|
+
if (!regex.test(lines[index])) continue;
|
|
559
|
+
const text = lines[index].trim();
|
|
560
|
+
matches.push({
|
|
561
|
+
path: rel,
|
|
562
|
+
line: index + 1,
|
|
563
|
+
text:
|
|
564
|
+
text.length > MAX_MATCH_LINE_CHARS
|
|
565
|
+
? `${text.slice(0, MAX_MATCH_LINE_CHARS)}…`
|
|
566
|
+
: text,
|
|
567
|
+
});
|
|
568
|
+
}
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
return {
|
|
572
|
+
matches,
|
|
573
|
+
truncated: matches.length >= limit || !scannedAll,
|
|
574
|
+
scannedFiles: scanned,
|
|
575
|
+
};
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
/** Does a file already exist on disk under rootDir? Used to decide overwrites. */
|
|
579
|
+
async function fileExists(rootDir, relPath) {
|
|
580
|
+
const target = resolveInside(rootDir, relPath);
|
|
581
|
+
if (!target || (await isUnsafeFileTarget(rootDir, target))) return false;
|
|
582
|
+
try {
|
|
583
|
+
await fs.access(target);
|
|
584
|
+
return true;
|
|
585
|
+
} catch {
|
|
586
|
+
return false;
|
|
587
|
+
}
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
module.exports = {
|
|
591
|
+
IGNORE_DIRS,
|
|
592
|
+
readSnapshot,
|
|
593
|
+
applyFile,
|
|
594
|
+
fileExists,
|
|
595
|
+
loadGitignore,
|
|
596
|
+
resolveInside,
|
|
597
|
+
isSecretPath,
|
|
598
|
+
isUnsafeFileTarget,
|
|
599
|
+
readFileRaw,
|
|
600
|
+
writeFileRaw,
|
|
601
|
+
appendFileRaw,
|
|
602
|
+
editFileRaw,
|
|
603
|
+
deleteFileRaw,
|
|
604
|
+
moveFileRaw,
|
|
605
|
+
listFiles,
|
|
606
|
+
searchFiles,
|
|
607
|
+
globToRegExp,
|
|
608
|
+
};
|