ysagc-agent 0.1.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/README.md +96 -0
- package/package.json +37 -0
- package/src/config.js +89 -0
- package/src/http-fs.js +171 -0
- package/src/index.js +378 -0
- package/src/logger.js +84 -0
- package/src/methods/fs.js +674 -0
- package/src/methods/git.js +391 -0
- package/src/methods/project.js +57 -0
- package/src/methods/scaffold.js +131 -0
- package/src/methods/skill.js +347 -0
- package/src/methods/system.js +82 -0
- package/src/methods/terminal.js +234 -0
- package/src/pairing.js +157 -0
- package/src/rpc.js +62 -0
- package/src/sandbox.js +178 -0
|
@@ -0,0 +1,674 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* fs.* RPC 方法(T-M1-03/04/06/07/12)
|
|
3
|
+
* - fs.list / fs.stat / fs.read / fs.write / fs.mkdir / fs.rename / fs.copy / fs.rm
|
|
4
|
+
* - fs.trash.*(回收站) / fs.search(ripgrep 优先,内置降级) / fs.addRoot 由 project.* 提供
|
|
5
|
+
* - 文件监听(chokidar)→ event.fs.changed
|
|
6
|
+
* - 写操作幂等(requestId 去重 5 分钟)
|
|
7
|
+
* 编码:jschardet + iconv-lite;原子写 tmp+rename;快照冲突检测;.git 内部拒绝
|
|
8
|
+
*/
|
|
9
|
+
'use strict';
|
|
10
|
+
|
|
11
|
+
const fs = require('fs');
|
|
12
|
+
const path = require('path');
|
|
13
|
+
const crypto = require('crypto');
|
|
14
|
+
const { RpcError } = require('../rpc');
|
|
15
|
+
const logger = require('../logger');
|
|
16
|
+
|
|
17
|
+
let jschardet = null;
|
|
18
|
+
let iconv = null;
|
|
19
|
+
let chokidar = null;
|
|
20
|
+
try { jschardet = require('jschardet'); } catch { /* optional */ }
|
|
21
|
+
try { iconv = require('iconv-lite'); } catch { /* optional */ }
|
|
22
|
+
try { chokidar = require('chokidar'); } catch { /* optional */ }
|
|
23
|
+
|
|
24
|
+
const MAX_EDIT_SIZE = 2 * 1024 * 1024; // >2MB 只读预览
|
|
25
|
+
const MAX_PREVIEW_SIZE = 20 * 1024 * 1024; // >20MB 仅下载
|
|
26
|
+
const TRASH_DIR_NAME = '.ws-trash';
|
|
27
|
+
const TRASH_RETENTION_MS = 7 * 24 * 3600 * 1000;
|
|
28
|
+
const TRASH_MAX_TOTAL = 2 * 1024 * 1024 * 1024; // 2GB
|
|
29
|
+
const HIDDEN_PATTERNS = [/^\.git$/, /^\.ws-trash$/, /^node_modules$/, /^dist$/, /^build$/, /^\.idea$/, /^\.vscode$/];
|
|
30
|
+
const SENSITIVE_PATTERNS = [/^\.env(\..*)?$/, /\.pem$/i, /\.key$/i, /^id_rsa/i, /^credentials/i, /secret/i, /token/i, /password/i, /\.p12$/i, /\.pfx$/i];
|
|
31
|
+
const SEARCH_MAX_RESULTS = 500;
|
|
32
|
+
const WRITE_IDEMPOTENT_MS = 5 * 60 * 1000;
|
|
33
|
+
|
|
34
|
+
function createFsMethods(ctxRef) {
|
|
35
|
+
const sandbox = () => ctxRef().sandbox;
|
|
36
|
+
const cfg = () => ctxRef().config;
|
|
37
|
+
/** @type {Map<string, {at:number, result:any}>} 写操作幂等缓存 */
|
|
38
|
+
const idempotentCache = new Map();
|
|
39
|
+
const watchers = new Map(); // root -> chokidar watcher
|
|
40
|
+
|
|
41
|
+
// ---------------- 工具 ----------------
|
|
42
|
+
|
|
43
|
+
function normSep(p) {
|
|
44
|
+
return String(p).replace(/\\/g, '/');
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function snapshotOf(absPath, size) {
|
|
48
|
+
try {
|
|
49
|
+
const stat = fs.statSync(absPath);
|
|
50
|
+
const mtimeMs = stat.mtimeMs;
|
|
51
|
+
const fileSize = size !== undefined ? size : stat.size;
|
|
52
|
+
let hash;
|
|
53
|
+
if (fileSize <= 1024 * 1024) {
|
|
54
|
+
hash = crypto.createHash('md5').update(fs.readFileSync(absPath)).digest('hex');
|
|
55
|
+
} else {
|
|
56
|
+
// >1MB 取头尾 64KB + size 组合
|
|
57
|
+
const fd = fs.openSync(absPath, 'r');
|
|
58
|
+
const head = Buffer.alloc(64 * 1024);
|
|
59
|
+
const tail = Buffer.alloc(64 * 1024);
|
|
60
|
+
fs.readSync(fd, head, 0, head.length, 0);
|
|
61
|
+
fs.readSync(fd, tail, 0, tail.length, fileSize - tail.length);
|
|
62
|
+
fs.closeSync(fd);
|
|
63
|
+
const h = crypto.createHash('md5');
|
|
64
|
+
h.update(head);
|
|
65
|
+
h.update(tail);
|
|
66
|
+
h.update(String(fileSize));
|
|
67
|
+
hash = h.digest('hex');
|
|
68
|
+
}
|
|
69
|
+
return { path: normSep(absPath), mtimeMs, size: fileSize, hash };
|
|
70
|
+
} catch {
|
|
71
|
+
return null;
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function isSensitive(name) {
|
|
76
|
+
return SENSITIVE_PATTERNS.some((re) => re.test(name));
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function isHiddenDir(name) {
|
|
80
|
+
return HIDDEN_PATTERNS.some((re) => re.test(name));
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function isStrictUtf8(buf) {
|
|
84
|
+
try {
|
|
85
|
+
new TextDecoder('utf-8', { fatal: true }).decode(buf);
|
|
86
|
+
return true;
|
|
87
|
+
} catch {
|
|
88
|
+
return false;
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function detectEncoding(buf) {
|
|
93
|
+
// UTF-16 BOM
|
|
94
|
+
if (buf.length >= 2) {
|
|
95
|
+
if (buf[0] === 0xff && buf[1] === 0xfe) return { encoding: 'utf-16le', confident: true };
|
|
96
|
+
if (buf[0] === 0xfe && buf[1] === 0xff) return { encoding: 'utf-16be', confident: true };
|
|
97
|
+
}
|
|
98
|
+
// UTF-8 BOM
|
|
99
|
+
if (buf.length >= 3 && buf[0] === 0xef && buf[1] === 0xbb && buf[2] === 0xbf) return { encoding: 'utf-8', confident: true };
|
|
100
|
+
// 严格 UTF-8 校验:合法即 UTF-8(中文 GBK 字节流一般不是合法 UTF-8)
|
|
101
|
+
if (isStrictUtf8(buf)) return { encoding: 'utf-8', confident: true };
|
|
102
|
+
// 非法 UTF-8 → jschardet 辅助判断,低置信/误判 utf-8 时回退 GBK(中文用户最常见场景)
|
|
103
|
+
if (jschardet) {
|
|
104
|
+
const det = jschardet.detect(buf.subarray(0, Math.min(buf.length, 64 * 1024)));
|
|
105
|
+
if (det && det.encoding && !/^(utf-8|ascii)$/i.test(det.encoding) && det.confidence >= 0.5) {
|
|
106
|
+
return { encoding: det.encoding, confident: det.confidence > 0.7 };
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
return { encoding: 'gbk', confident: false };
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function decodeBuffer(buf, encoding) {
|
|
113
|
+
const enc = String(encoding || 'utf-8').toLowerCase();
|
|
114
|
+
if (enc === 'gbk' || enc === 'gb2312' || enc === 'gb18030') {
|
|
115
|
+
if (iconv) return iconv.decode(buf, 'gbk');
|
|
116
|
+
return buf.toString('latin1');
|
|
117
|
+
}
|
|
118
|
+
if (enc === 'big5' || enc === 'shift_jis' || enc === 'euc-kr') {
|
|
119
|
+
if (iconv) return iconv.decode(buf, enc);
|
|
120
|
+
return buf.toString('latin1');
|
|
121
|
+
}
|
|
122
|
+
if (enc === 'utf-16le' || enc === 'utf-16be') {
|
|
123
|
+
if (iconv) return iconv.decode(buf, enc);
|
|
124
|
+
return buf.toString('utf8');
|
|
125
|
+
}
|
|
126
|
+
return buf.toString('utf8');
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function encodeContent(content, encoding, eol) {
|
|
130
|
+
const enc = String(encoding || 'utf-8').toLowerCase();
|
|
131
|
+
let text = String(content);
|
|
132
|
+
// EOL 保持:写入时按文件原 EOL 归一
|
|
133
|
+
if (eol === 'CRLF') {
|
|
134
|
+
text = text.replace(/\r\n/g, '\n').replace(/\n/g, '\r\n');
|
|
135
|
+
} else {
|
|
136
|
+
text = text.replace(/\r\n/g, '\n');
|
|
137
|
+
}
|
|
138
|
+
if (enc === 'gbk' || enc === 'gb2312' || enc === 'gb18030') {
|
|
139
|
+
return iconv ? iconv.encode(text, 'gbk') : Buffer.from(text, 'latin1');
|
|
140
|
+
}
|
|
141
|
+
if (enc === 'utf-16le') return iconv ? iconv.encode(text, 'utf-16le') : Buffer.from(text, 'utf8');
|
|
142
|
+
if (enc === 'big5' || enc === 'shift_jis' || enc === 'euc-kr') {
|
|
143
|
+
return iconv ? iconv.encode(text, enc) : Buffer.from(text, 'utf8');
|
|
144
|
+
}
|
|
145
|
+
return Buffer.from(text, 'utf8');
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function detectEol(text) {
|
|
149
|
+
const crlf = (text.match(/\r\n/g) || []).length;
|
|
150
|
+
const lf = (text.match(/(?<!\r)\n/g) || []).length;
|
|
151
|
+
return crlf > lf ? 'CRLF' : 'LF';
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
function isBinary(buf) {
|
|
155
|
+
const head = buf.subarray(0, Math.min(buf.length, 8192));
|
|
156
|
+
for (let i = 0; i < head.length; i++) {
|
|
157
|
+
if (head[i] === 0) return true;
|
|
158
|
+
}
|
|
159
|
+
return false;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
function atomicWrite(absPath, buf) {
|
|
163
|
+
const tmp = path.join(path.dirname(absPath), `.${path.basename(absPath)}.tmp-${crypto.randomBytes(4).toString('hex')}`);
|
|
164
|
+
fs.writeFileSync(tmp, buf);
|
|
165
|
+
fs.renameSync(tmp, absPath); // 同目录 rename 原子替换
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
// 写操作幂等包装
|
|
169
|
+
async function withIdempotent(requestId, fn) {
|
|
170
|
+
if (requestId) {
|
|
171
|
+
const hit = idempotentCache.get(requestId);
|
|
172
|
+
if (hit && Date.now() - hit.at < WRITE_IDEMPOTENT_MS) return hit.result;
|
|
173
|
+
}
|
|
174
|
+
const result = await fn();
|
|
175
|
+
if (requestId) {
|
|
176
|
+
idempotentCache.set(requestId, { at: Date.now(), result });
|
|
177
|
+
if (idempotentCache.size > 500) {
|
|
178
|
+
const oldest = [...idempotentCache.entries()].sort((a, b) => a[1].at - b[1].at)[0];
|
|
179
|
+
if (oldest) idempotentCache.delete(oldest[0]);
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
return result;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
// ---------------- 回收站 ----------------
|
|
186
|
+
|
|
187
|
+
function trashRootFor(root) {
|
|
188
|
+
return path.join(root, TRASH_DIR_NAME);
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
function ensureTrashDir(root) {
|
|
192
|
+
const dir = trashRootFor(root);
|
|
193
|
+
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
|
|
194
|
+
return dir;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
function trashManifestPath(root) {
|
|
198
|
+
return path.join(trashRootFor(root), 'manifest.json');
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
function readManifest(root) {
|
|
202
|
+
try {
|
|
203
|
+
return JSON.parse(fs.readFileSync(trashManifestPath(root), 'utf8'));
|
|
204
|
+
} catch {
|
|
205
|
+
return { items: [] };
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
function writeManifest(root, manifest) {
|
|
210
|
+
try {
|
|
211
|
+
ensureTrashDir(root);
|
|
212
|
+
fs.writeFileSync(trashManifestPath(root), JSON.stringify(manifest, null, 2));
|
|
213
|
+
} catch {
|
|
214
|
+
/* ignore */
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
function moveToTrash(root, absPath) {
|
|
219
|
+
const manifest = readManifest(root);
|
|
220
|
+
const id = `${Date.now()}-${crypto.randomBytes(3).toString('hex')}`;
|
|
221
|
+
const trashDir = ensureTrashDir(root);
|
|
222
|
+
const dest = path.join(trashDir, id);
|
|
223
|
+
fs.renameSync(absPath, dest);
|
|
224
|
+
const item = {
|
|
225
|
+
id,
|
|
226
|
+
originalPath: normSep(absPath),
|
|
227
|
+
name: path.basename(absPath),
|
|
228
|
+
isDir: fs.statSync(dest).isDirectory(),
|
|
229
|
+
size: fs.statSync(dest).size,
|
|
230
|
+
deletedAt: Date.now(),
|
|
231
|
+
};
|
|
232
|
+
manifest.items.unshift(item);
|
|
233
|
+
writeManifest(root, manifest);
|
|
234
|
+
// 清理超期(7 天)与超总量(2GB)
|
|
235
|
+
cleanupTrash(root, manifest);
|
|
236
|
+
return item;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
function cleanupTrash(root, manifest) {
|
|
240
|
+
const now = Date.now();
|
|
241
|
+
let total = 0;
|
|
242
|
+
const keep = [];
|
|
243
|
+
for (const item of manifest.items) {
|
|
244
|
+
const p = path.join(trashRootFor(root), item.id);
|
|
245
|
+
let exists = false;
|
|
246
|
+
try {
|
|
247
|
+
exists = fs.existsSync(p);
|
|
248
|
+
} catch {
|
|
249
|
+
exists = false;
|
|
250
|
+
}
|
|
251
|
+
if (!exists || now - item.deletedAt > TRASH_RETENTION_MS) {
|
|
252
|
+
try { if (exists) fs.rmSync(p, { recursive: true, force: true }); } catch { /* ignore */ }
|
|
253
|
+
continue;
|
|
254
|
+
}
|
|
255
|
+
if (total + item.size > TRASH_MAX_TOTAL) {
|
|
256
|
+
try { fs.rmSync(p, { recursive: true, force: true }); } catch { /* ignore */ }
|
|
257
|
+
continue;
|
|
258
|
+
}
|
|
259
|
+
total += item.size;
|
|
260
|
+
keep.push(item);
|
|
261
|
+
}
|
|
262
|
+
if (keep.length !== manifest.items.length) {
|
|
263
|
+
manifest.items = keep;
|
|
264
|
+
writeManifest(root, manifest);
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
// ---------------- 文件监听(T-M1-07) ----------------
|
|
269
|
+
|
|
270
|
+
function ensureWatcher(root) {
|
|
271
|
+
if (!chokidar) return;
|
|
272
|
+
if (watchers.has(root)) return watchers.get(root);
|
|
273
|
+
let queue = [];
|
|
274
|
+
let flushTimer = null;
|
|
275
|
+
const emit = () => {
|
|
276
|
+
if (!queue.length) return;
|
|
277
|
+
const batch = queue.slice();
|
|
278
|
+
queue = [];
|
|
279
|
+
try {
|
|
280
|
+
const br = ctxRef().broadcast;
|
|
281
|
+
if (br) br({ jsonrpc: '2.0', method: 'event.fs.changed', params: { root: normSep(root), events: batch } });
|
|
282
|
+
} catch {
|
|
283
|
+
/* ignore */
|
|
284
|
+
}
|
|
285
|
+
};
|
|
286
|
+
const w = chokidar.watch(root, {
|
|
287
|
+
ignored: (p) => {
|
|
288
|
+
const rel = path.relative(root, p);
|
|
289
|
+
return rel.split(path.sep).some((seg) => isHiddenDir(seg)) || rel.includes('/.ws-trash/') || rel.startsWith(TRASH_DIR_NAME);
|
|
290
|
+
},
|
|
291
|
+
ignoreInitial: true,
|
|
292
|
+
awaitWriteFinish: { stabilityThreshold: 300, pollInterval: 100 },
|
|
293
|
+
});
|
|
294
|
+
w.on('all', (event, p) => {
|
|
295
|
+
if (event === 'raw') return;
|
|
296
|
+
const rel = normSep(path.relative(root, p));
|
|
297
|
+
if (!rel) return;
|
|
298
|
+
queue.push({ type: event, path: rel });
|
|
299
|
+
if (flushTimer) clearTimeout(flushTimer);
|
|
300
|
+
flushTimer = setTimeout(() => emit(), 300);
|
|
301
|
+
});
|
|
302
|
+
w.on('error', (e) => logger.warn(`[fs] 监听错误: ${e.message}`));
|
|
303
|
+
watchers.set(root, w);
|
|
304
|
+
return w;
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
// ---------------- 内置搜索(ripgrep 缺失降级) ----------------
|
|
308
|
+
|
|
309
|
+
function builtinSearch(root, query, opts) {
|
|
310
|
+
const results = [];
|
|
311
|
+
const regex = opts.regex ? safeRegex(query, opts.caseSensitive) : null;
|
|
312
|
+
const walk = (dir, rel) => {
|
|
313
|
+
if (results.length >= SEARCH_MAX_RESULTS) return;
|
|
314
|
+
let entries;
|
|
315
|
+
try {
|
|
316
|
+
entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
317
|
+
} catch {
|
|
318
|
+
return;
|
|
319
|
+
}
|
|
320
|
+
for (const e of entries) {
|
|
321
|
+
if (results.length >= SEARCH_MAX_RESULTS) return;
|
|
322
|
+
const abs = path.join(dir, e.name);
|
|
323
|
+
const r = rel ? `${rel}/${e.name}` : e.name;
|
|
324
|
+
if (e.isDirectory()) {
|
|
325
|
+
if (isHiddenDir(e.name)) continue;
|
|
326
|
+
walk(abs, r);
|
|
327
|
+
} else if (e.isFile()) {
|
|
328
|
+
const size = fs.statSync(abs).size;
|
|
329
|
+
if (size > MAX_EDIT_SIZE) continue; // 超大文件不搜
|
|
330
|
+
let content;
|
|
331
|
+
try {
|
|
332
|
+
const buf = fs.readFileSync(abs);
|
|
333
|
+
content = decodeBuffer(buf, detectEncoding(buf).encoding);
|
|
334
|
+
} catch {
|
|
335
|
+
continue;
|
|
336
|
+
}
|
|
337
|
+
const lines = content.split(/\r?\n/);
|
|
338
|
+
for (let i = 0; i < lines.length; i++) {
|
|
339
|
+
let hit = false;
|
|
340
|
+
if (regex) hit = regex.test(lines[i]);
|
|
341
|
+
else {
|
|
342
|
+
const hay = opts.caseSensitive ? lines[i] : lines[i].toLowerCase();
|
|
343
|
+
const needle = opts.caseSensitive ? query : query.toLowerCase();
|
|
344
|
+
hit = hay.includes(needle);
|
|
345
|
+
}
|
|
346
|
+
if (hit) {
|
|
347
|
+
results.push({ path: normSep(r), line: i + 1, text: lines[i].slice(0, 500) });
|
|
348
|
+
if (results.length >= SEARCH_MAX_RESULTS) break;
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
};
|
|
354
|
+
walk(root, '');
|
|
355
|
+
return results;
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
function safeRegex(pattern, caseSensitive) {
|
|
359
|
+
try {
|
|
360
|
+
return new RegExp(pattern, caseSensitive ? '' : 'i');
|
|
361
|
+
} catch {
|
|
362
|
+
return null;
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
function rgSearch(root, query, opts) {
|
|
367
|
+
return new Promise((resolve) => {
|
|
368
|
+
const args = ['--no-heading', '--line-number', '--max-count', String(SEARCH_MAX_RESULTS), '--max-columns', '500'];
|
|
369
|
+
if (!opts.caseSensitive) args.push('-i');
|
|
370
|
+
if (opts.regex) args.push('-e', query);
|
|
371
|
+
else args.push('-F', query);
|
|
372
|
+
args.push(root);
|
|
373
|
+
let child;
|
|
374
|
+
try {
|
|
375
|
+
const { execFile } = require('child_process');
|
|
376
|
+
child = execFile('rg', args, { timeout: 10000, maxBuffer: 10 * 1024 * 1024, windowsHide: true, stdio: ['ignore', 'pipe', 'ignore'] }, (err, stdout) => {
|
|
377
|
+
if (err && !stdout) return resolve(null);
|
|
378
|
+
const results = [];
|
|
379
|
+
for (const line of String(stdout).split(/\r?\n/)) {
|
|
380
|
+
if (!line) continue;
|
|
381
|
+
const m = line.match(/^(.+?):(\d+):(.*)$/);
|
|
382
|
+
if (m) results.push({ path: normSep(m[1]), line: Number(m[2]), text: m[3].slice(0, 500) });
|
|
383
|
+
if (results.length >= SEARCH_MAX_RESULTS) break;
|
|
384
|
+
}
|
|
385
|
+
resolve(results);
|
|
386
|
+
});
|
|
387
|
+
child.on('error', () => resolve(null));
|
|
388
|
+
} catch {
|
|
389
|
+
// spawn 被环境禁止/异常(如沙箱、rg 缺失)→ 降级内置搜索
|
|
390
|
+
resolve(null);
|
|
391
|
+
}
|
|
392
|
+
});
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
// ---------------- RPC 注册 ----------------
|
|
396
|
+
|
|
397
|
+
function register(reg) {
|
|
398
|
+
// ---- requestId 结果查询(T-M1-12)----
|
|
399
|
+
// 断线重连后,前端对"结果未知"的写操作查询是否已执行,防重复写入/重复扣费
|
|
400
|
+
reg('fs.requestStatus', async (params = {}) => {
|
|
401
|
+
const requestId = String(params.requestId || '');
|
|
402
|
+
if (!requestId) throw new RpcError('E_VALIDATION', '缺少 requestId');
|
|
403
|
+
const hit = idempotentCache.get(requestId);
|
|
404
|
+
if (!hit) return { known: false };
|
|
405
|
+
return { known: true, result: hit.result, at: hit.at };
|
|
406
|
+
});
|
|
407
|
+
|
|
408
|
+
// ---- 列表 ----
|
|
409
|
+
reg('fs.list', async (params = {}, ctx) => {
|
|
410
|
+
const r = sandbox().resolve(params.path, params.base);
|
|
411
|
+
const depth = Math.max(1, Math.min(Number(params.depth) || 1, 5));
|
|
412
|
+
const showHidden = !!params.showHidden;
|
|
413
|
+
const entries = [];
|
|
414
|
+
let dirs;
|
|
415
|
+
try {
|
|
416
|
+
dirs = fs.readdirSync(r.abs, { withFileTypes: true });
|
|
417
|
+
} catch (e) {
|
|
418
|
+
throw new RpcError('E_PATH_NOT_FOUND', `目录不存在或无法读取: ${e.message}`);
|
|
419
|
+
}
|
|
420
|
+
dirs.sort((a, b) => (a.isDirectory() === b.isDirectory() ? a.name.localeCompare(b.name) : a.isDirectory() ? -1 : 1));
|
|
421
|
+
for (const e of dirs) {
|
|
422
|
+
const name = e.name;
|
|
423
|
+
if (e.isDirectory() && isHiddenDir(name) && !showHidden) continue;
|
|
424
|
+
const abs = path.join(r.abs, name);
|
|
425
|
+
const entry = {
|
|
426
|
+
name,
|
|
427
|
+
relPath: normSep(path.join(r.rel, name)),
|
|
428
|
+
type: e.isDirectory() ? 'dir' : 'file',
|
|
429
|
+
hidden: isHiddenDir(name),
|
|
430
|
+
sensitive: !e.isDirectory() && isSensitive(name),
|
|
431
|
+
};
|
|
432
|
+
try {
|
|
433
|
+
const st = fs.statSync(abs);
|
|
434
|
+
entry.size = st.size;
|
|
435
|
+
entry.mtimeMs = st.mtimeMs;
|
|
436
|
+
if (e.isDirectory() && depth > 1) {
|
|
437
|
+
entry.children = listDir(abs, depth - 1, showHidden, path.join(r.rel, name));
|
|
438
|
+
}
|
|
439
|
+
} catch {
|
|
440
|
+
entry.size = 0;
|
|
441
|
+
entry.mtimeMs = 0;
|
|
442
|
+
}
|
|
443
|
+
entries.push(entry);
|
|
444
|
+
}
|
|
445
|
+
return { root: r.root, rel: r.rel, entries };
|
|
446
|
+
});
|
|
447
|
+
|
|
448
|
+
// ---- stat ----
|
|
449
|
+
reg('fs.stat', async (params = {}) => {
|
|
450
|
+
const r = sandbox().resolve(params.path, params.base);
|
|
451
|
+
const st = fs.statSync(r.abs);
|
|
452
|
+
return {
|
|
453
|
+
name: path.basename(r.abs),
|
|
454
|
+
relPath: normSep(r.rel),
|
|
455
|
+
type: st.isDirectory() ? 'dir' : 'file',
|
|
456
|
+
size: st.size,
|
|
457
|
+
mtimeMs: st.mtimeMs,
|
|
458
|
+
snapshot: st.isFile() ? snapshotOf(r.abs, st.size) : null,
|
|
459
|
+
sensitive: !st.isDirectory() && isSensitive(path.basename(r.abs)),
|
|
460
|
+
};
|
|
461
|
+
});
|
|
462
|
+
|
|
463
|
+
// ---- 读 ----
|
|
464
|
+
reg('fs.read', async (params = {}) => {
|
|
465
|
+
const r = sandbox().resolve(params.path, params.base);
|
|
466
|
+
const st = fs.statSync(r.abs);
|
|
467
|
+
if (st.isDirectory()) throw new RpcError('E_PATH_NOT_DIR', '不能以读文件方式打开文件夹');
|
|
468
|
+
if (st.size > MAX_PREVIEW_SIZE) {
|
|
469
|
+
return { content: '', size: st.size, tooLarge: true, reason: 'EXCEED_20MB', message: '文件超过 20MB,仅支持下载' };
|
|
470
|
+
}
|
|
471
|
+
const buf = fs.readFileSync(r.abs);
|
|
472
|
+
if (isBinary(buf)) {
|
|
473
|
+
throw new RpcError('E_BINARY_FILE', '二进制文件无法编辑,仅支持下载');
|
|
474
|
+
}
|
|
475
|
+
const det = detectEncoding(buf);
|
|
476
|
+
const content = decodeBuffer(buf, det.encoding);
|
|
477
|
+
const snapshot = snapshotOf(r.abs, st.size);
|
|
478
|
+
if (st.size > MAX_EDIT_SIZE) {
|
|
479
|
+
// >2MB:只读预览(前 512KB)
|
|
480
|
+
return {
|
|
481
|
+
content: content.slice(0, 512 * 1024),
|
|
482
|
+
encoding: det.encoding,
|
|
483
|
+
eol: detectEol(content),
|
|
484
|
+
size: st.size,
|
|
485
|
+
truncated: true,
|
|
486
|
+
readOnly: true,
|
|
487
|
+
reason: 'EXCEED_2MB',
|
|
488
|
+
snapshot,
|
|
489
|
+
};
|
|
490
|
+
}
|
|
491
|
+
return { content, encoding: det.encoding, eol: detectEol(content), size: st.size, truncated: false, snapshot };
|
|
492
|
+
});
|
|
493
|
+
|
|
494
|
+
// ---- 写(带快照冲突检测 + 原子写 + requestId 幂等)----
|
|
495
|
+
reg('fs.write', async (params = {}, ctx) => {
|
|
496
|
+
const requestId = params.requestId;
|
|
497
|
+
return withIdempotent(requestId, async () => {
|
|
498
|
+
const r = sandbox().resolve(params.path, params.base);
|
|
499
|
+
const content = String(params.content ?? '');
|
|
500
|
+
// 冲突检测:携带快照时比对
|
|
501
|
+
if (params.snapshot) {
|
|
502
|
+
const cur = snapshotOf(r.abs, undefined);
|
|
503
|
+
const snap = params.snapshot;
|
|
504
|
+
const mismatch = !cur || cur.mtimeMs !== snap.mtimeMs || cur.size !== snap.size || cur.hash !== snap.hash;
|
|
505
|
+
if (mismatch) {
|
|
506
|
+
const err = new RpcError('E_FILE_CHANGED', '文件已在磁盘上被外部修改');
|
|
507
|
+
err.data = { remote: cur };
|
|
508
|
+
throw err;
|
|
509
|
+
}
|
|
510
|
+
}
|
|
511
|
+
const encoding = params.encoding || 'utf-8';
|
|
512
|
+
const eol = params.eol || 'LF';
|
|
513
|
+
const buf = encodeContent(content, encoding, eol);
|
|
514
|
+
try {
|
|
515
|
+
atomicWrite(r.abs, buf);
|
|
516
|
+
} catch (e) {
|
|
517
|
+
if ((e && e.code === 'EBUSY') || (e && e.code === 'EPERM')) throw new RpcError('E_FILE_LOCKED', `文件被占用: ${e.message}`);
|
|
518
|
+
if (e && e.code === 'ENOSPC') throw new RpcError('E_DISK_FULL', '磁盘空间不足');
|
|
519
|
+
throw e;
|
|
520
|
+
}
|
|
521
|
+
const snap = snapshotOf(r.abs, buf.length);
|
|
522
|
+
return { ok: true, snapshot: snap, size: buf.length };
|
|
523
|
+
});
|
|
524
|
+
});
|
|
525
|
+
|
|
526
|
+
// ---- 新建目录/文件 ----
|
|
527
|
+
reg('fs.mkdir', async (params = {}) => {
|
|
528
|
+
const r = sandbox().resolve(params.path, params.base);
|
|
529
|
+
if (fs.existsSync(r.abs)) throw new RpcError('E_PATH_EXISTS', '同名文件/文件夹已存在');
|
|
530
|
+
fs.mkdirSync(r.abs, { recursive: true });
|
|
531
|
+
return { ok: true, relPath: normSep(r.rel) };
|
|
532
|
+
});
|
|
533
|
+
|
|
534
|
+
reg('fs.createFile', async (params = {}) => {
|
|
535
|
+
const r = sandbox().resolve(params.path, params.base);
|
|
536
|
+
if (fs.existsSync(r.abs)) throw new RpcError('E_PATH_EXISTS', '同名文件已存在');
|
|
537
|
+
fs.writeFileSync(r.abs, '');
|
|
538
|
+
return { ok: true, relPath: normSep(r.rel) };
|
|
539
|
+
});
|
|
540
|
+
|
|
541
|
+
// ---- 重命名/移动 ----
|
|
542
|
+
reg('fs.rename', async (params = {}) => {
|
|
543
|
+
const requestId = params.requestId;
|
|
544
|
+
return withIdempotent(requestId, async () => {
|
|
545
|
+
const from = sandbox().resolve(params.path, params.base);
|
|
546
|
+
const to = sandbox().resolve(params.newPath, params.base);
|
|
547
|
+
if (from.root !== to.root) throw new RpcError('E_PATH_ESCAPE', '不能移动到项目目录之外');
|
|
548
|
+
if (fs.existsSync(to.abs)) throw new RpcError('E_PATH_EXISTS', '目标位置已存在同名文件/文件夹');
|
|
549
|
+
fs.renameSync(from.abs, to.abs);
|
|
550
|
+
return { ok: true };
|
|
551
|
+
});
|
|
552
|
+
});
|
|
553
|
+
|
|
554
|
+
// ---- 复制 ----
|
|
555
|
+
reg('fs.copy', async (params = {}) => {
|
|
556
|
+
const from = sandbox().resolve(params.path, params.base);
|
|
557
|
+
const to = sandbox().resolve(params.destPath, params.base);
|
|
558
|
+
if (from.root !== to.root) throw new RpcError('E_PATH_ESCAPE', '不能复制到项目目录之外');
|
|
559
|
+
if (fs.existsSync(to.abs)) throw new RpcError('E_PATH_EXISTS', '目标位置已存在同名文件/文件夹');
|
|
560
|
+
fs.cpSync(from.abs, to.abs, { recursive: true });
|
|
561
|
+
return { ok: true };
|
|
562
|
+
});
|
|
563
|
+
|
|
564
|
+
// ---- 删除(进回收站)----
|
|
565
|
+
reg('fs.rm', async (params = {}, ctx) => {
|
|
566
|
+
const requestId = params.requestId;
|
|
567
|
+
return withIdempotent(requestId, async () => {
|
|
568
|
+
const r = sandbox().resolve(params.path, params.base);
|
|
569
|
+
if (r.rel === '' || r.rel === '.') throw new RpcError('E_VALIDATION', '不能删除项目根目录');
|
|
570
|
+
if (cfg().trashEnabled) {
|
|
571
|
+
const item = moveToTrash(r.root, r.abs);
|
|
572
|
+
return { ok: true, trashed: true, trashId: item.id };
|
|
573
|
+
}
|
|
574
|
+
fs.rmSync(r.abs, { recursive: true, force: true });
|
|
575
|
+
return { ok: true, trashed: false };
|
|
576
|
+
});
|
|
577
|
+
});
|
|
578
|
+
|
|
579
|
+
// ---- 回收站 ----
|
|
580
|
+
reg('fs.trash.list', async (params = {}) => {
|
|
581
|
+
const r = sandbox().resolve(params.path, params.base);
|
|
582
|
+
return { items: readManifest(r.root).items };
|
|
583
|
+
});
|
|
584
|
+
|
|
585
|
+
reg('fs.trash.restore', async (params = {}) => {
|
|
586
|
+
const r = sandbox().resolve(params.path, params.base);
|
|
587
|
+
const manifest = readManifest(r.root);
|
|
588
|
+
const item = manifest.items.find((i) => i.id === params.trashId);
|
|
589
|
+
if (!item) throw new RpcError('E_PATH_NOT_FOUND', '回收站条目不存在');
|
|
590
|
+
const src = path.join(trashRootFor(r.root), item.id);
|
|
591
|
+
const originalAbs = item.originalPath.replace(/\//g, path.sep);
|
|
592
|
+
// 恢复目标必须在项目内
|
|
593
|
+
const restored = sandbox().resolve(originalAbs, r.root);
|
|
594
|
+
if (fs.existsSync(restored.abs)) throw new RpcError('E_PATH_EXISTS', '原位置已存在同名文件,请先处理');
|
|
595
|
+
fs.renameSync(src, restored.abs);
|
|
596
|
+
manifest.items = manifest.items.filter((i) => i.id !== item.id);
|
|
597
|
+
writeManifest(r.root, manifest);
|
|
598
|
+
return { ok: true };
|
|
599
|
+
});
|
|
600
|
+
|
|
601
|
+
reg('fs.trash.empty', async (params = {}) => {
|
|
602
|
+
const r = sandbox().resolve(params.path, params.base);
|
|
603
|
+
const manifest = readManifest(r.root);
|
|
604
|
+
for (const item of manifest.items) {
|
|
605
|
+
try {
|
|
606
|
+
fs.rmSync(path.join(trashRootFor(r.root), item.id), { recursive: true, force: true });
|
|
607
|
+
} catch {
|
|
608
|
+
/* ignore */
|
|
609
|
+
}
|
|
610
|
+
}
|
|
611
|
+
manifest.items = [];
|
|
612
|
+
writeManifest(r.root, manifest);
|
|
613
|
+
return { ok: true };
|
|
614
|
+
});
|
|
615
|
+
|
|
616
|
+
// ---- 搜索 ----
|
|
617
|
+
reg('fs.search', async (params = {}) => {
|
|
618
|
+
const r = sandbox().resolve(params.path, params.base);
|
|
619
|
+
const query = String(params.query || '').trim();
|
|
620
|
+
if (!query) throw new RpcError('E_VALIDATION', '搜索关键词为空');
|
|
621
|
+
const opts = {
|
|
622
|
+
caseSensitive: !!params.caseSensitive,
|
|
623
|
+
regex: !!params.regex,
|
|
624
|
+
};
|
|
625
|
+
let results = await rgSearch(r.abs, query, opts);
|
|
626
|
+
if (results === null) results = builtinSearch(r.abs, query, opts);
|
|
627
|
+
const truncated = results.length >= SEARCH_MAX_RESULTS;
|
|
628
|
+
return { results: results.slice(0, SEARCH_MAX_RESULTS), truncated };
|
|
629
|
+
});
|
|
630
|
+
|
|
631
|
+
// ---- 监听 ----
|
|
632
|
+
reg('fs.watch', async (params = {}) => {
|
|
633
|
+
const r = sandbox().resolve(params.path, params.base);
|
|
634
|
+
ensureWatcher(r.root);
|
|
635
|
+
return { ok: true, root: normSep(r.root) };
|
|
636
|
+
});
|
|
637
|
+
}
|
|
638
|
+
|
|
639
|
+
function listDir(absDir, depth, showHidden, relBase) {
|
|
640
|
+
const out = [];
|
|
641
|
+
let dirs;
|
|
642
|
+
try {
|
|
643
|
+
dirs = fs.readdirSync(absDir, { withFileTypes: true });
|
|
644
|
+
} catch {
|
|
645
|
+
return out;
|
|
646
|
+
}
|
|
647
|
+
for (const e of dirs) {
|
|
648
|
+
if (e.isDirectory() && isHiddenDir(e.name) && !showHidden) continue;
|
|
649
|
+
const abs = path.join(absDir, e.name);
|
|
650
|
+
const entry = {
|
|
651
|
+
name: e.name,
|
|
652
|
+
relPath: normSep(path.join(relBase, e.name)),
|
|
653
|
+
type: e.isDirectory() ? 'dir' : 'file',
|
|
654
|
+
hidden: e.isDirectory() && isHiddenDir(e.name),
|
|
655
|
+
sensitive: !e.isDirectory() && isSensitive(e.name),
|
|
656
|
+
};
|
|
657
|
+
try {
|
|
658
|
+
const st = fs.statSync(abs);
|
|
659
|
+
entry.size = st.size;
|
|
660
|
+
entry.mtimeMs = st.mtimeMs;
|
|
661
|
+
if (e.isDirectory() && depth > 1) entry.children = listDir(abs, depth - 1, showHidden, path.join(relBase, e.name));
|
|
662
|
+
} catch {
|
|
663
|
+
entry.size = 0;
|
|
664
|
+
entry.mtimeMs = 0;
|
|
665
|
+
}
|
|
666
|
+
out.push(entry);
|
|
667
|
+
}
|
|
668
|
+
return out;
|
|
669
|
+
}
|
|
670
|
+
|
|
671
|
+
return { register };
|
|
672
|
+
}
|
|
673
|
+
|
|
674
|
+
module.exports = { createFsMethods };
|