agent-syncer 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/lib/prompt.js CHANGED
@@ -1,7 +1,7 @@
1
1
  // @ts-check
2
2
  import process from 'node:process';
3
3
  import readline from 'node:readline';
4
- import { bold, cyan, dim, green } from './log.js';
4
+ import { bold, cyan, dim, green, red } from './log.js';
5
5
 
6
6
  /**
7
7
  * 是否具备交互条件。
@@ -123,3 +123,366 @@ export function checkbox({ message, choices, hint, input = process.stdin, output
123
123
  render();
124
124
  });
125
125
  }
126
+
127
+ /**
128
+ * 可打印字符吗——即「这次按键是用户想输进筛选框 / 输入框的一个字」。
129
+ *
130
+ * 判据是 readline 给的 `str`:方向键、功能键的 str 是转义序列或空串,
131
+ * 只有可打印字符是一个字符长。再排掉 Ctrl / Alt 组合,否则 Ctrl-A 之类
132
+ * 会被当成输入了一个 'a'。
133
+ *
134
+ * @param {string} str @param {import('node:readline').Key} key
135
+ */
136
+ function isPrintable(str, key) {
137
+ return typeof str === 'string' && str.length === 1 && !key.ctrl && !key.meta && str >= ' ';
138
+ }
139
+
140
+ /**
141
+ * select / text / confirm 共用的骨架:接管 raw mode、把按键转给调用方,
142
+ * **无论走哪条路退出都恢复原状**(不恢复的话监听器会留着,进程不退出)。
143
+ *
144
+ * 与 checkbox 是同一套做法(重绘前先退回已画的行数、Ctrl-C → null、
145
+ * finally 里收拾干净)。没有反过来让 checkbox 也用它:那个多选已经稳定,
146
+ * 有一整套测试钉着,为了少几行重复去改它,风险大于收益。
147
+ *
148
+ * @param {{
149
+ * input: NodeJS.ReadStream,
150
+ * output: NodeJS.WriteStream,
151
+ * render: (final: boolean) => string[],
152
+ * onKey: (str: string, key: import('node:readline').Key, ctl: {draw: (final?: boolean) => void, finish: (result: any) => void}) => void,
153
+ * }} opts
154
+ * @returns {Promise<any>} 取消(Ctrl-C)一律返回 null
155
+ */
156
+ function interactive({ input, output, render, onKey }) {
157
+ /** @type {(result: any) => void} */
158
+ let resolveFn = () => {};
159
+ const promise = new Promise((r) => {
160
+ resolveFn = r;
161
+ });
162
+
163
+ let drawn = 0;
164
+ let settled = false;
165
+
166
+ /** 重绘:先退回已画的行数再重画,避免刷屏 */
167
+ const draw = (final = false) => {
168
+ if (drawn > 0) {
169
+ readline.moveCursor(output, 0, -drawn);
170
+ readline.cursorTo(output, 0);
171
+ readline.clearScreenDown(output);
172
+ }
173
+ const lines = render(final);
174
+ output.write(`${lines.join('\n')}\n`);
175
+ drawn = lines.length;
176
+ };
177
+
178
+ /** @param {any} result */
179
+ const finish = (result) => {
180
+ if (settled) return;
181
+ settled = true;
182
+ input.removeListener('keypress', handler);
183
+ try {
184
+ input.setRawMode(false);
185
+ } catch {
186
+ // 非 TTY 时 setRawMode 会抛,忽略即可
187
+ }
188
+ input.pause();
189
+ try {
190
+ draw(true);
191
+ } catch {
192
+ // 输出流可能已被关闭,渲染失败不应影响返回值
193
+ }
194
+ resolveFn(result);
195
+ };
196
+
197
+ /** @param {string} str @param {import('node:readline').Key} key */
198
+ const handler = (str, key) => {
199
+ if (!key) return;
200
+ // Ctrl-C 是全局的「取消」:所有提示都返回 null,调用方据此不得写盘
201
+ if (key.ctrl && key.name === 'c') {
202
+ finish(null);
203
+ return;
204
+ }
205
+ onKey(str, key, { draw, finish });
206
+ };
207
+
208
+ readline.emitKeypressEvents(input);
209
+ try {
210
+ input.setRawMode(true);
211
+ } catch {
212
+ // 同上:真实交互场景下不会走到这里
213
+ }
214
+ input.resume();
215
+ input.on('keypress', handler);
216
+ draw();
217
+
218
+ return promise;
219
+ }
220
+
221
+ /**
222
+ * 单选列表。按键:↑↓(或 k / j)移动 回车 确认 Ctrl-C 取消。
223
+ *
224
+ * `filterable` 打开后还能就地筛选——在**下拉框里输字符缩小范围**,退格删字符,
225
+ * Esc 清空。这一步不是锦上添花:内容仓库的分支和标签动辄几十上百个,
226
+ * 只能上下翻的话找一个 tag 要按到手酸。(打开筛选后 k / j 让位给输入,
227
+ * 移动只认方向键。)
228
+ *
229
+ * 列表比 `maxVisible` 长时**只画可视区**(其余折叠成「上面还有 N 项」),
230
+ * 光标始终在窗口内。全刷出来会把前面问过的问题冲掉。
231
+ *
232
+ * @param {{
233
+ * message: string,
234
+ * choices: {value: string, label: string, note?: string}[],
235
+ * hint?: string,
236
+ * initial?: number,
237
+ * maxVisible?: number,
238
+ * filterable?: boolean,
239
+ * input?: NodeJS.ReadStream,
240
+ * output?: NodeJS.WriteStream,
241
+ * }} opts
242
+ * @returns {Promise<string|null>} 选中项的 value;取消返回 null;本来就没有可选项也返回 null
243
+ */
244
+ export function select({
245
+ message,
246
+ choices,
247
+ hint,
248
+ initial = 0,
249
+ maxVisible = 12,
250
+ filterable = false,
251
+ input = process.stdin,
252
+ output = process.stdout,
253
+ }) {
254
+ // 没得选就没什么可问的。调用方应当自己先判空(问「要用哪个版本」之前
255
+ // 总得先确认取到了版本),这里的 null 只是别让空列表把渲染搞崩。
256
+ if (choices.length === 0) return Promise.resolve(null);
257
+
258
+ const items = choices.map((c) => ({ ...c }));
259
+ let cursor = Math.min(Math.max(initial, 0), items.length - 1);
260
+ let filter = '';
261
+
262
+ /** 当前可选的项(筛选之后)。空筛选返回原列表,连数组都不新建 */
263
+ const shown = () => {
264
+ if (filter === '') return items;
265
+ const q = filter.toLowerCase();
266
+ return items.filter((c) => `${c.value} ${c.label}`.toLowerCase().includes(q));
267
+ };
268
+
269
+ const trailing =
270
+ hint ??
271
+ (filterable
272
+ ? '↑↓ 移动 · 回车 确认 · 输入字符筛选 · 退格删除 · Esc 清空 · Ctrl-C 取消'
273
+ : '↑↓ 移动 · 回车 确认 · Ctrl-C 取消');
274
+
275
+ const render = (final) => {
276
+ const list = shown();
277
+ // 筛选会让列表变短,光标要跟着收回来,否则回车会读到空位置上
278
+ if (cursor > list.length - 1) cursor = Math.max(0, list.length - 1);
279
+
280
+ const lines = [bold(message)];
281
+ if (filterable && filter !== '') lines.push(` ${dim('筛选:')}${cyan(filter)}`);
282
+
283
+ const visible = Math.max(1, maxVisible);
284
+ const start =
285
+ list.length <= visible
286
+ ? 0
287
+ : Math.min(Math.max(cursor - Math.floor(visible / 2), 0), list.length - visible);
288
+ const end = Math.min(list.length, start + visible);
289
+
290
+ if (list.length === 0) {
291
+ lines.push(dim(' (没有匹配的项,退格或 Esc 可以改回来)'));
292
+ } else {
293
+ if (start > 0) lines.push(dim(` ↑ 上面还有 ${start} 项`));
294
+ for (let i = start; i < end; i += 1) {
295
+ const c = list[i];
296
+ // 光标标记在收尾那一帧**也留着**:多选收尾时勾选状态本身就说明了结果,
297
+ // 单选不留下标记的话,最后一屏只剩一列一样长的选项,看不出选了哪个
298
+ const active = i === cursor;
299
+ lines.push(
300
+ ` ${active ? cyan('❯') : ' '} ${active ? cyan(c.label) : c.label}` +
301
+ `${c.note ? ` ${dim(c.note)}` : ''}`,
302
+ );
303
+ }
304
+ if (end < list.length) lines.push(dim(` ↓ 下面还有 ${list.length - end} 项`));
305
+ }
306
+
307
+ if (!final) lines.push(dim(` ${trailing}`));
308
+ return lines;
309
+ };
310
+
311
+ /** @param {string} str @param {import('node:readline').Key} key */
312
+ const onKey = (str, key, ctl) => {
313
+ const list = shown();
314
+
315
+ if (key.name === 'up' || (!filterable && key.name === 'k')) {
316
+ if (list.length === 0) return;
317
+ cursor = (cursor - 1 + list.length) % list.length;
318
+ } else if (key.name === 'down' || (!filterable && key.name === 'j')) {
319
+ if (list.length === 0) return;
320
+ cursor = (cursor + 1) % list.length;
321
+ } else if (key.name === 'return' || key.name === 'enter') {
322
+ if (list.length === 0) return;
323
+ const picked = list[cursor];
324
+ if (!picked) return;
325
+ ctl.finish(picked.value);
326
+ return;
327
+ } else if (filterable && key.name === 'escape') {
328
+ if (filter === '') return;
329
+ filter = '';
330
+ cursor = 0;
331
+ } else if (filterable && key.name === 'backspace') {
332
+ if (filter === '') return;
333
+ filter = filter.slice(0, -1);
334
+ cursor = 0;
335
+ } else if (filterable && isPrintable(str, key)) {
336
+ filter += str;
337
+ cursor = 0;
338
+ } else {
339
+ return;
340
+ }
341
+
342
+ ctl.draw();
343
+ };
344
+
345
+ return interactive({ input, output, render, onKey });
346
+ }
347
+
348
+ /**
349
+ * 单行文本输入。按键:直接输入字符 退格删除 回车 确认 Ctrl-C 取消。
350
+ *
351
+ * `initial` 是**预填的默认答案**,不是一段等着往后接的文本:用户一打字就整个替换掉。
352
+ * 不这样做的话,「预填一条长路径 + 用户开始打字」会拼成两个路径粘在一起,
353
+ * 想去掉预填还得按几十下退格——而预填值的绝大多数用途正是「懒得重打,但真要改就得换掉」。
354
+ * 回车原样采纳预填值这条不受影响。
355
+ *
356
+ * 返回值**原样**给出,不 trim——要不要去掉两边的空白是调用方的事
357
+ * (内容仓库路径要,别的未必)。`validate` 返回一段说明就停在原地重问,
358
+ * 而不是让一个空值混进配置里。
359
+ *
360
+ * @param {{
361
+ * message: string,
362
+ * initial?: string,
363
+ * hint?: string,
364
+ * validate?: (value: string) => string|null|undefined,
365
+ * input?: NodeJS.ReadStream,
366
+ * output?: NodeJS.WriteStream,
367
+ * }} opts
368
+ * @returns {Promise<string|null>} 输入的原文;取消返回 null
369
+ */
370
+ export function text({
371
+ message,
372
+ initial = '',
373
+ hint,
374
+ validate,
375
+ input = process.stdin,
376
+ output = process.stdout,
377
+ }) {
378
+ let value = initial;
379
+ /** 预填值还没被碰过——这时候打进来的第一个字符是来替换它的 */
380
+ let pristine = initial !== '';
381
+ /** @type {string|null} */
382
+ let error = null;
383
+
384
+ // 提示语按「有没有预填值」定,不跟着 pristine 变——提示在编辑途中换词只会让人分神
385
+ const trailing =
386
+ hint ??
387
+ (initial !== ''
388
+ ? '输入即替换预填值 · 回车 直接采纳 · Ctrl-C 取消'
389
+ : '直接输入 · 退格删除 · 回车 确认 · Ctrl-C 取消');
390
+
391
+ const render = (final) => {
392
+ const lines = [bold(message), ` ${cyan(value)}${final ? '' : dim('▌')}`];
393
+ if (error) lines.push(` ${red(error)}`);
394
+ if (!final) lines.push(dim(` ${trailing}`));
395
+ return lines;
396
+ };
397
+
398
+ /** @param {string} str @param {import('node:readline').Key} key */
399
+ const onKey = (str, key, ctl) => {
400
+ if (key.name === 'return' || key.name === 'enter') {
401
+ const problem = validate ? validate(value) : null;
402
+ if (problem) {
403
+ error = problem;
404
+ ctl.draw();
405
+ return;
406
+ }
407
+ ctl.finish(value);
408
+ return;
409
+ }
410
+ if (key.name === 'backspace' || key.name === 'delete') {
411
+ if (value === '') return;
412
+ value = value.slice(0, -1);
413
+ pristine = false;
414
+ error = null;
415
+ ctl.draw();
416
+ return;
417
+ }
418
+ if (isPrintable(str, key)) {
419
+ value = pristine ? str : value + str;
420
+ pristine = false;
421
+ error = null;
422
+ ctl.draw();
423
+ }
424
+ };
425
+
426
+ return interactive({ input, output, render, onKey });
427
+ }
428
+
429
+ /**
430
+ * 是 / 否。按键:y / n 直接作答(不等回车) 方向键或空格 切换 回车 确认 Ctrl-C 取消。
431
+ *
432
+ * 直接按 y / n 就结束,是因为这个提示只用在「要不要写盘」「要不要接着跑」
433
+ * 这种一句话就能答的地方,多按一次回车没有信息量。
434
+ *
435
+ * @param {{
436
+ * message: string,
437
+ * initial?: boolean,
438
+ * hint?: string,
439
+ * input?: NodeJS.ReadStream,
440
+ * output?: NodeJS.WriteStream,
441
+ * }} opts
442
+ * @returns {Promise<boolean|null>} 取消返回 null
443
+ */
444
+ export function confirm({
445
+ message,
446
+ initial = true,
447
+ hint,
448
+ input = process.stdin,
449
+ output = process.stdout,
450
+ }) {
451
+ let value = Boolean(initial);
452
+
453
+ const render = (final) => {
454
+ const on = (/** @type {boolean} */ v) => (value === v ? cyan(`❯ ${v ? '是' : '否'}`) : dim(` ${v ? '是' : '否'}`));
455
+ const lines = [bold(message), ` ${on(true)} / ${on(false)}`];
456
+ if (!final) lines.push(dim(` ${hint ?? 'y / n 直接作答 · 方向键切换 · 回车 确认 · Ctrl-C 取消'}`));
457
+ return lines;
458
+ };
459
+
460
+ /** @param {string} str @param {import('node:readline').Key} key */
461
+ const onKey = (str, key, ctl) => {
462
+ if (key.name === 'y' || key.name === 'Y') {
463
+ ctl.finish(true);
464
+ return;
465
+ }
466
+ if (key.name === 'n' || key.name === 'N') {
467
+ ctl.finish(false);
468
+ return;
469
+ }
470
+ if (key.name === 'return' || key.name === 'enter') {
471
+ ctl.finish(value);
472
+ return;
473
+ }
474
+ if (
475
+ key.name === 'up' ||
476
+ key.name === 'down' ||
477
+ key.name === 'left' ||
478
+ key.name === 'right' ||
479
+ key.name === 'tab' ||
480
+ key.name === 'space'
481
+ ) {
482
+ value = !value;
483
+ ctl.draw();
484
+ }
485
+ };
486
+
487
+ return interactive({ input, output, render, onKey });
488
+ }
package/lib/prune.js ADDED
@@ -0,0 +1,80 @@
1
+ // @ts-check
2
+ import fs from 'node:fs';
3
+ import path from 'node:path';
4
+
5
+ /**
6
+ * `--prune` 删完之后,把因此空掉的目录也收掉。
7
+ *
8
+ * ## 为什么敢删目录
9
+ *
10
+ * README 里那条承诺是「绝不删除实体目录」,这个模块把它**收窄**成
11
+ * 「绝不删除**有内容的**目录」——`fs.rmdirSync` 只对空目录成功,非空一律
12
+ * `ENOTEMPTY` 报错。所以这里不可能删掉任何还装着东西的目录,哪怕判断逻辑
13
+ * 写错了也删不掉:内核会把这一刀挡下来。
14
+ *
15
+ * 另外只认**实体**目录:`lstatSync` 一看是链接就直接放弃。Windows 上
16
+ * `RemoveDirectoryW` 对 junction 的语义是「摘掉重解析点」而非「删目标内容」,
17
+ * 所以万一传进来一个指向别处、目标非空的 junction,那一下就把别人的链接摘了。
18
+ * 链接归 `removeLink` 管,这里不越界。
19
+ *
20
+ * ## 为什么要删
21
+ *
22
+ * git 不跟踪空目录,所以留着不影响版本库。唯一的实际后果是**误导**:
23
+ * `doctor` 见到 `.trae/` 还在就会报「目录存在,但未在 links 里声明」,
24
+ * 而那只是上一轮 `--prune` 的残渣——用户会以为自己的配置写漏了。
25
+ *
26
+ * 调用方只在 `--prune` 分支里用它,默认(只报告)那条路上一个字节都不动。
27
+ */
28
+
29
+ /**
30
+ * 空的实体目录就删掉。返回是否真的删了。
31
+ * @param {string} abs
32
+ */
33
+ export function rmdirIfEmpty(abs) {
34
+ try {
35
+ if (!fs.lstatSync(abs).isDirectory()) return false; // 链接和普通文件一律不碰
36
+ } catch {
37
+ return false; // 不存在
38
+ }
39
+ try {
40
+ fs.rmdirSync(abs);
41
+ return true;
42
+ } catch {
43
+ // 非空、被占用、权限不足——一律当作「留着」,不抛错也不上报
44
+ return false;
45
+ }
46
+ }
47
+
48
+ /**
49
+ * 自下而上收空目录,`abs` 自己也算在内。返回被删掉的目录(自下而上的顺序)。
50
+ *
51
+ * 给 `scripts/` 用:里面的相对路径可能带子目录,删掉一个文件之后从它往上
52
+ * 可能连着空好几层,而每一层都只差最后一次 `rmdir`。
53
+ *
54
+ * @param {string} abs
55
+ * @returns {string[]}
56
+ */
57
+ export function rmdirTreeIfEmpty(abs) {
58
+ /** @type {string[]} */
59
+ const removed = [];
60
+
61
+ /** @param {string} dir */
62
+ const walk = (dir) => {
63
+ /** @type {fs.Dirent[]} */
64
+ let entries;
65
+ try {
66
+ entries = fs.readdirSync(dir, { withFileTypes: true });
67
+ } catch {
68
+ return;
69
+ }
70
+ for (const e of entries) {
71
+ // 链接不是目录(`isDirectory()` 对 junction 在 Windows 上是 false),
72
+ // 两道判断都留着,免得跟着链接走到别人的地盘上去
73
+ if (e.isDirectory() && !e.isSymbolicLink()) walk(path.join(dir, e.name));
74
+ }
75
+ if (rmdirIfEmpty(dir)) removed.push(dir);
76
+ };
77
+
78
+ walk(abs);
79
+ return removed;
80
+ }