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.
@@ -0,0 +1,391 @@
1
+ /**
2
+ * git.* RPC 方法(T-M2-01..09)
3
+ * - git.check / git.init / git.status / git.diff
4
+ * - git.stage / git.unstage / git.commit / git.push / git.pull / git.fetch
5
+ * - git.branches / git.createBranch / git.checkout / git.deleteBranch / git.merge / git.mergeAbort
6
+ * - 安全:spawn 参数数组(绝不拼字符串命令)、GIT_TERMINAL_PROMPT=0 防凭证挂起、
7
+ * 单设备单 git 写操作(E_GIT_BUSY)、输出截断 200KB、敏感字段(URL 凭证)脱敏
8
+ * - 凭证:不落后端、不落盘(系统钥匙串/credential manager/ssh-agent 由 git 自身处理)
9
+ */
10
+ 'use strict';
11
+
12
+ const path = require('path');
13
+ const { execFile } = require('child_process');
14
+ const { RpcError } = require('../rpc');
15
+ const logger = require('../logger');
16
+
17
+ const GIT_TIMEOUT = 120000;
18
+ const MAX_OUTPUT = 200 * 1024;
19
+ const GIT_ENV = { ...process.env, GIT_TERMINAL_PROMPT: '0', GIT_ASKPASS: 'echo' };
20
+
21
+ /** 运行 git 命令(参数数组;cwd 必须在已授权项目内) */
22
+ function runGit(args, opts = {}) {
23
+ return new Promise((resolve, reject) => {
24
+ execFile(
25
+ 'git',
26
+ args,
27
+ {
28
+ cwd: opts.cwd,
29
+ timeout: opts.timeout || GIT_TIMEOUT,
30
+ maxBuffer: MAX_OUTPUT,
31
+ windowsHide: true,
32
+ env: GIT_ENV,
33
+ encoding: 'utf8',
34
+ },
35
+ (err, stdout, stderr) => {
36
+ const out = String(stdout || '');
37
+ const errOut = String(stderr || '');
38
+ if (err) {
39
+ const code = (err.code || '').toString();
40
+ // 凭证/认证类错误 → 引导
41
+ if (/authentication|could not read|credential|fatal: unable to access/i.test(errOut)) {
42
+ return reject(new RpcError('E_GIT_AUTH', `Git 认证失败(凭证):${errOut.trim().split('\n').pop()}`));
43
+ }
44
+ if (/not a git repository/i.test(errOut)) {
45
+ return reject(new RpcError('E_GIT_NOT_REPO', '不是 Git 仓库,请先 git init'));
46
+ }
47
+ if (/conflict/i.test(errOut)) {
48
+ return reject(new RpcError('E_GIT_CONFLICT', `存在冲突:${errOut.trim().split('\n').pop()}`));
49
+ }
50
+ return reject(new RpcError('E_GIT_FAILED', `${errOut.trim().split('\n').pop() || err.message}`));
51
+ }
52
+ resolve({ stdout: out, stderr: errOut });
53
+ }
54
+ );
55
+ });
56
+ }
57
+
58
+ /** 解析 porcelain v2 状态 */
59
+ function parseStatusV2(text) {
60
+ const branch = { current: '', upstream: '', ahead: 0, behind: 0 };
61
+ const changes = { staged: [], unstaged: [], untracked: [], conflicts: [] };
62
+ for (const line of String(text).split(/\r?\n/)) {
63
+ if (!line) continue;
64
+ const parts = line.split(' ');
65
+ const h = parts[0];
66
+ if (h === '#') {
67
+ if (parts[1] === 'branch.head') branch.current = parts[2] === '(detached)' ? '(detached)' : parts[2];
68
+ else if (parts[1] === 'branch.upstream') branch.upstream = parts[2];
69
+ else if (parts[1] === 'branch.ab') {
70
+ branch.ahead = Number(parts[2].replace('+', ''));
71
+ branch.behind = Number(parts[3].replace('-', ''));
72
+ }
73
+ continue;
74
+ }
75
+ if (h === '1' || h === '2') {
76
+ const xy = parts[1];
77
+ const path2 = h === '2' ? parts[8] : parts[7];
78
+ const entry = { path: path2, xy };
79
+ if (xy[0] !== '.' && xy[0] !== '?') changes.staged.push(entry);
80
+ if (xy[1] !== '.') changes.unstaged.push(entry);
81
+ if (xy === 'UU' || xy[0] === 'u' || xy[1] === 'u') changes.conflicts.push(entry);
82
+ } else if (h === 'u') {
83
+ // 冲突条目:u <xy> <sub> <m1> <m2> <m3> <mW> <hH> <hI> <path>
84
+ changes.conflicts.push({ path: parts[parts.length - 1], xy: 'UU' });
85
+ } else if (h === '?') {
86
+ changes.untracked.push({ path: parts[1], xy: '??' });
87
+ }
88
+ }
89
+ // 去重(staged+unstaged 可能同文件)
90
+ changes.conflicts = changes.conflicts.filter((c, i, arr) => arr.findIndex((x) => x.path === c.path) === i);
91
+ return { branch, changes };
92
+ }
93
+
94
+ /** 解析 numstat(增删行) */
95
+ function parseNumstat(text, staged = false) {
96
+ const map = {};
97
+ for (const line of String(text).split(/\r?\n/)) {
98
+ if (!line) continue;
99
+ const m = line.match(/^(\d+|-)\t(\d+|-)\t(.+)$/);
100
+ if (m) map[m[3]] = { added: m[1] === '-' ? 0 : Number(m[1]), deleted: m[2] === '-' ? 0 : Number(m[2]), staged };
101
+ }
102
+ return map;
103
+ }
104
+
105
+ function createGitMethods(ctxRef) {
106
+ const sandbox = () => ctxRef().sandbox;
107
+ /** 单设备单 git 写操作锁(T-M2-09) */
108
+ let writeLock = null;
109
+
110
+ async function withWriteLock(fn) {
111
+ if (writeLock) throw new RpcError('E_GIT_BUSY', '已有 Git 写操作进行中,请稍后再试');
112
+ writeLock = true;
113
+ try {
114
+ return await fn();
115
+ } finally {
116
+ writeLock = false;
117
+ }
118
+ }
119
+
120
+ function resolveRepo(params) {
121
+ const r = sandbox().resolve(params.path || '', params.base);
122
+ if (!r.rel || r.rel === '.' || r.rel === '') {
123
+ return r.abs; // 项目根
124
+ }
125
+ return r.abs;
126
+ }
127
+
128
+ /** 敏感字段脱敏:URL 中的 user:pass@ → ***@ */
129
+ function sanitize(text) {
130
+ return String(text).replace(/(https?:\/\/)[^/@\s]+@/g, '$1***@');
131
+ }
132
+
133
+ function register(reg) {
134
+ // ---- 检查:git 是否安装 + 是否仓库(T-M2-01)----
135
+ reg('git.check', async (params = {}) => {
136
+ const abs = resolveRepo(params);
137
+ let version = '';
138
+ let installed = true;
139
+ try {
140
+ const r = await runGit(['--version'], { cwd: abs, timeout: 15000 });
141
+ const m = r.stdout.match(/git version\s+([^\s]+)/i);
142
+ version = m ? m[1] : r.stdout.trim();
143
+ } catch {
144
+ installed = false;
145
+ }
146
+ let isRepo = false;
147
+ if (installed) {
148
+ try {
149
+ await runGit(['rev-parse', '--is-inside-work-tree'], { cwd: abs, timeout: 15000 });
150
+ isRepo = true;
151
+ } catch {
152
+ isRepo = false;
153
+ }
154
+ }
155
+ return { installed, version, isRepo, path: abs };
156
+ });
157
+
158
+ // ---- git init(T-M2-01 按钮)----
159
+ reg('git.init', async (params = {}) => {
160
+ const abs = resolveRepo(params);
161
+ return withWriteLock(async () => {
162
+ try {
163
+ await runGit(['init'], { cwd: abs, timeout: 30000 });
164
+ } catch (e) {
165
+ throw new RpcError('E_GIT_FAILED', `git init 失败: ${e.message}`);
166
+ }
167
+ return { ok: true };
168
+ });
169
+ });
170
+
171
+ // ---- 状态(T-M2-02)----
172
+ reg('git.status', async (params = {}) => {
173
+ const abs = resolveRepo(params);
174
+ try {
175
+ const [st, num, numC] = await Promise.all([
176
+ runGit(['status', '--porcelain=v2', '-b', '--untracked-files=normal'], { cwd: abs }),
177
+ runGit(['diff', '--numstat'], { cwd: abs }).catch(() => ({ stdout: '' })),
178
+ runGit(['diff', '--cached', '--numstat'], { cwd: abs }).catch(() => ({ stdout: '' })),
179
+ ]);
180
+ const parsed = parseStatusV2(st.stdout);
181
+ const numstat = { ...parseNumstat(num.stdout), ...parseNumstat(numC.stdout, true) };
182
+ const mergeInProgress = /^\s*Merge conflict|unmerged/i.test(st.stdout);
183
+ return {
184
+ branch: parsed.branch,
185
+ changes: parsed.changes,
186
+ numstat,
187
+ mergeInProgress,
188
+ isRepo: true,
189
+ };
190
+ } catch (e) {
191
+ if (e && e.code === 'E_GIT_NOT_REPO') return { isRepo: false };
192
+ throw e;
193
+ }
194
+ });
195
+
196
+ // ---- diff(T-M2-03)----
197
+ reg('git.diff', async (params = {}) => {
198
+ const abs = resolveRepo(params);
199
+ const args = ['diff'];
200
+ if (params.staged) args.push('--cached');
201
+ if (params.file) {
202
+ args.push('--', String(params.file));
203
+ } else if (params.files && Array.isArray(params.files) && params.files.length > 0) {
204
+ args.push('--', ...params.files.map(String));
205
+ }
206
+ const r = await runGit(args, { cwd: abs });
207
+ return { diff: r.stdout };
208
+ });
209
+
210
+ // ---- 暂存/取消暂存(T-M2-04)----
211
+ reg('git.stage', async (params = {}) => {
212
+ const abs = resolveRepo(params);
213
+ return withWriteLock(async () => {
214
+ const args = ['add'];
215
+ if (params.all) args.push('-A');
216
+ else if (params.files && Array.isArray(params.files) && params.files.length > 0) args.push('--', ...params.files.map(String));
217
+ else if (params.file) args.push('--', String(params.file));
218
+ else throw new RpcError('E_VALIDATION', '缺少文件或 all');
219
+ await runGit(args, { cwd: abs });
220
+ return { ok: true };
221
+ });
222
+ });
223
+
224
+ reg('git.unstage', async (params = {}) => {
225
+ const abs = resolveRepo(params);
226
+ return withWriteLock(async () => {
227
+ const args = ['reset', 'HEAD'];
228
+ if (params.files && Array.isArray(params.files) && params.files.length > 0) args.push('--', ...params.files.map(String));
229
+ else if (params.file) args.push('--', String(params.file));
230
+ else throw new RpcError('E_VALIDATION', '缺少文件');
231
+ await runGit(args, { cwd: abs }).catch(() => { /* 空仓库 reset 可能失败,忽略 */ });
232
+ return { ok: true };
233
+ });
234
+ });
235
+
236
+ // ---- 提交(T-M2-04)----
237
+ reg('git.commit', async (params = {}) => {
238
+ const abs = resolveRepo(params);
239
+ const message = String(params.message || '').trim();
240
+ if (!message) throw new RpcError('E_VALIDATION', '提交信息不能为空');
241
+ if (message.length > 500) throw new RpcError('E_VALIDATION', '提交信息过长(≤500 字符)');
242
+ return withWriteLock(async () => {
243
+ const r = await runGit(['commit', '-m', message], { cwd: abs });
244
+ return { ok: true, output: sanitize(r.stdout.trim()) };
245
+ });
246
+ });
247
+
248
+ // ---- 推送(T-M2-05,危险级:前端必确认)----
249
+ reg('git.push', async (params = {}) => {
250
+ const abs = resolveRepo(params);
251
+ return withWriteLock(async () => {
252
+ const args = ['push'];
253
+ if (params.remote) args.push(String(params.remote));
254
+ if (params.branch) args.push(String(params.branch));
255
+ args.push('--porcelain');
256
+ const r = await runGit(args, { cwd: abs, timeout: 180000 });
257
+ return { ok: true, output: sanitize(r.stdout.trim() || r.stderr.trim()) };
258
+ });
259
+ });
260
+
261
+ // ---- 拉取/获取(T-M2-05)----
262
+ reg('git.pull', async (params = {}) => {
263
+ const abs = resolveRepo(params);
264
+ return withWriteLock(async () => {
265
+ const args = ['pull'];
266
+ if (params.remote) args.push(String(params.remote));
267
+ if (params.branch) args.push(String(params.branch));
268
+ const r = await runGit(args, { cwd: abs, timeout: 180000 });
269
+ const out = sanitize(r.stdout.trim() || r.stderr.trim());
270
+ const hasConflict = /conflict|CONFLICT/i.test(r.stdout + r.stderr);
271
+ return { ok: true, output: out, conflict: hasConflict };
272
+ });
273
+ });
274
+
275
+ reg('git.fetch', async (params = {}) => {
276
+ const abs = resolveRepo(params);
277
+ return withWriteLock(async () => {
278
+ const r = await runGit(['fetch', '--prune'], { cwd: abs, timeout: 180000 });
279
+ return { ok: true, output: sanitize(r.stdout.trim() || r.stderr.trim()) };
280
+ });
281
+ });
282
+
283
+ // ---- 分支(T-M2-06)----
284
+ reg('git.branches', async (params = {}) => {
285
+ const abs = resolveRepo(params);
286
+ const [local, remote, status] = await Promise.all([
287
+ runGit(['branch', '--format=%(refname:short)'], { cwd: abs }),
288
+ runGit(['branch', '-r', '--format=%(refname:short)'], { cwd: abs }).catch(() => ({ stdout: '' })),
289
+ runGit(['status', '--porcelain=v2', '-b', '--untracked-files=no'], { cwd: abs }),
290
+ ]);
291
+ const current = parseStatusV2(status.stdout).branch.current;
292
+ return {
293
+ current,
294
+ local: local.stdout.split(/\r?\n/).filter(Boolean),
295
+ remote: remote.stdout.split(/\r?\n/).filter(Boolean),
296
+ };
297
+ });
298
+
299
+ reg('git.createBranch', async (params = {}) => {
300
+ const abs = resolveRepo(params);
301
+ const name = String(params.name || '').trim();
302
+ if (!/^[^\s~^:?*[\\]+$/.test(name)) throw new RpcError('E_VALIDATION', '分支名包含非法字符');
303
+ return withWriteLock(async () => {
304
+ await runGit(['checkout', '-b', name], { cwd: abs });
305
+ return { ok: true, branch: name };
306
+ });
307
+ });
308
+
309
+ reg('git.checkout', async (params = {}) => {
310
+ const abs = resolveRepo(params);
311
+ const branch = String(params.branch || '').trim();
312
+ if (!branch) throw new RpcError('E_VALIDATION', '缺少分支名');
313
+ return withWriteLock(async () => {
314
+ const args = ['checkout'];
315
+ if (params.force) args.push('-f');
316
+ args.push(branch);
317
+ const r = await runGit(args, { cwd: abs });
318
+ return { ok: true, output: r.stdout.trim() };
319
+ });
320
+ });
321
+
322
+ reg('git.deleteBranch', async (params = {}) => {
323
+ const abs = resolveRepo(params);
324
+ const name = String(params.name || '').trim();
325
+ if (!name) throw new RpcError('E_VALIDATION', '缺少分支名');
326
+ return withWriteLock(async () => {
327
+ const args = ['branch', '-d'];
328
+ if (params.force) args.push('-D');
329
+ args.push(name);
330
+ const r = await runGit(args, { cwd: abs });
331
+ return { ok: true, output: r.stdout.trim() };
332
+ });
333
+ });
334
+
335
+ // ---- 合并(T-M2-06/07,危险级:前端必确认)----
336
+ reg('git.merge', async (params = {}) => {
337
+ const abs = resolveRepo(params);
338
+ const branch = String(params.branch || '').trim();
339
+ if (!branch) throw new RpcError('E_VALIDATION', '缺少要合并的分支');
340
+ return withWriteLock(async () => {
341
+ const r = await runGit(['merge', branch], { cwd: abs, timeout: 120000 });
342
+ const out = sanitize(r.stdout.trim() || r.stderr.trim());
343
+ return { ok: true, output: out, conflict: /conflict|CONFLICT/i.test(r.stdout + r.stderr) };
344
+ });
345
+ });
346
+
347
+ reg('git.mergeAbort', async (params = {}) => {
348
+ const abs = resolveRepo(params);
349
+ return withWriteLock(async () => {
350
+ await runGit(['merge', '--abort'], { cwd: abs });
351
+ return { ok: true };
352
+ });
353
+ });
354
+
355
+ // ---- 最近提交(辅助展示)----
356
+ reg('git.log', async (params = {}) => {
357
+ const abs = resolveRepo(params);
358
+ const n = Math.min(50, Math.max(1, Number(params.n) || 20));
359
+ const r = await runGit(['log', `-${n}`, '--pretty=format:%h|%an|%ad|%s', '--date=format:%Y-%m-%d %H:%M'], { cwd: abs });
360
+ const commits = r.stdout
361
+ .split(/\r?\n/)
362
+ .filter(Boolean)
363
+ .map((line) => {
364
+ const [hash, author, date, ...rest] = line.split('|');
365
+ return { hash, author, date, subject: rest.join('|') };
366
+ });
367
+ return { commits };
368
+ });
369
+
370
+ // ---- 凭证检测(T-M2-08:仅检测,不取回凭证)----
371
+ reg('git.credentialCheck', async (params = {}) => {
372
+ const abs = resolveRepo(params);
373
+ const remote = String(params.remote || '');
374
+ let url = remote;
375
+ if (!url) {
376
+ try {
377
+ const r = await runGit(['remote', 'get-url', 'origin'], { cwd: abs });
378
+ url = r.stdout.trim();
379
+ } catch {
380
+ url = '';
381
+ }
382
+ }
383
+ // 只返回脱敏后的远程地址与是否含凭证提示
384
+ return { remoteUrl: url ? sanitize(url) : '', hasRemote: !!url };
385
+ });
386
+ }
387
+
388
+ return { register };
389
+ }
390
+
391
+ module.exports = { createGitMethods };
@@ -0,0 +1,57 @@
1
+ /**
2
+ * project.* RPC:项目目录授权管理(allowlist 维护,T-M0-13)
3
+ * - project.addRoot {path} 网站添加项目时由前端调用(写入 agent.json allowedRoots)
4
+ * - project.removeRoot {path}
5
+ * - project.listRoots
6
+ * - project.validate {path} 校验路径是否在沙箱内(后端/前端预检用)
7
+ */
8
+ 'use strict';
9
+
10
+ const { RpcError } = require('../rpc');
11
+
12
+ function createProjectMethods(ctxRef) {
13
+ function sandbox() {
14
+ return ctxRef().sandbox;
15
+ }
16
+
17
+ return {
18
+ register(register) {
19
+ register('project.addRoot', async (params = {}) => {
20
+ const cfg = ctxRef().config;
21
+ const root = sandbox().addRoot(params.path, { force: false });
22
+ // 持久化到 agent.json + 内存同步
23
+ const { saveConfig } = require('../config');
24
+ if (!cfg.allowedRoots.includes(root)) {
25
+ cfg.allowedRoots = [...cfg.allowedRoots, root];
26
+ saveConfig({ allowedRoots: cfg.allowedRoots });
27
+ }
28
+ return { root };
29
+ });
30
+
31
+ register('project.removeRoot', async (params = {}) => {
32
+ const cfg = ctxRef().config;
33
+ const ok = sandbox().removeRoot(params.path);
34
+ if (ok) {
35
+ const { saveConfig } = require('../config');
36
+ cfg.allowedRoots = cfg.allowedRoots.filter((r) => r !== params.path && require('path').resolve(r) !== require('path').resolve(params.path));
37
+ saveConfig({ allowedRoots: cfg.allowedRoots });
38
+ }
39
+ return { removed: ok };
40
+ });
41
+
42
+ register('project.listRoots', async () => ({ roots: sandbox().listRoots() }));
43
+
44
+ register('project.validate', async (params = {}) => {
45
+ try {
46
+ const r = sandbox().resolve(params.path, params.base);
47
+ return { ok: true, abs: r.abs, root: r.root, rel: r.rel };
48
+ } catch (e) {
49
+ if (e instanceof RpcError) return { ok: false, code: e.code, message: e.message };
50
+ throw e;
51
+ }
52
+ });
53
+ },
54
+ };
55
+ }
56
+
57
+ module.exports = { createProjectMethods };
@@ -0,0 +1,131 @@
1
+ /**
2
+ * scaffold.* RPC 方法(T-M3-10/11,需求 5.3.2 方式 C / 7.3)
3
+ * - scaffold.write {files:[{relPath, content}], requestId} 批量写文件(幂等)
4
+ * - scaffold.rollback {manifestId} 删除本次创建的全部文件(回滚)
5
+ * - 安全:路径沙箱校验(相对路径/无穿越/无非法字符)、大小限制(文件数≤200、单文件≤200KB、总量≤10MB)
6
+ * - 幂等:requestId 去重;回滚清单持久化(技能/项目根下 .scaffold-manifests.json)
7
+ */
8
+ 'use strict';
9
+
10
+ const fs = require('fs');
11
+ const path = require('path');
12
+ const { RpcError } = require('../rpc');
13
+ const { CONFIG_DIR } = require('../config');
14
+ const logger = require('../logger');
15
+
16
+ const MAX_FILES = 200;
17
+ const MAX_FILE_SIZE = 200 * 1024; // 单文件 200KB
18
+ const MAX_TOTAL = 10 * 1024 * 1024; // 总量 10MB
19
+ const MANIFEST_FILE = path.join(CONFIG_DIR, 'scaffold-manifests.json');
20
+ const IDEMPOTENT_MS = 5 * 60 * 1000;
21
+
22
+ function createScaffoldMethods(ctxRef) {
23
+ const sandbox = () => ctxRef().sandbox;
24
+ const idempotentCache = new Map();
25
+
26
+ function readManifests() {
27
+ try {
28
+ return JSON.parse(fs.readFileSync(MANIFEST_FILE, 'utf8'));
29
+ } catch {
30
+ return [];
31
+ }
32
+ }
33
+ function saveManifests(list) {
34
+ try {
35
+ fs.mkdirSync(path.dirname(MANIFEST_FILE), { recursive: true });
36
+ fs.writeFileSync(MANIFEST_FILE, JSON.stringify(list, null, 2));
37
+ } catch (e) {
38
+ logger.warn(`[scaffold] 清单保存失败: ${e.message}`);
39
+ }
40
+ }
41
+
42
+ function register(reg) {
43
+ // ---- 批量写入(幂等)----
44
+ reg('scaffold.write', async (params = {}) => {
45
+ const requestId = String(params.requestId || '');
46
+ if (requestId) {
47
+ const hit = idempotentCache.get(requestId);
48
+ if (hit && Date.now() - hit.at < IDEMPOTENT_MS) return hit.result;
49
+ }
50
+ const files = Array.isArray(params.files) ? params.files : [];
51
+ if (files.length === 0) throw new RpcError('E_SCAFFOLD_EMPTY', '文件清单为空');
52
+ if (files.length > MAX_FILES) throw new RpcError('E_SCAFFOLD_OVERFLOW', `文件数超限(>${MAX_FILES})`);
53
+
54
+ // 校验 + 解析(相对路径基准 = 项目根)
55
+ const projectRoot = params.projectRoot ? String(params.projectRoot) : '';
56
+ const r = sandbox().resolve(projectRoot, '');
57
+ const absRoot = r.abs;
58
+ const written = [];
59
+ let totalBytes = 0;
60
+ for (const f of files) {
61
+ const rel = String(f.relPath || '');
62
+ if (!rel || rel.includes('..') || rel.startsWith('/') || rel.includes('\\') || rel.split('/').some((s) => !s || s === '.')) {
63
+ throw new RpcError('E_SCAFFOLD_INVALID', `非法相对路径: ${rel}`);
64
+ }
65
+ const content = String(f.content ?? '');
66
+ if (Buffer.byteLength(content, 'utf8') > MAX_FILE_SIZE) {
67
+ throw new RpcError('E_SCAFFOLD_OVERFLOW', `单文件超限(>200KB): ${rel}`);
68
+ }
69
+ totalBytes += Buffer.byteLength(content, 'utf8');
70
+ if (totalBytes > MAX_TOTAL) throw new RpcError('E_SCAFFOLD_OVERFLOW', '总大小超限(>10MB)');
71
+ written.push({ relPath: rel, content });
72
+ }
73
+
74
+ // 批量写入(幂等:已存在同内容跳过,不同内容报错防覆盖)
75
+ const created = [];
76
+ const manifestId = `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
77
+ for (const f of written) {
78
+ const abs = path.join(absRoot, f.relPath);
79
+ if (fs.existsSync(abs)) {
80
+ const cur = fs.readFileSync(abs, 'utf8');
81
+ if (cur === f.content) continue; // 已写入(重试幂等)
82
+ throw new RpcError('E_PATH_EXISTS', `文件已存在且内容不同(已创建 ${created.length} 个,可回滚): ${f.relPath}`);
83
+ }
84
+ fs.mkdirSync(path.dirname(abs), { recursive: true });
85
+ fs.writeFileSync(abs, f.content, 'utf8');
86
+ created.push(f.relPath);
87
+ }
88
+ // 记录回滚清单
89
+ const manifests = readManifests();
90
+ manifests.push({ manifestId, projectRoot: absRoot, files: created, createdAt: Date.now() });
91
+ saveManifests(manifests.slice(-100));
92
+
93
+ const result = { ok: true, manifestId, created: created.length };
94
+ if (requestId) idempotentCache.set(requestId, { at: Date.now(), result });
95
+ logger.info(`[scaffold] 写入 ${created.length}/${written.length} 个文件到 ${absRoot}`);
96
+ return result;
97
+ });
98
+
99
+ // ---- 回滚(删除本次创建文件)----
100
+ reg('scaffold.rollback', async (params = {}) => {
101
+ const manifestId = String(params.manifestId || '');
102
+ const manifests = readManifests();
103
+ const idx = manifests.findIndex((m) => m.manifestId === manifestId);
104
+ if (idx < 0) throw new RpcError('E_SCAFFOLD_NOT_FOUND', '回滚清单不存在');
105
+ const m = manifests[idx];
106
+ // 目标必须在授权项目内
107
+ const r = sandbox().resolve(m.projectRoot, '');
108
+ if (r.abs !== m.projectRoot && !m.projectRoot.startsWith(r.abs + path.sep)) {
109
+ throw new RpcError('E_PATH_ESCAPE', '回滚目标越界');
110
+ }
111
+ // 只删除清单内文件(倒序删文件,目录留着)
112
+ const removed = [];
113
+ for (const rel of m.files.slice().reverse()) {
114
+ const abs = path.join(m.projectRoot, rel);
115
+ try {
116
+ if (fs.existsSync(abs)) {
117
+ fs.rmSync(abs);
118
+ removed.push(rel);
119
+ }
120
+ } catch { /* ignore */ }
121
+ }
122
+ manifests.splice(idx, 1);
123
+ saveManifests(manifests);
124
+ return { ok: true, removed: removed.length };
125
+ });
126
+ }
127
+
128
+ return { register };
129
+ }
130
+
131
+ module.exports = { createScaffoldMethods };