@myagentroam/node 0.1.0 → 0.1.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 +19 -2
- package/dist/capabilities.d.ts +5 -0
- package/dist/capabilities.js +58 -3
- package/dist/capabilities.js.map +1 -1
- package/dist/claude-agent-sdk.d.ts +1 -0
- package/dist/claude-agent-sdk.js +36 -2
- package/dist/claude-agent-sdk.js.map +1 -1
- package/dist/codex-app-server.d.ts +1 -0
- package/dist/codex-app-server.js +23 -5
- package/dist/codex-app-server.js.map +1 -1
- package/dist/connector.d.ts +41 -3
- package/dist/connector.js +625 -134
- package/dist/connector.js.map +1 -1
- package/dist/database.d.ts +13 -0
- package/dist/database.js +284 -239
- package/dist/database.js.map +1 -1
- package/dist/migrations/v001.d.ts +3 -0
- package/dist/migrations/v001.js +249 -0
- package/dist/migrations/v001.js.map +1 -0
- package/dist/migrations/v002.d.ts +4 -0
- package/dist/migrations/v002.js +144 -0
- package/dist/migrations/v002.js.map +1 -0
- package/dist/operational.d.ts +9 -0
- package/dist/operational.js +30 -0
- package/dist/operational.js.map +1 -1
- package/dist/workspace.d.ts +36 -8
- package/dist/workspace.js +315 -99
- package/dist/workspace.js.map +1 -1
- package/package.json +3 -2
package/dist/workspace.js
CHANGED
|
@@ -1,17 +1,270 @@
|
|
|
1
1
|
import { createHash } from 'node:crypto';
|
|
2
|
+
import { watch } from 'node:fs';
|
|
2
3
|
import { lstat, readdir, readFile, realpath, stat, unlink } from 'node:fs/promises';
|
|
3
4
|
import { homedir, platform as hostPlatform } from 'node:os';
|
|
4
5
|
import { dirname, isAbsolute, posix, relative, resolve, sep, win32 } from 'node:path';
|
|
5
6
|
import { spawn } from 'node:child_process';
|
|
7
|
+
import { minimatch } from 'minimatch';
|
|
6
8
|
const GIT_TIMEOUT_MS = 5_000;
|
|
7
9
|
const GIT_OUTPUT_LIMIT = 512 * 1024;
|
|
8
10
|
const FILE_READ_LIMIT = 512 * 1024;
|
|
9
11
|
const DIRECTORY_PAGE_LIMIT = 200;
|
|
10
12
|
const DIRECTORY_PICKER_LIMIT = 200;
|
|
11
13
|
const SEARCH_RESULT_LIMIT = 200;
|
|
12
|
-
const
|
|
13
|
-
const
|
|
14
|
+
const FILE_INDEX_IDLE_TTL_MS = 60 * 60 * 1_000;
|
|
15
|
+
const FILE_INDEX_ENTRY_LIMIT = 100_000;
|
|
16
|
+
const FILE_INDEX_FALLBACK_POLL_MS = 30_000;
|
|
17
|
+
const DEFAULT_QUICK_OPEN_EXCLUDES = [
|
|
18
|
+
'**/.git/**',
|
|
19
|
+
'**/node_modules/**',
|
|
20
|
+
'**/bower_components/**',
|
|
21
|
+
'**/.pnpm/**',
|
|
22
|
+
'**/.yarn/**',
|
|
23
|
+
'**/.cache/**',
|
|
24
|
+
'**/.parcel-cache/**',
|
|
25
|
+
'**/.turbo/**',
|
|
26
|
+
'**/dist/**',
|
|
27
|
+
'**/build/**',
|
|
28
|
+
'**/out/**',
|
|
29
|
+
'**/coverage/**',
|
|
30
|
+
'**/.next/**',
|
|
31
|
+
'**/.nuxt/**',
|
|
32
|
+
'**/.svelte-kit/**',
|
|
33
|
+
'**/.angular/**',
|
|
34
|
+
'**/target/**',
|
|
35
|
+
'**/.gradle/**',
|
|
36
|
+
'**/bin/**',
|
|
37
|
+
'**/obj/**',
|
|
38
|
+
'**/vendor/**',
|
|
39
|
+
'**/__pycache__/**',
|
|
40
|
+
'**/.venv/**',
|
|
41
|
+
'**/venv/**'
|
|
42
|
+
];
|
|
14
43
|
const REF_PATTERN = /^(?!-)(?!.*\.\.)(?!.*@\{)[A-Za-z0-9][A-Za-z0-9._/-]{0,254}$/;
|
|
44
|
+
/** A per-workspace, process-local index for the Quick Open file picker. */
|
|
45
|
+
export class WorkspaceFileIndex {
|
|
46
|
+
workspaceRoot;
|
|
47
|
+
limits;
|
|
48
|
+
entries = [];
|
|
49
|
+
truncated = false;
|
|
50
|
+
root;
|
|
51
|
+
loading;
|
|
52
|
+
stale = true;
|
|
53
|
+
watcher;
|
|
54
|
+
expiryTimer;
|
|
55
|
+
fallbackTimer;
|
|
56
|
+
signature = '';
|
|
57
|
+
constructor(workspaceRoot, limits = {}) {
|
|
58
|
+
this.workspaceRoot = workspaceRoot;
|
|
59
|
+
this.limits = limits;
|
|
60
|
+
}
|
|
61
|
+
/** Starts/renews the lightweight index observer without exposing file data. */
|
|
62
|
+
async observe() {
|
|
63
|
+
await this.ensureLoaded();
|
|
64
|
+
this.touch();
|
|
65
|
+
}
|
|
66
|
+
dispose() {
|
|
67
|
+
if (this.expiryTimer !== undefined)
|
|
68
|
+
clearTimeout(this.expiryTimer);
|
|
69
|
+
this.expiryTimer = undefined;
|
|
70
|
+
this.watcher?.close();
|
|
71
|
+
this.watcher = undefined;
|
|
72
|
+
if (this.fallbackTimer !== undefined)
|
|
73
|
+
clearInterval(this.fallbackTimer);
|
|
74
|
+
this.fallbackTimer = undefined;
|
|
75
|
+
this.entries = [];
|
|
76
|
+
this.truncated = false;
|
|
77
|
+
this.stale = true;
|
|
78
|
+
this.signature = '';
|
|
79
|
+
}
|
|
80
|
+
async search(query, cursor, limit = SEARCH_RESULT_LIMIT) {
|
|
81
|
+
if (query.trim().length === 0 ||
|
|
82
|
+
query.length > 256 ||
|
|
83
|
+
!Number.isSafeInteger(limit) ||
|
|
84
|
+
limit < 1 ||
|
|
85
|
+
limit > SEARCH_RESULT_LIMIT)
|
|
86
|
+
throw new Error('FILE_SEARCH_LIMIT_EXCEEDED');
|
|
87
|
+
await this.ensureLoaded();
|
|
88
|
+
this.touch();
|
|
89
|
+
const after = decodeSearchCursor(cursor, query);
|
|
90
|
+
const ranked = this.entries
|
|
91
|
+
.map((entry) => ({ entry, score: fuzzyPathScore(entry.path, query) }))
|
|
92
|
+
.filter((candidate) => candidate.score !== -1)
|
|
93
|
+
.sort((left, right) => right.score - left.score || left.entry.path.localeCompare(right.entry.path));
|
|
94
|
+
const start = after === undefined ? 0 : ranked.findIndex((candidate) => candidate.entry.path === after) + 1;
|
|
95
|
+
const page = ranked
|
|
96
|
+
.slice(Math.max(0, start), Math.max(0, start) + limit)
|
|
97
|
+
.map((candidate) => candidate.entry);
|
|
98
|
+
const next = ranked[Math.max(0, start) + limit]?.entry.path;
|
|
99
|
+
return {
|
|
100
|
+
entries: page,
|
|
101
|
+
nextCursor: next === undefined ? null : encodeSearchCursor(query, page.at(-1)?.path ?? after ?? ''),
|
|
102
|
+
truncated: next !== undefined || this.truncated
|
|
103
|
+
};
|
|
104
|
+
}
|
|
105
|
+
async ensureLoaded() {
|
|
106
|
+
if (!this.stale && this.root !== undefined)
|
|
107
|
+
return;
|
|
108
|
+
if (this.loading === undefined) {
|
|
109
|
+
this.loading = this.rebuild().finally(() => {
|
|
110
|
+
this.loading = undefined;
|
|
111
|
+
});
|
|
112
|
+
}
|
|
113
|
+
await this.loading;
|
|
114
|
+
}
|
|
115
|
+
touch() {
|
|
116
|
+
if (this.expiryTimer !== undefined)
|
|
117
|
+
clearTimeout(this.expiryTimer);
|
|
118
|
+
this.expiryTimer = setTimeout(() => this.dispose(), this.limits.idleTtlMs ?? FILE_INDEX_IDLE_TTL_MS);
|
|
119
|
+
this.expiryTimer.unref();
|
|
120
|
+
}
|
|
121
|
+
async rebuild() {
|
|
122
|
+
const root = await realpath(this.workspaceRoot);
|
|
123
|
+
const excludes = [...DEFAULT_QUICK_OPEN_EXCLUDES, ...(await workspaceExcludePatterns(root))];
|
|
124
|
+
const indexed = [];
|
|
125
|
+
const entryLimit = this.limits.entryLimit ?? FILE_INDEX_ENTRY_LIMIT;
|
|
126
|
+
let truncated = false;
|
|
127
|
+
const directories = ['.'];
|
|
128
|
+
while (directories.length > 0 && !truncated) {
|
|
129
|
+
const relativeDirectory = directories.shift();
|
|
130
|
+
const directory = await resolveWorkspaceEntry(root, relativeDirectory, true).catch(() => undefined);
|
|
131
|
+
if (directory === undefined)
|
|
132
|
+
continue;
|
|
133
|
+
const children = await readdir(directory, { withFileTypes: true }).catch(() => undefined);
|
|
134
|
+
if (children === undefined)
|
|
135
|
+
continue;
|
|
136
|
+
for (const child of children) {
|
|
137
|
+
const candidatePath = relative(root, resolve(directory, child.name)).split(sep).join('/');
|
|
138
|
+
if (candidatePath === '.git' ||
|
|
139
|
+
candidatePath.startsWith('.git/') ||
|
|
140
|
+
isExcluded(candidatePath, excludes))
|
|
141
|
+
continue;
|
|
142
|
+
const resolved = await resolveWorkspaceEntry(root, candidatePath, false).catch(() => undefined);
|
|
143
|
+
if (resolved === undefined)
|
|
144
|
+
continue;
|
|
145
|
+
const metadata = await lstat(resolved).catch(() => undefined);
|
|
146
|
+
if (metadata === undefined || (!metadata.isFile() && !metadata.isDirectory()))
|
|
147
|
+
continue;
|
|
148
|
+
const safePath = relative(root, resolved).split(sep).join('/');
|
|
149
|
+
if (metadata.isDirectory()) {
|
|
150
|
+
directories.push(safePath);
|
|
151
|
+
continue;
|
|
152
|
+
}
|
|
153
|
+
indexed.push({
|
|
154
|
+
path: safePath,
|
|
155
|
+
name: child.name,
|
|
156
|
+
kind: 'FILE',
|
|
157
|
+
size: metadata.size,
|
|
158
|
+
modifiedAt: metadata.mtimeMs
|
|
159
|
+
});
|
|
160
|
+
if (indexed.length >= entryLimit) {
|
|
161
|
+
truncated = true;
|
|
162
|
+
break;
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
this.root = root;
|
|
167
|
+
this.entries = indexed;
|
|
168
|
+
this.truncated = truncated;
|
|
169
|
+
this.signature = fileIndexSignature(indexed, truncated);
|
|
170
|
+
this.stale = false;
|
|
171
|
+
this.watcher?.close();
|
|
172
|
+
// fs.watch is advisory. Any change safely invalidates the in-memory index;
|
|
173
|
+
// the next lookup rebuilds it and never trusts stale paths for file reads.
|
|
174
|
+
try {
|
|
175
|
+
this.watcher = watch(root, { persistent: false, recursive: true }, () => {
|
|
176
|
+
this.invalidate();
|
|
177
|
+
});
|
|
178
|
+
this.watcher.on('error', () => {
|
|
179
|
+
this.invalidate();
|
|
180
|
+
this.watcher?.close();
|
|
181
|
+
this.watcher = undefined;
|
|
182
|
+
this.startFallbackPolling();
|
|
183
|
+
});
|
|
184
|
+
}
|
|
185
|
+
catch {
|
|
186
|
+
// Recursive watching is unavailable on some filesystems. A low-frequency
|
|
187
|
+
// scan still rebuilds the index, but only emits after its metadata changed.
|
|
188
|
+
this.watcher = undefined;
|
|
189
|
+
this.startFallbackPolling();
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
startFallbackPolling() {
|
|
193
|
+
if (this.fallbackTimer !== undefined)
|
|
194
|
+
return;
|
|
195
|
+
this.fallbackTimer = setInterval(() => void this.pollFallback(), FILE_INDEX_FALLBACK_POLL_MS);
|
|
196
|
+
this.fallbackTimer.unref();
|
|
197
|
+
}
|
|
198
|
+
async pollFallback() {
|
|
199
|
+
if (this.loading !== undefined)
|
|
200
|
+
return;
|
|
201
|
+
const previous = this.signature;
|
|
202
|
+
this.stale = true;
|
|
203
|
+
try {
|
|
204
|
+
await this.ensureLoaded();
|
|
205
|
+
if (previous !== this.signature)
|
|
206
|
+
this.limits.onInvalidated?.();
|
|
207
|
+
}
|
|
208
|
+
catch {
|
|
209
|
+
// Keep the current last-known index. A later poll can recover when the
|
|
210
|
+
// Workspace becomes readable again.
|
|
211
|
+
this.stale = false;
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
invalidate() {
|
|
215
|
+
if (this.stale)
|
|
216
|
+
return;
|
|
217
|
+
this.stale = true;
|
|
218
|
+
this.limits.onInvalidated?.();
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
function fileIndexSignature(entries, truncated) {
|
|
222
|
+
return JSON.stringify({
|
|
223
|
+
truncated,
|
|
224
|
+
entries: entries.map((entry) => [entry.path, entry.size, entry.modifiedAt])
|
|
225
|
+
});
|
|
226
|
+
}
|
|
227
|
+
async function workspaceExcludePatterns(root) {
|
|
228
|
+
const settingsPath = resolve(root, '.vscode/settings.json');
|
|
229
|
+
const settings = (await readFile(settingsPath, 'utf8')
|
|
230
|
+
.then(JSON.parse)
|
|
231
|
+
.catch(() => undefined));
|
|
232
|
+
if (typeof settings !== 'object' || settings === null)
|
|
233
|
+
return [];
|
|
234
|
+
return ['files.exclude', 'search.exclude'].flatMap((key) => {
|
|
235
|
+
const value = settings[key];
|
|
236
|
+
return typeof value === 'object' && value !== null
|
|
237
|
+
? Object.entries(value).flatMap(([pattern, enabled]) => (enabled === true ? [pattern] : []))
|
|
238
|
+
: [];
|
|
239
|
+
});
|
|
240
|
+
}
|
|
241
|
+
function isExcluded(path, patterns) {
|
|
242
|
+
return patterns.some((pattern) => minimatch(path, pattern, { dot: true, nocase: hostPlatform() !== 'linux' }) ||
|
|
243
|
+
minimatch(`${path}/`, pattern, { dot: true, nocase: hostPlatform() !== 'linux' }));
|
|
244
|
+
}
|
|
245
|
+
function fuzzyPathScore(path, query) {
|
|
246
|
+
const source = path.toLocaleLowerCase();
|
|
247
|
+
const needle = query.trim().toLocaleLowerCase();
|
|
248
|
+
let cursor = 0;
|
|
249
|
+
let score = 0;
|
|
250
|
+
let previous = -2;
|
|
251
|
+
for (const character of needle) {
|
|
252
|
+
const index = source.indexOf(character, cursor);
|
|
253
|
+
if (index === -1)
|
|
254
|
+
return -1;
|
|
255
|
+
score += index === previous + 1 ? 12 : 1;
|
|
256
|
+
if (index === 0 || '/._-'.includes(source[index - 1] ?? ''))
|
|
257
|
+
score += 8;
|
|
258
|
+
previous = index;
|
|
259
|
+
cursor = index + 1;
|
|
260
|
+
}
|
|
261
|
+
const name = path.slice(path.lastIndexOf('/') + 1).toLocaleLowerCase();
|
|
262
|
+
if (name.includes(needle))
|
|
263
|
+
score += 40;
|
|
264
|
+
else if (source.includes(needle))
|
|
265
|
+
score += 16;
|
|
266
|
+
return score - path.length / 1_000;
|
|
267
|
+
}
|
|
15
268
|
/**
|
|
16
269
|
* Lists directory names only for the Workspace picker. Browsing begins at the
|
|
17
270
|
* first allowed root when configured, while the Node account home remains a
|
|
@@ -51,10 +304,10 @@ export async function listWorkspaceDirectories(requestedPath, allowedRoots, home
|
|
|
51
304
|
truncated: entries.length > DIRECTORY_PICKER_LIMIT
|
|
52
305
|
};
|
|
53
306
|
}
|
|
54
|
-
export async function listWorkspaceFiles(workspaceRoot, requestedPath = '.', cursor, limit = DIRECTORY_PAGE_LIMIT
|
|
307
|
+
export async function listWorkspaceFiles(workspaceRoot, requestedPath = '.', cursor, limit = DIRECTORY_PAGE_LIMIT) {
|
|
55
308
|
if (!Number.isSafeInteger(limit) || limit < 1 || limit > DIRECTORY_PAGE_LIMIT)
|
|
56
309
|
throw new Error('DIRECTORY_CURSOR_INVALID');
|
|
57
|
-
const directory = await resolveWorkspaceEntry(workspaceRoot, requestedPath, true
|
|
310
|
+
const directory = await resolveWorkspaceEntry(workspaceRoot, requestedPath, true);
|
|
58
311
|
const entries = await readdir(directory, { withFileTypes: true });
|
|
59
312
|
const root = await realpath(workspaceRoot);
|
|
60
313
|
const cursorValue = decodeDirectoryCursor(cursor, requestedPath);
|
|
@@ -68,10 +321,18 @@ export async function listWorkspaceFiles(workspaceRoot, requestedPath = '.', cur
|
|
|
68
321
|
const pageNames = names.slice(start, start + limit);
|
|
69
322
|
const output = [];
|
|
70
323
|
for (const name of pageNames) {
|
|
71
|
-
const child = await resolveWorkspaceEntry(root, relative(root, resolve(directory, name)), false
|
|
324
|
+
const child = await resolveWorkspaceEntry(root, relative(root, resolve(directory, name)), false).catch((error) => {
|
|
325
|
+
if (isSkippableWorkspaceEntryError(error))
|
|
326
|
+
return undefined;
|
|
327
|
+
throw error;
|
|
328
|
+
});
|
|
72
329
|
if (child === undefined)
|
|
73
330
|
continue;
|
|
74
|
-
const metadata = await lstat(child).catch(() =>
|
|
331
|
+
const metadata = await lstat(child).catch((error) => {
|
|
332
|
+
if (error.code === 'ENOENT')
|
|
333
|
+
return undefined;
|
|
334
|
+
throw error;
|
|
335
|
+
});
|
|
75
336
|
if (metadata === undefined || (!metadata.isFile() && !metadata.isDirectory()))
|
|
76
337
|
continue;
|
|
77
338
|
output.push({
|
|
@@ -91,8 +352,14 @@ export async function listWorkspaceFiles(workspaceRoot, requestedPath = '.', cur
|
|
|
91
352
|
truncated: nextName !== undefined
|
|
92
353
|
};
|
|
93
354
|
}
|
|
355
|
+
function isSkippableWorkspaceEntryError(error) {
|
|
356
|
+
return (error instanceof Error &&
|
|
357
|
+
(error.message === 'FILE_PATH_ESCAPE' ||
|
|
358
|
+
error.message === 'FILE_NOT_FOUND' ||
|
|
359
|
+
error.message === 'FILE_NOT_REGULAR'));
|
|
360
|
+
}
|
|
94
361
|
/** Validates an upload destination without following an existing target symlink. */
|
|
95
|
-
export async function preflightWorkspaceUpload(workspaceRoot, requestedDirectory, name
|
|
362
|
+
export async function preflightWorkspaceUpload(workspaceRoot, requestedDirectory, name) {
|
|
96
363
|
if (name.length === 0 ||
|
|
97
364
|
name === '.' ||
|
|
98
365
|
name === '..' ||
|
|
@@ -103,11 +370,10 @@ export async function preflightWorkspaceUpload(workspaceRoot, requestedDirectory
|
|
|
103
370
|
return { path: '', absolutePath: '', status: 'INVALID_NAME' };
|
|
104
371
|
}
|
|
105
372
|
const root = await realpath(workspaceRoot);
|
|
106
|
-
const directory = await resolveWorkspaceEntry(root, requestedDirectory, true
|
|
373
|
+
const directory = await resolveWorkspaceEntry(root, requestedDirectory, true);
|
|
107
374
|
const absolutePath = resolve(directory, name);
|
|
108
375
|
if (dirname(absolutePath) !== directory)
|
|
109
376
|
throw new Error('FILE_PATH_ESCAPE');
|
|
110
|
-
assertNotPrivate(absolutePath, options.privateRoots);
|
|
111
377
|
const relativePath = relative(root, absolutePath).split(sep).join('/');
|
|
112
378
|
const metadata = await lstat(absolutePath).catch((error) => {
|
|
113
379
|
if (error.code === 'ENOENT')
|
|
@@ -128,7 +394,7 @@ export function workspaceUploadTemporaryName(uploadId) {
|
|
|
128
394
|
function isWorkspaceUploadTemporaryName(name) {
|
|
129
395
|
return /^\.mar-upload-[0-9a-f-]{36}\.part$/i.test(name);
|
|
130
396
|
}
|
|
131
|
-
export async function readWorkspaceTextFile(workspaceRoot, requestedPath, offset = 0, limit = FILE_READ_LIMIT
|
|
397
|
+
export async function readWorkspaceTextFile(workspaceRoot, requestedPath, offset = 0, limit = FILE_READ_LIMIT) {
|
|
132
398
|
if (!Number.isSafeInteger(offset) ||
|
|
133
399
|
offset < 0 ||
|
|
134
400
|
!Number.isSafeInteger(limit) ||
|
|
@@ -136,7 +402,7 @@ export async function readWorkspaceTextFile(workspaceRoot, requestedPath, offset
|
|
|
136
402
|
limit > FILE_READ_LIMIT)
|
|
137
403
|
throw new Error('FILE_RANGE_INVALID');
|
|
138
404
|
const root = await realpath(workspaceRoot);
|
|
139
|
-
const file = await resolveWorkspaceEntry(root, requestedPath, false
|
|
405
|
+
const file = await resolveWorkspaceEntry(root, requestedPath, false);
|
|
140
406
|
const metadata = await lstat(file);
|
|
141
407
|
if (!metadata.isFile())
|
|
142
408
|
throw new Error('FILE_NOT_REGULAR');
|
|
@@ -181,7 +447,7 @@ export async function readWorkspaceTextFile(workspaceRoot, requestedPath, offset
|
|
|
181
447
|
};
|
|
182
448
|
}
|
|
183
449
|
/** Reads one absolute file only when the resolved target remains within a Node allowed root. */
|
|
184
|
-
export async function readAllowedTextFile(requestedPath, allowedRoots, offset = 0, limit = FILE_READ_LIMIT
|
|
450
|
+
export async function readAllowedTextFile(requestedPath, allowedRoots, offset = 0, limit = FILE_READ_LIMIT) {
|
|
185
451
|
if (!isAbsolute(requestedPath))
|
|
186
452
|
throw new Error('FILE_PATH_ESCAPE');
|
|
187
453
|
const file = await realpath(requestedPath).catch((error) => {
|
|
@@ -192,7 +458,6 @@ export async function readAllowedTextFile(requestedPath, allowedRoots, offset =
|
|
|
192
458
|
const roots = await Promise.all(allowedRoots.map((root) => realpath(root)));
|
|
193
459
|
if (!isWithinAllowedRoot(file, roots))
|
|
194
460
|
throw new Error('FILE_PATH_ESCAPE');
|
|
195
|
-
assertNotPrivate(file, options.privateRoots);
|
|
196
461
|
const metadata = await lstat(file);
|
|
197
462
|
if (!metadata.isFile())
|
|
198
463
|
throw new Error('FILE_NOT_REGULAR');
|
|
@@ -253,83 +518,16 @@ function isUtf8ContinuationByte(value) {
|
|
|
253
518
|
return (value & 0b1100_0000) === 0b1000_0000;
|
|
254
519
|
}
|
|
255
520
|
/** Searches file names only, so an exploratory request cannot exfiltrate file contents. */
|
|
256
|
-
export async function searchWorkspaceFiles(workspaceRoot, query, cursor, limit = SEARCH_RESULT_LIMIT
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
const after = decodeSearchCursor(cursor, query);
|
|
264
|
-
const deadline = Date.now() + FILE_SEARCH_TIMEOUT_MS;
|
|
265
|
-
const results = [];
|
|
266
|
-
let scanned = 0;
|
|
267
|
-
let exhausted = true;
|
|
268
|
-
const directories = ['.'];
|
|
269
|
-
const normalizedQuery = query.toLocaleLowerCase();
|
|
270
|
-
while (directories.length > 0) {
|
|
271
|
-
if (Date.now() > deadline || scanned >= SEARCH_SCAN_LIMIT) {
|
|
272
|
-
exhausted = false;
|
|
273
|
-
break;
|
|
274
|
-
}
|
|
275
|
-
const directoryPath = directories.shift();
|
|
276
|
-
const directory = await resolveWorkspaceEntry(root, directoryPath, true, options).catch((error) => {
|
|
277
|
-
if (error instanceof Error && error.message === 'FILE_NOT_FOUND')
|
|
278
|
-
return undefined;
|
|
279
|
-
throw error;
|
|
280
|
-
});
|
|
281
|
-
if (directory === undefined)
|
|
282
|
-
continue;
|
|
283
|
-
const entries = await readdir(directory, { withFileTypes: true }).catch((error) => {
|
|
284
|
-
if (error.code === 'ENOENT')
|
|
285
|
-
return undefined;
|
|
286
|
-
throw error;
|
|
287
|
-
});
|
|
288
|
-
if (entries === undefined)
|
|
289
|
-
continue;
|
|
290
|
-
for (const entry of entries.sort((left, right) => left.name.localeCompare(right.name))) {
|
|
291
|
-
if (entry.name === '.git')
|
|
292
|
-
continue;
|
|
293
|
-
scanned += 1;
|
|
294
|
-
if (scanned > SEARCH_SCAN_LIMIT || Date.now() > deadline) {
|
|
295
|
-
exhausted = false;
|
|
296
|
-
break;
|
|
297
|
-
}
|
|
298
|
-
const requestPath = relative(root, resolve(directory, entry.name)).split(sep).join('/');
|
|
299
|
-
const child = await resolveWorkspaceEntry(root, requestPath, false, options).catch(() => undefined);
|
|
300
|
-
if (child === undefined)
|
|
301
|
-
continue;
|
|
302
|
-
const metadata = await lstat(child).catch(() => undefined);
|
|
303
|
-
if (metadata === undefined || (!metadata.isFile() && !metadata.isDirectory()))
|
|
304
|
-
continue;
|
|
305
|
-
const safePath = relative(root, child).split(sep).join('/');
|
|
306
|
-
if (metadata.isDirectory())
|
|
307
|
-
directories.push(safePath);
|
|
308
|
-
if (safePath <= (after ?? '') || !safePath.toLocaleLowerCase().includes(normalizedQuery))
|
|
309
|
-
continue;
|
|
310
|
-
results.push({
|
|
311
|
-
path: safePath,
|
|
312
|
-
name: entry.name,
|
|
313
|
-
kind: metadata.isDirectory() ? 'DIRECTORY' : 'FILE',
|
|
314
|
-
size: metadata.isFile() ? metadata.size : null,
|
|
315
|
-
modifiedAt: metadata.mtimeMs
|
|
316
|
-
});
|
|
317
|
-
if (results.length > limit) {
|
|
318
|
-
exhausted = false;
|
|
319
|
-
break;
|
|
320
|
-
}
|
|
321
|
-
}
|
|
322
|
-
if (!exhausted || results.length > limit)
|
|
323
|
-
break;
|
|
521
|
+
export async function searchWorkspaceFiles(workspaceRoot, query, cursor, limit = SEARCH_RESULT_LIMIT) {
|
|
522
|
+
const index = new WorkspaceFileIndex(workspaceRoot);
|
|
523
|
+
try {
|
|
524
|
+
return await index.search(query, cursor, limit);
|
|
525
|
+
}
|
|
526
|
+
finally {
|
|
527
|
+
index.dispose();
|
|
324
528
|
}
|
|
325
|
-
results.sort((left, right) => left.path.localeCompare(right.path));
|
|
326
|
-
const page = results.slice(0, limit);
|
|
327
|
-
const nextCursor = !exhausted || results.length > limit
|
|
328
|
-
? encodeSearchCursor(query, page[page.length - 1]?.path ?? after ?? '')
|
|
329
|
-
: null;
|
|
330
|
-
return { entries: page, nextCursor, truncated: nextCursor !== null };
|
|
331
529
|
}
|
|
332
|
-
async function resolveWorkspaceEntry(workspaceRoot, requestedPath, directory
|
|
530
|
+
async function resolveWorkspaceEntry(workspaceRoot, requestedPath, directory) {
|
|
333
531
|
if (requestedPath.includes('\0') ||
|
|
334
532
|
requestedPath.length === 0 ||
|
|
335
533
|
requestedPath.startsWith('/') ||
|
|
@@ -353,20 +551,11 @@ async function resolveWorkspaceEntry(workspaceRoot, requestedPath, directory, op
|
|
|
353
551
|
pathToCandidate === '..' ||
|
|
354
552
|
pathToCandidate.startsWith(`..${sep}`))
|
|
355
553
|
throw new Error('FILE_PATH_ESCAPE');
|
|
356
|
-
assertNotPrivate(candidate, options.privateRoots);
|
|
357
554
|
const metadata = await lstat(candidate);
|
|
358
555
|
if (directory ? !metadata.isDirectory() : !metadata.isFile() && !metadata.isDirectory())
|
|
359
556
|
throw new Error(directory ? 'FILE_NOT_DIRECTORY' : 'FILE_NOT_REGULAR');
|
|
360
557
|
return candidate;
|
|
361
558
|
}
|
|
362
|
-
function assertNotPrivate(candidate, privateRoots) {
|
|
363
|
-
if (privateRoots?.some((privateRoot) => isSameOrDescendant(privateRoot, candidate)))
|
|
364
|
-
throw new Error('FILE_PATH_ESCAPE');
|
|
365
|
-
}
|
|
366
|
-
function isSameOrDescendant(root, candidate) {
|
|
367
|
-
const relativePath = relative(root, candidate);
|
|
368
|
-
return (relativePath === '' || (!relativePath.startsWith('..') && !relativePath.startsWith(`..${sep}`)));
|
|
369
|
-
}
|
|
370
559
|
function encodeDirectoryCursor(path, name) {
|
|
371
560
|
return Buffer.from(JSON.stringify({ path, name })).toString('base64url');
|
|
372
561
|
}
|
|
@@ -554,6 +743,7 @@ export async function readCurrentChanges(workspacePath) {
|
|
|
554
743
|
}
|
|
555
744
|
export async function readCurrentChangeDiff(workspacePath, requestedPath) {
|
|
556
745
|
const path = safeGitWorkspacePath(requestedPath);
|
|
746
|
+
await assertCurrentChangePathAccessible(workspacePath, path);
|
|
557
747
|
const result = await runGit(workspacePath, [
|
|
558
748
|
'diff',
|
|
559
749
|
'--no-ext-diff',
|
|
@@ -579,6 +769,33 @@ export async function readCurrentChangeDiff(workspacePath, requestedPath) {
|
|
|
579
769
|
...(after === undefined ? {} : { afterTruncated: after.truncated })
|
|
580
770
|
};
|
|
581
771
|
}
|
|
772
|
+
/** Current Changes must not expose an external link. */
|
|
773
|
+
export async function isWorkspaceChangeVisible(workspacePath, requestedPath) {
|
|
774
|
+
try {
|
|
775
|
+
await assertCurrentChangePathAccessible(workspacePath, safeGitWorkspacePath(requestedPath));
|
|
776
|
+
return true;
|
|
777
|
+
}
|
|
778
|
+
catch (error) {
|
|
779
|
+
if (error instanceof Error &&
|
|
780
|
+
(error.message === 'FILE_PATH_ESCAPE' ||
|
|
781
|
+
error.message === 'FILE_NOT_FOUND' ||
|
|
782
|
+
error.message === 'FILE_NOT_REGULAR'))
|
|
783
|
+
return false;
|
|
784
|
+
throw error;
|
|
785
|
+
}
|
|
786
|
+
}
|
|
787
|
+
async function assertCurrentChangePathAccessible(workspacePath, path) {
|
|
788
|
+
const root = await realpath(workspacePath);
|
|
789
|
+
const candidate = resolve(root, path);
|
|
790
|
+
const metadata = await lstat(candidate).catch((error) => {
|
|
791
|
+
if (error.code === 'ENOENT')
|
|
792
|
+
return undefined;
|
|
793
|
+
throw error;
|
|
794
|
+
});
|
|
795
|
+
if (metadata === undefined)
|
|
796
|
+
return;
|
|
797
|
+
await resolveWorkspaceEntry(root, path, false);
|
|
798
|
+
}
|
|
582
799
|
async function readWorkingTreeText(workspacePath, path) {
|
|
583
800
|
const root = await realpath(workspacePath);
|
|
584
801
|
let file;
|
|
@@ -601,7 +818,7 @@ function diffTextFromBuffer(source, truncated) {
|
|
|
601
818
|
end -= 1;
|
|
602
819
|
return { binary: false, text: source.subarray(0, end).toString('utf8'), truncated };
|
|
603
820
|
}
|
|
604
|
-
export async function restoreCurrentChange(workspacePath, requestedPath, state
|
|
821
|
+
export async function restoreCurrentChange(workspacePath, requestedPath, state) {
|
|
605
822
|
const path = safeGitWorkspacePath(requestedPath);
|
|
606
823
|
const pathspec = `:(literal)${path}`;
|
|
607
824
|
if (state === 'UNTRACKED') {
|
|
@@ -619,7 +836,6 @@ export async function restoreCurrentChange(workspacePath, requestedPath, state,
|
|
|
619
836
|
throw new Error('GIT_CHANGE_RESTORE_UNAVAILABLE');
|
|
620
837
|
const root = await realpath(workspacePath);
|
|
621
838
|
const file = resolve(root, path);
|
|
622
|
-
assertNotPrivate(file, options.privateRoots);
|
|
623
839
|
const metadata = await lstat(file);
|
|
624
840
|
if (!metadata.isFile() || metadata.isSymbolicLink())
|
|
625
841
|
throw new Error('GIT_CHANGE_RESTORE_UNAVAILABLE');
|