deel-local-cli 2.0.0 → 2.0.3

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.
Files changed (65) hide show
  1. package/README.ko.md +14 -5
  2. package/README.md +14 -5
  3. package/package.json +1 -1
  4. package/src/acp/serve.js +49 -12
  5. package/src/agent/commit.js +2 -2
  6. package/src/agent/compact.js +4 -4
  7. package/src/agent/grade.js +1 -1
  8. package/src/agent/loop.js +2 -2
  9. package/src/agent/memory.js +1 -1
  10. package/src/agent/models.js +1 -1
  11. package/src/agent/outschema.js +1 -1
  12. package/src/agent/pins.js +7 -2
  13. package/src/agent/project.js +1 -1
  14. package/src/agent/recall.js +1 -1
  15. package/src/agent/store.js +13 -26
  16. package/src/backend/ctxsize.js +2 -2
  17. package/src/backend/http.js +1 -1
  18. package/src/backend/learn.js +27 -1
  19. package/src/backend/mcp.js +97 -16
  20. package/src/backend/proxy.js +1 -1
  21. package/src/backend/quota.js +2 -2
  22. package/src/commands/common.js +2 -2
  23. package/src/commands/extend.js +0 -1
  24. package/src/commands/model.js +1 -2
  25. package/src/commands.js +4 -2
  26. package/src/completion.js +13 -3
  27. package/src/config.js +35 -13
  28. package/src/configexplain.js +3 -2
  29. package/src/doctor.js +1 -2
  30. package/src/jsonl.js +42 -0
  31. package/src/lsp/client.js +6 -46
  32. package/src/oneshot.js +22 -0
  33. package/src/pack/selfpack.js +2 -2
  34. package/src/pack/zip.js +18 -1
  35. package/src/plugins/manage.js +81 -14
  36. package/src/reap.js +70 -0
  37. package/src/report.js +1 -1
  38. package/src/safety/audit.js +4 -19
  39. package/src/safety/authcmd.js +1 -1
  40. package/src/safety/guard.js +81 -7
  41. package/src/safety/hooks.js +1 -1
  42. package/src/safety/keystore.js +18 -3
  43. package/src/safety/shellenv.js +1 -1
  44. package/src/safety/undo.js +44 -20
  45. package/src/skills/discover.js +1 -1
  46. package/src/tools/doc2md.js +25 -3
  47. package/src/tools/docs.js +3 -3
  48. package/src/tools/edit-match.js +1 -1
  49. package/src/tools/encoding.js +1 -1
  50. package/src/tools/excel-com.js +56 -30
  51. package/src/tools/fastgrep.js +65 -10
  52. package/src/tools/fsutil.js +8 -4
  53. package/src/tools/ignore.js +21 -1
  54. package/src/tools/index.js +77 -7
  55. package/src/tools/jobs.js +27 -5
  56. package/src/tools/pdf.js +1 -3
  57. package/src/tools/shell.js +27 -0
  58. package/src/tools/todo.js +1 -1
  59. package/src/tools/verify.js +17 -3
  60. package/src/tools/webfetch.js +12 -2
  61. package/src/tools/xlsx.js +4 -4
  62. package/src/ui/banner.js +1 -1
  63. package/src/ui/inputbox.js +1 -1
  64. package/src/ui/md.js +1 -1
  65. package/src/ui/motion.js +1 -1
package/src/pack/zip.js CHANGED
@@ -56,13 +56,26 @@ const UTF8_NAMES = 0x0800; // 플래그 11번 비트 — 이름이 UTF-8 이
56
56
  */
57
57
  export const 풀기총상한 = 256 * 1024 * 1024;
58
58
 
59
+ /*
60
+ * ── zip64 없이 담을 수 있는 끝 (2.0.2 · Z5) ───────────────────────────────────
61
+ *
62
+ * 이 zip 은 zip64 를 안 쓴다. 항목 수·이름 길이는 16비트, 크기·자리는 32비트 칸이다.
63
+ * 넘으면 `writeUInt16LE` 가 날것 RangeError(「must be >= 0 and <= 65535」)를 던졌다 —
64
+ * 그것도 파일 수만 개를 **다 눌러 담은 뒤** 맨 끝 표식을 쓰다가. 무엇이 넘었는지 사람
65
+ * 말로, 넘는 순간에 멈춘다. 검사에서 끝을 좁혀 잴 수 있게 한도는 넘겨받는다.
66
+ */
67
+ export const zip한도 = { 개수: 0xffff, 이름: 0xffff, 크기: 0xffffffff };
68
+
59
69
  /**
60
70
  * @param {Array<{name:string, data:Buffer, mtime?:Date, mode?:number}>} entries
61
71
  * name 은 zip 안에서의 경로. 구분자는 항상 '/'.
62
72
  * mode 는 유닉스 권한(예: 0o755). 실행 파일에 필요하다.
63
73
  * @returns {Buffer}
64
74
  */
65
- export function makeZip(entries) {
75
+ export function makeZip(entries, 한도 = zip한도) {
76
+ if (entries.length > 한도.개수) {
77
+ throw new Error(`zip 에 담을 파일이 ${entries.length.toLocaleString('en-US')}개입니다 — 한 묶음에 ${한도.개수.toLocaleString('en-US')}개까지 담습니다 (zip64 는 만들지 않습니다). 나눠서 묶으세요.`);
78
+ }
66
79
  const locals = [];
67
80
  const central = [];
68
81
  let offset = 0;
@@ -77,6 +90,10 @@ export function makeZip(entries) {
77
90
  const useDeflate = packed.length < raw.length;
78
91
  const body = useDeflate ? packed : raw;
79
92
  const method = useDeflate ? 8 : 0;
93
+ if (nameBuf.length > 한도.이름) throw new Error(`zip 에 담을 이름이 너무 깁니다 (${nameBuf.length}바이트) — ${한도.이름}바이트까지: ${e.name.slice(0, 80)}…`);
94
+ if (raw.length > 한도.크기 || offset + 30 + nameBuf.length + body.length > 한도.크기) {
95
+ throw new Error(`zip 이 zip64 없이 담는 크기(${한도.크기.toLocaleString('en-US')}바이트 · 약 4GB)를 넘습니다 — 나눠서 묶으세요 (넘은 자리: ${e.name})`);
96
+ }
80
97
 
81
98
  const when = 담을시각(e.mtime ?? new Date());
82
99
  const time = dosTime(when);
@@ -6,7 +6,7 @@ import { execFile } from 'node:child_process';
6
6
  import { homeDir } from '../config.js';
7
7
  import { join, dirname, basename, resolve, relative, sep } from 'node:path';
8
8
  import {
9
- existsSync, mkdirSync, writeFileSync, readFileSync, rmSync,
9
+ existsSync, mkdirSync, writeFileSync, readFileSync, rmSync, renameSync,
10
10
  readdirSync, statSync,
11
11
  } from 'node:fs';
12
12
  import { untargz, stripTop, 밖을가리키는것 } from '../pack/tar.js';
@@ -107,13 +107,26 @@ export function 묶음풀기(dest, files) {
107
107
  * 고 하고 하나만 남겼다. 쓰기 전에 전부 재 보고 사람 말로 거절한다.
108
108
  */
109
109
  const 뿌리 = resolve(dest);
110
- const 자리들 = new Map(); // 소문자 상대 자리 → 적힌 이름
110
+ /*
111
+ * ── 쓸 이름도 **잰 이름과 같게** (2.0.2 · P3 · P1) ──────────────────────────
112
+ *
113
+ * 아래 재기는 역슬래시를 빗금으로 보고 쟀는데, 쓸 때는 적힌 이름 그대로 `join` 했다.
114
+ * 윈도에서는 둘이 같지만 리눅스·맥에서는 역슬래시가 **이름 글자**라, 윈도에서 만든
115
+ * 묶음의 `sub\file.txt` 가 `sub` 폴더 안이 아니라 뿌리에 한 파일로 풀렸다 — 스킬이
116
+ * 제자리를 못 찾는다. 이름을 한 번 빗금으로 편 것을 재기와 쓰기가 같이 쓴다.
117
+ *
118
+ * 같은 자리 견주기에는 NFC 도 맞춘다. 맥은 `한글`(NFC)과 `한글`(NFD)을 한 파일로
119
+ * 보므로, 두 꼴이 같이 든 묶음은 맥에서 하나가 말없이 사라진다. 대소문자만 다른
120
+ * 이름과 같은 대접으로 묶음째 거절한다.
121
+ */
122
+ const 편이름 = (f) => String(f.name ?? '').replace(/\\/g, '/');
123
+ const 자리들 = new Map(); // 소문자·NFC 상대 자리 → 적힌 이름
111
124
  for (const f of files) {
112
- const 상대 = relative(뿌리, resolve(뿌리, String(f.name ?? '').replace(/\\/g, '/'))).replace(/\\/g, '/');
125
+ const 상대 = relative(뿌리, resolve(뿌리, 편이름(f))).replace(/\\/g, '/');
113
126
  if (!상대) return { error: `묶음 안에 풀 폴더 그 자체를 가리키는 파일이 있습니다 — 풀지 않았습니다: ${f.name}` };
114
- const 열쇠 = 상대.toLowerCase();
127
+ const 열쇠 = 상대.normalize('NFC').toLowerCase();
115
128
  if (자리들.has(열쇠)) {
116
- return { error: `묶음 안에 같은 자리를 가리키는 이름이 둘 있습니다(대소문자만 다를 수 있음) — 풀지 않았습니다: ${자리들.get(열쇠)} · ${f.name}` };
129
+ return { error: `묶음 안에 같은 자리를 가리키는 이름이 둘 있습니다(대소문자나 한글 자모 꼴만 다를 수 있음) — 풀지 않았습니다: ${자리들.get(열쇠)} · ${f.name}` };
117
130
  }
118
131
  자리들.set(열쇠, f.name);
119
132
  }
@@ -126,13 +139,32 @@ export function 묶음풀기(dest, files) {
126
139
  }
127
140
  rmSync(dest, { recursive: true, force: true });
128
141
  for (const f of files) {
129
- const p = join(dest, f.name);
142
+ const p = join(dest, 편이름(f));
130
143
  mkdirSync(dirname(p), { recursive: true });
131
144
  writeFileSync(p, f.data);
132
145
  }
133
146
  return { 푼것: files.length };
134
147
  }
135
148
 
149
+ /*
150
+ * ── git 없이 받을 주소들 (2.0.2 · P5) ─────────────────────────────────────────
151
+ *
152
+ * `#v1.0.0` 처럼 **태그**를 적어도 `refs/heads/` 만 물어서, git 이 없는 PC 에서는 늘
153
+ * 「받지 못했습니다 — 가지 이름을 확인하세요」 였다. git 이 있으면 `clone --branch` 가
154
+ * 태그도 받으니, 같은 명령이 git 유무에 따라 되고 안 됐다. 가지를 먼저 묻고 없으면
155
+ * 태그를 묻는다(같은 이름이면 git 도 가지를 먼저 고른다).
156
+ */
157
+ export function 받을주소들(spec) {
158
+ const 뿌리 = `https://codeload.github.com/${spec.owner}/${spec.repo}/tar.gz`;
159
+ if (spec.ref) {
160
+ return [
161
+ { branch: spec.ref, url: `${뿌리}/refs/heads/${spec.ref}` },
162
+ { branch: spec.ref, url: `${뿌리}/refs/tags/${spec.ref}` },
163
+ ];
164
+ }
165
+ return ['main', 'master'].map((b) => ({ branch: b, url: `${뿌리}/refs/heads/${b}` }));
166
+ }
167
+
136
168
  // 받는 방법 두 가지. git 이 있으면 clone, 없으면 tarball 을 내려받아 푼다.
137
169
  async function fetchInto(spec, dest, onStep) {
138
170
  if (await has('git')) {
@@ -150,8 +182,7 @@ async function fetchInto(spec, dest, onStep) {
150
182
 
151
183
  onStep?.('tarball 내려받기');
152
184
  if (isOffline()) return { error: '오프라인 모드입니다 — 받아 올 수 없습니다. 풀어 놓은 폴더 경로를 주세요.' };
153
- for (const branch of spec.ref ? [spec.ref] : ['main', 'master']) {
154
- const url = `https://codeload.github.com/${spec.owner}/${spec.repo}/tar.gz/refs/heads/${branch}`;
185
+ for (const { branch, url } of 받을주소들(spec)) {
155
186
  // 사용자가 이 명령을 친 동안만 github 를 연다. 끝나면 바로 닫는다.
156
187
  const close = allowTemporarily(url);
157
188
  let gz;
@@ -183,7 +214,7 @@ async function fetchInto(spec, dest, onStep) {
183
214
  if (푼것.error) return { error: 푼것.error };
184
215
  return { how: 'tarball', branch };
185
216
  }
186
- return { error: '받지 못했습니다 — 저장소 주소나 가지 이름을 확인하세요' };
217
+ return { error: '받지 못했습니다 — 저장소 주소나 가지·태그 이름을 확인하세요' };
187
218
  }
188
219
 
189
220
  /**
@@ -246,7 +277,7 @@ export function 이름한칸(이름) {
246
277
  * 자르는 자가 언젠가 틀릴 수 있다. 그때 마지막으로 서는 그물이다. 지우고
247
278
  * 쓰는 자리라 두 겹으로 막는다.
248
279
  */
249
- export function 플러그인자리인가(base, dest) {
280
+ function 플러그인자리인가(base, dest) {
250
281
  const 뿌리 = resolve(base);
251
282
  const 갈곳 = resolve(dest);
252
283
  return 갈곳 !== 뿌리 && 갈곳.startsWith(뿌리 + sep);
@@ -312,7 +343,7 @@ export async function install(spec, 옵션 = {}) {
312
343
  }
313
344
  }
314
345
 
315
- async function 설치하기(spec, { home = homeDir(), onStep } = {}, 치울것 = []) {
346
+ async function 설치하기(spec, { home = homeDir(), onStep, 복사 = copyDir } = {}, 치울것 = []) {
316
347
  const base = pluginsDir(home);
317
348
 
318
349
  // 이미 풀어 놓은 폴더를 그대로 넣는 길. 오프라인 기기에서 이쪽을 쓴다.
@@ -383,9 +414,43 @@ async function 설치하기(spec, { home = homeDir(), onStep } = {}, 치울것 =
383
414
  return { error: `스킬도 명령도 없습니다. 플러그인이 맞는지 확인하세요 (${isLocal ? asPath : parsed.owner + '/' + parsed.repo})` };
384
415
  }
385
416
 
386
- rmSync(dest, { recursive: true, force: true });
417
+ /*
418
+ * ── 옛것을 **먼저 지우지 않는다** (2.0.2 · P7) ──────────────────────────────
419
+ *
420
+ * 다시 설치(업데이트)는 옛 폴더를 지우고 새것을 베꼈다. 베끼다 넘어지면(디스크가
421
+ * 꽉 참 · 잠긴 파일 · 긴 경로) **둘 다 잃었다** — 옛것은 지웠고 새것은 반쪽이다.
422
+ * 업데이트 한 번에 쓰던 플러그인이 사라지는 것이다.
423
+ *
424
+ * 옛것을 점 이름(`.old-…` — list() 가 안 보는 이름)으로 비켜 두고 베낀다. 다 베끼면
425
+ * 비켜 둔 것을 버리고, 넘어지면 반쪽을 치우고 비켜 둔 것을 제자리로 되돌린다.
426
+ * 비키기(rename)부터 안 되면(윈도에서 그 폴더의 파일을 연 프로그램이 있으면 흔하다)
427
+ * 아무것도 안 건드리고 그렇다고 말한다.
428
+ */
387
429
  mkdirSync(dirname(dest), { recursive: true });
388
- copyDir(tmp, dest, { skipped: 건너뛴것 });
430
+ const 옛것있나 = existsSync(dest);
431
+ const 비켜둘곳 = join(base, `.old-${name}-${process.pid}-${Date.now()}`);
432
+ if (옛것있나) {
433
+ try { renameSync(dest, 비켜둘곳); }
434
+ catch (err) {
435
+ rmSync(tmp, { recursive: true, force: true });
436
+ return { error: `깔려 있던 ${name} 을 비키지 못해 다시 설치하지 않았습니다 — 쓰던 것은 그대로입니다 (${err?.code ?? err?.message ?? err}). 그 폴더의 파일을 연 프로그램을 닫고 다시 하세요.` };
437
+ }
438
+ }
439
+ try {
440
+ 복사(tmp, dest, { skipped: 건너뛴것 });
441
+ } catch (err) {
442
+ try { rmSync(dest, { recursive: true, force: true }); } catch { /* 반쪽을 못 치우면 아래 되돌리기가 넘어진다 — 그 말도 한다 */ }
443
+ let 되돌림 = '쓰던 것은 그대로입니다';
444
+ if (옛것있나) {
445
+ try { renameSync(비켜둘곳, dest); }
446
+ catch { 되돌림 = `쓰던 것은 ${비켜둘곳} 에 남아 있습니다 — 폴더 이름을 ${name} 으로 되돌리면 됩니다`; }
447
+ } else {
448
+ 되돌림 = '반쪽은 치웠습니다';
449
+ }
450
+ rmSync(tmp, { recursive: true, force: true });
451
+ return { error: `${name} 을 베끼지 못해 설치하지 않았습니다 — ${되돌림} (${err?.code ?? err?.message ?? err})` };
452
+ }
453
+ if (옛것있나) { try { rmSync(비켜둘곳, { recursive: true, force: true }); } catch { /* 못 버려도 list() 는 점 이름을 안 본다 */ } }
389
454
  rmSync(tmp, { recursive: true, force: true });
390
455
  // 개수는 **깔린 자리**에서 다시 센다. 복사하다 빠진 것이 여기서 빠진다.
391
456
  const counts = countIn(dest);
@@ -569,7 +634,9 @@ export function pack(outFile, { home = homeDir(), only = null } = {}) {
569
634
  ].join('\n');
570
635
  entries.unshift({ name: '사용안내.txt', data: Buffer.from(manifest, 'utf8') });
571
636
 
572
- const zip = makeZip(entries);
637
+ // 한도(파일 65,535개 · 4GB)를 넘으면 makeZip 이 사람 말로 던진다 — 명령째 죽지 않게 받아 올린다 (2.0.2 · Z5).
638
+ let zip;
639
+ try { zip = makeZip(entries); } catch (err) { return { error: String(err?.message ?? err) }; }
573
640
  mkdirSync(dirname(outFile), { recursive: true });
574
641
  writeFileSync(outFile, zip);
575
642
  return { out: outFile, plugins: included, files: entries.length, skipped, bytes: zip.length, manifest };
package/src/reap.js ADDED
@@ -0,0 +1,70 @@
1
+ /*
2
+ * 신호로 끝날 때 아이들을 거두는 **하나뿐인** 손 (2.0.2 · M3).
3
+ *
4
+ * ── 왜 한 자리로 모았나 ──────────────────────────────────────────────────
5
+ *
6
+ * 'exit' 그물(MCP 서버 · 일감 · 언어 서버가 저마다 건 것)은 **신호로 끝나면 안 돈다.**
7
+ * Node 는 SIGINT·SIGTERM 에 손이 없으면 그 자리에서 죽고, 그 길에는 'exit' 이 없다.
8
+ *
9
+ * 신호 손은 언어 서버(lsp/client.js)에만 있었고, 그 손은 **제 아이만** 거둔 뒤 같은 신호를
10
+ * 다시 보내 죽었다. 그래서 `kill <pid>` 한 번에 언어 서버는 거둬지고 MCP 서버와 일감
11
+ * (`npm run dev` 같은 것)은 남았다 — 입력이 닫히면 스스로 끝나는 서버만 운 좋게 사라졌다.
12
+ * 저마다 손을 따로 달면 더 나쁘다. 손이 둘이면 서로 「남이 맡고 있다」 고 보고 아무도 안
13
+ * 죽어서, deel 이 SIGTERM 을 **삼키는** 프로그램이 된다(아래 다시보낼까).
14
+ *
15
+ * 그래서 손은 여기 하나만 달고, 거둘 것은 각자 `신호에거두기()` 로 적는다.
16
+ *
17
+ * ── 치우고 나면 **원래대로 죽어야 한다** ────────────────────────────────
18
+ *
19
+ * Node 는 SIGINT·SIGTERM 에 손이 하나라도 달려 있으면 **기본 동작(끝내기)을 안 한다.**
20
+ * 치우기만 하고 말면 `kill <pid>` 에 아이들은 죽고 deel 은 그대로 산다 — 잡 관리자·컨테이너
21
+ * 종료·systemd 가 전부 이 신호로 정리하는데, 그것들이 하나같이 시간을 다 기다린 뒤 SIGKILL 로
22
+ * 때려잡게 된다. 그래서 치운 다음 우리 손을 떼고 같은 신호를 자신에게 다시 보낸다. 그러면
23
+ * 기본 동작이 돌아 128+n 로 끝난다 — 셸이 기대하는 그 값이다.
24
+ *
25
+ * 다만 **남이 그 신호를 맡고 있으면 안 건드린다.** 한 방 실행은 SIGINT 로 턴만 끊고 살아
26
+ * 있어야 하고(oneshot.js), 대화 화면의 Ctrl+C 는 readline 이 따로 받는다. 우리 것 말고 다른
27
+ * 손이 달려 있으면 그쪽 뜻이 먼저다 — 남이 맡은 신호로 결국 끝나면 그때는 'exit' 그물이 거둔다.
28
+ */
29
+
30
+ /** 신호로 끝날 때 부를 것들. 저마다 제 'exit' 그물과 같은 함수를 적는다. */
31
+ const 거둘것 = new Set();
32
+
33
+ /** 신호로 끝날 때 이것도 부른다. 같은 함수를 두 번 적어도 한 번만 돈다. */
34
+ export function 신호에거두기(손) { 거둘것.add(손); return 손; }
35
+
36
+ /** 검사가 본다 — 이 손이 적혀 있나. */
37
+ export function 거둘것에있나(손) { return 거둘것.has(손); }
38
+
39
+ /**
40
+ * 치운 다음 그 신호로 진짜 죽어야 하나.
41
+ *
42
+ * 갈림을 함수로 빼 둔 까닭: 윈도우에서는 이 길을 통째로 밟아 볼 수가 없다
43
+ * (SIGTERM 을 보내면 손이 돌기도 전에 Node 가 죽인다). 그런데 갈림 자체가
44
+ * 틀리면 유닉스에서 한 방 실행의 Ctrl+C 가 턴만 끊는 대신 프로그램을 죽인다 —
45
+ * 어느 판에서든 재어 볼 수 있어야 하는 자리다.
46
+ *
47
+ * @param 셈 이 신호에 달린 손의 수. 우리 것 하나만이면 우리가 마지막이다.
48
+ */
49
+ export function 다시보낼까(신호, 셈 = process.listenerCount(신호)) {
50
+ return Number(셈) <= 1;
51
+ }
52
+
53
+ /** 적힌 것을 다 부른다. 하나가 던져도 나머지는 부른다 — 끝나는 길이다. */
54
+ function 모두거두기() {
55
+ for (const 손 of 거둘것) { try { 손(); } catch { /* 끝나는 중이라 할 수 있는 게 없다 */ } }
56
+ }
57
+
58
+ for (const 신호 of ['SIGINT', 'SIGTERM']) {
59
+ const 손 = () => {
60
+ /*
61
+ * 갈림을 **거두기보다 먼저** 본다. 거두고 나서 갈랐었다 — 그래서 대화 화면의 Ctrl+C 가
62
+ * 턴만 끊고 세션은 사는데 언어 서버는 전부 죽고 풀도 비었다(2.0.0 6회차 사냥).
63
+ */
64
+ if (!다시보낼까(신호)) return; // 남이 맡고 있다 — 그쪽 뜻이 먼저다
65
+ 모두거두기();
66
+ process.removeListener(신호, 손);
67
+ try { process.kill(process.pid, 신호); } catch { process.exit(신호 === 'SIGINT' ? 130 : 143); }
68
+ };
69
+ process.on(신호, 손);
70
+ }
package/src/report.js CHANGED
@@ -109,7 +109,7 @@ const VERDICT_STYLE = {
109
109
  };
110
110
 
111
111
  /** 판정을 사람 말로. 모르는 값이면 그대로 돌려준다 — 지어내지 않는다. */
112
- export function verdictName(level) {
112
+ function verdictName(level) {
113
113
  return VERDICT_NAME[level] ?? String(level ?? '');
114
114
  }
115
115
 
@@ -1,7 +1,8 @@
1
1
  // 감사 로그. 무엇을 언제 어떻게 했는지 전부 남긴다.
2
2
  // 자율 실행을 사내에 설득할 때 이 파일이 근거가 된다.
3
3
  import { join } from 'node:path';
4
- import { appendFileSync, mkdirSync, existsSync, readFileSync, chmodSync, statSync, openSync, readSync, closeSync } from 'node:fs';
4
+ import { appendFileSync, mkdirSync, existsSync, readFileSync } from 'node:fs';
5
+ import { 줄로끝나나, 본인만잠그기 } from '../jsonl.js';
5
6
  import { 가리기 } from './secrets.js';
6
7
  import { 첫이름 } from '../tools/label.js';
7
8
 
@@ -126,25 +127,13 @@ export class Audit {
126
127
  * 개행이 아니면 개행부터 붙인다. 줄마다 재지 않는다 — 그 뒤로는 우리가 적은 줄이다.
127
128
  */
128
129
  #줄끝봤나 = false;
129
- #줄로끝나나() {
130
- let fd = null;
131
- try {
132
- const 크기 = statSync(this.file).size;
133
- if (!크기) return true;
134
- fd = openSync(this.file, 'r');
135
- const 한바이트 = Buffer.alloc(1);
136
- readSync(fd, 한바이트, 0, 1, 크기 - 1);
137
- return 한바이트[0] === 0x0a;
138
- } catch { return true; } // 없거나 못 읽으면 붙일 반쪽도 없다
139
- finally { if (fd != null) { try { closeSync(fd); } catch { /* 닫다 터져도 적기는 한다 */ } } }
140
- }
141
130
 
142
131
  write(kind, data) {
143
132
  const rec = { at: new Date().toISOString(), session: this.session, kind, ...data };
144
133
  try {
145
134
  let 줄 = JSON.stringify(rec) + '\n';
146
135
  if (!this.#줄끝봤나) {
147
- if (!this.#줄로끝나나()) 줄 = '\n' + 줄;
136
+ if (!줄로끝나나(this.file)) 줄 = '\n' + 줄;
148
137
  this.#줄끝봤나 = true;
149
138
  }
150
139
  appendFileSync(this.file, 줄, 'utf8');
@@ -180,11 +169,7 @@ export class Audit {
180
169
  #잠그기() {
181
170
  if (this.#잠갔나) return;
182
171
  this.#잠갔나 = true;
183
- // 윈도우에서 chmod 는 아무 일도 안 하고 **성공한다.** 그 성공을 적으면 위 머리말이 금지한
184
- // 「잠근 척」 이다 — agent/store.js 와 같이 안 걸었다고 적는다.
185
- if (process.platform === 'win32') { this.잠금 = { 못함: 'windows' }; return; }
186
- try { chmodSync(this.file, 0o600); this.잠금 = { 모드: 0o600 }; }
187
- catch (err) { this.잠금 = { 못함: err?.code ?? String(err) }; }
172
+ this.잠금 = 본인만잠그기(this.file);
188
173
  }
189
174
 
190
175
  /** 기록이 새고 있나. 새고 있으면 `{수, 까닭}`, 멀쩡하면 null. */
@@ -49,7 +49,7 @@ import { isOffline } from './network.js';
49
49
  import { 가리기 } from './secrets.js';
50
50
 
51
51
  /** 기다려 주는 시간. 사내 로그인은 브라우저를 열고 사람을 기다린다. */
52
- export const 기본기다림 = 180000;
52
+ const 기본기다림 = 180000;
53
53
 
54
54
  /** 수명을 안 알려 줄 때 몇 초짜리로 볼까. 짧게 잡는다 — 늦게 아는 것보다 낫다. */
55
55
  export const 기본수명 = 3300;
@@ -1,9 +1,9 @@
1
1
  // 자율 실행의 울타리.
2
2
  // 승인 프롬프트를 안 쓰는 대신 (1) 작업 범위 밖은 못 건드리고
3
3
  // (2) 되돌릴 수 없는 명령만 막는다. 나머지는 전부 통과시킨다.
4
- import { resolve, relative, isAbsolute, sep, dirname, basename } from 'node:path';
5
- import { realpathSync, existsSync } from 'node:fs';
6
- import { homedir } from 'node:os';
4
+ import { resolve, relative, isAbsolute, sep, dirname, basename, win32, posix } from 'node:path';
5
+ import { realpathSync, existsSync, readFileSync } from 'node:fs';
6
+ import { homedir, userInfo } from 'node:os';
7
7
  import { 내부살림 } from '../tools/fsutil.js';
8
8
 
9
9
  export class ScopeError extends Error {}
@@ -110,8 +110,28 @@ export function makeScope(root) {
110
110
  // Git Bash 꼴 절대 경로(/c/Users/…)는 윈도우에서 C:\Users\… 다. 안 풀면 지금
111
111
  // 드라이브 밑의 \c\Users\… 로 읽혀, 안에 있는 파일이 '범위 밖' 으로 막힌다.
112
112
  const 경로 = MSYS풀기(p);
113
- const abs = isAbsolute(경로) ? resolve(경로) : resolve(base, 경로);
114
- if (밖(base, abs)) throw new ScopeError(`작업 범위 밖입니다: ${p}\n 범위: ${base}`);
113
+ let abs = isAbsolute(경로) ? resolve(경로) : resolve(base, 경로);
114
+ /*
115
+ * ── 글자로는 밖인데 **같은 자리를 다르게 적은 것** (2.0.2 · A5) ──────────────
116
+ *
117
+ * 윈도우는 한 폴더를 두 이름으로 적는다 — `C:\Users\RUNNER~1\…`(8.3) 와
118
+ * `C:\Users\runneradmin\…`. 뿌리를 짧은 이름으로 들고 있는데 모델이 긴 이름
119
+ * 절대경로를 주면(셸의 pwd · 오류 글 · 딴 도구의 답에서 베낀 것), 여기가 글자로만
120
+ * 견줘 **같은 폴더의 파일을 「작업 범위 밖」 으로** 막았다. 재 봤다 —
121
+ * `DEEL_A~1` 뿌리에서 `deel_a5_longfoldername_…\b.txt` 쓰기가 막혔다.
122
+ *
123
+ * 그래서 글자로 밖이면 한 번 더 **실제 자리끼리** 견준다. 실제로도 밖이면 그대로
124
+ * 막는다. 실제로는 안이면 받아 들이되, 돌려주는 경로는 **뿌리가 적힌 꼴로** 다시
125
+ * 붙인다 — 되돌리기 기록(rel)·화면 경로(show)·커밋이 다 뿌리의 꼴로 견주기 때문이다.
126
+ * 긴 이름 그대로 넘기면 여기만 지나고 아래에서 같은 병이 다시 난다.
127
+ */
128
+ if (밖(base, abs)) {
129
+ const 실제 = 진짜자리(abs);
130
+ const 뿌리실제 = 진짜뿌리();
131
+ if (밖(뿌리실제, 실제)) throw new ScopeError(`작업 범위 밖입니다: ${p}\n 범위: ${base}`);
132
+ const 안쪽 = relative(같은꼴(뿌리실제), 같은꼴(실제));
133
+ abs = 안쪽 ? resolve(base, 안쪽) : base;
134
+ }
115
135
  // 글자로는 안인데 링크를 따라가면 밖인 자리. 정션 하나로 울타리가 열린다.
116
136
  //
117
137
  // 여기 견주기도 한 모양으로 맞춘다. 맥에서 realpathSync 는 디스크에 있는
@@ -405,8 +425,32 @@ const BLOCKED = [
405
425
  *
406
426
  * 감싸는 낱말 앞에도 자리를 받고, 셸 이름 앞의 자리는 따옴표 안이면 빈칸도
407
427
  * 받는다. 끝은 낱말째여야 한다 — 그래야 `fish.py` 나 `hash.sh` 를 안 문다.
428
+ *
429
+ * ── 앞머리 인자 자리가 **되돌이(ReDoS)** 였다 ─────────────────────────
430
+ *
431
+ * `\S+=\S+` 로 두었더니 `!==!` 한 조각을 등호 자리마다 다르게 쪼갤 수
432
+ * 있었다. 그런 조각이 늘어선 글이 오면 갈래가 두 배씩 불어나 이 울타리
433
+ * 하나가 프로그램을 멈춰 세운다 — **막으려고 둔 자리가 막히는** 꼴이다.
434
+ * 여기 들어오는 것은 모델이 지어낸 명령줄이라 길이도 안 정해져 있다.
435
+ *
436
+ * 인자 꼴을 **하나씩 적어 두는 것을 그만둔다.** 적어 둔 꼴만 받으면
437
+ * 안 적은 꼴에서 통째로 빠져나간다 — 2차 눈이 그 자리를 찾아 줬다.
438
+ *
439
+ * curl … | sudo -E bash 막힘
440
+ * curl … | sudo -u root bash 통과 ← `root` 가 어느 꼴도 아니다
441
+ *
442
+ * `-u root` 처럼 **값이 떨어진 옵션**이 오면 거기서 맞추기가 멈춘다.
443
+ * 이런 옵션은 얼마든지 있다(`-C dir` · `--user x`). 꼴을 늘려 쫓아가는
444
+ * 대신 **셸이 아닌 낱말은 무엇이든 건너뛴다** 로 뒤집는다.
445
+ *
446
+ * 이러면 ReDoS 도 같이 없어진다 — `\S+\s+` 는 빈칸으로 쪼개는 길이
447
+ * 하나뿐이고, 앞에 붙인 부정 전방탐색은 자리를 안 먹는다. 여러 갈래를
448
+ * 늘어놓아 생기던 겹침 자체가 사라진다.
449
+ *
450
+ * 감싸는 낱말(`sudo`·`env`…)은 **그대로 못 박아 둔다.** 그것까지 풀면
451
+ * `curl … | grep foo bash` 같은 것이 걸려 거짓 경고가 된다.
408
452
  */
409
- re: /\b(?:curl|wget|fetch|aria2c)\b[^|]*\|\s*(?:["']?(?:[^|"'\s]*[\\/])?(?:sudo|doas|env|nohup|command)["']?\s+(?:-\S+\s+|\S+=\S+\s+)*)*["']?(?:[^|"'\n]*[\\/])?(?:ba|z|k|da|c|tc|fi)?sh(?:\.exe)?(?=["'\s]|$)/i,
453
+ re: /\b(?:curl|wget|fetch|aria2c)\b[^|]*\|\s*(?:["']?(?:[^|"'\s]*[\\/])?(?:sudo|doas|env|nohup|command)["']?\s+(?:(?!["']?(?:[^|"'\n]*[\\/])?(?:ba|z|k|da|c|tc|fi)?sh(?:\.exe)?(?=["'\s]|$))\S+\s+)*)?["']?(?:[^|"'\n]*[\\/])?(?:ba|z|k|da|c|tc|fi)?sh(?:\.exe)?(?=["'\s]|$)/i,
410
454
  why: '받은 스크립트를 그대로 실행합니다',
411
455
  },
412
456
  {
@@ -536,6 +580,36 @@ export function checkPaths(cmd, scope) {
536
580
  return true;
537
581
  }
538
582
 
583
+ /*
584
+ * ── `~이름` 이 가리키는 집 (2.0.2 · 388) ────────────────────────────────────
585
+ *
586
+ * 「내 집 옆자리」 로만 풀었다. 그런데 root 로 돌면 내 집이 `/root` 라 옆자리가 **뿌리**다 —
587
+ * `~admin` 이 `/admin` 이 됐다. 셸은 passwd 의 `/home/admin` 으로 가는데. 작업 폴더가 마침
588
+ * `/admin` 이면 남의 집을 「범위 안」 으로 읽는다.
589
+ *
590
+ * 유닉스에서는 passwd 에 적힌 집을 먼저 본다(셸이 보는 그 자리). 없으면(맥의 디렉터리 서비스 ·
591
+ * 윈도) 예전처럼 내 집 옆자리로 풀되, 그 옆자리가 뿌리면 관례 자리(`/home`)로 간다. 내 이름이면
592
+ * 내 집이다.
593
+ */
594
+ export function 남의집(이름, { 집 = homedir(), 나 = null, passwd = null, platform = process.platform } = {}) {
595
+ let 내이름 = 나;
596
+ if (내이름 == null) { try { 내이름 = userInfo().username; } catch { 내이름 = ''; } }
597
+ if (이름 === 내이름) return 집;
598
+ if (platform !== 'win32') {
599
+ let 글 = passwd;
600
+ if (글 == null) { try { 글 = readFileSync('/etc/passwd', 'utf8'); } catch { 글 = ''; } }
601
+ for (const 줄 of String(글).split('\n')) {
602
+ const 칸 = 줄.split(':');
603
+ if (칸[0] === 이름 && 칸[5]) return 칸[5];
604
+ }
605
+ }
606
+ // 판을 넘겨받았으면 경로 셈도 그 판으로 한다. 리눅스에서 윈도 판을 물으면 `C:\Users\me` 가
607
+ // 상대경로로 읽혀 작업 폴더 밑이 됐다(검사만 그 판을 만든다 — 실제로는 늘 이 PC 의 판이다).
608
+ const 길 = platform === 'win32' ? win32 : posix;
609
+ const 옆 = 길.dirname(집);
610
+ return 길.resolve(옆 === 길.parse(옆).root ? 길.join(옆, 'home') : 옆, 이름);
611
+ }
612
+
539
613
  // 셸이 알아서 풀어 주는 자리표. 우리도 같이 풀어야 실제로 닿는 곳을 본다.
540
614
  function 자리표풀기(s) {
541
615
  return s
@@ -558,7 +632,7 @@ function 자리표풀기(s) {
558
632
  * 남의 집은 어느 쪽으로 풀든 내 작업 폴더 밖이다. 이름은 글자·밑줄로 시작할 때만 받는다
559
633
  * (`~1` · `~+` 은 셸의 다른 뜻). 가운데 물결(`./~backup` · `PROGRA~1`)은 `^` 에 안 닿는다.
560
634
  */
561
- .replace(/^~([A-Za-z_][\w.-]*)(?=[/\\]|$)/, (전체, 이름) => resolve(dirname(homedir()), 이름))
635
+ .replace(/^~([A-Za-z_][\w.-]*)(?=[/\\]|$)/, (전체, 이름) => 남의집(이름))
562
636
  /*
563
637
  * 파워셸은 `$env:이름` 으로 적는다.
564
638
  *
@@ -55,7 +55,7 @@ export const 제한기본 = 15000;
55
55
  /** 한 훅이 뱉을 수 있는 글의 최대. 넘으면 자르고 잘랐다고 말한다. */
56
56
  export const 글최대 = 16 * 1024;
57
57
  /** 한 자리에서 돌릴 수 있는 훅 수. 이보다 많으면 설정이 잘못된 것이다. */
58
- export const 훅최대 = 32;
58
+ const 훅최대 = 32;
59
59
 
60
60
  /**
61
61
  * 훅을 걸 수 있는 자리.
@@ -222,7 +222,7 @@ export function 잠그기(글, { 프로필id = null } = {}) {
222
222
 
223
223
  if (process.platform === 'win32') {
224
224
  const r = 파워셸실행(잠그는스크립트, Buffer.from(값, 'utf8').toString('base64'));
225
- if (r.ok && r.out) return `dpapi:${r.out}`;
225
+ if (r.ok && r.out) return 됐다(`dpapi:${r.out}`);
226
226
  못잠근까닭 = r.err || '파워셸이 잠그기를 못 마쳤습니다';
227
227
  해보고졌나 = true;
228
228
  return null;
@@ -230,7 +230,7 @@ export function 잠그기(글, { 프로필id = null } = {}) {
230
230
  if (process.platform === 'darwin') {
231
231
  const 이름 = 키체인이름(프로필id);
232
232
  const r = 키체인넣기(값, 이름);
233
- if (r.ok) return `keychain:${이름}`;
233
+ if (r.ok) return 됐다(`keychain:${이름}`);
234
234
  못잠근까닭 = r.err || 'security 가 열쇠를 못 넣었습니다';
235
235
  해보고졌나 = true;
236
236
  return null;
@@ -282,7 +282,22 @@ let 못잠근까닭 = null;
282
282
  */
283
283
  let 해보고졌나 = false;
284
284
 
285
- /** 마지막으로 잠그다 실패한 까닭. 한 번도 실패한 적이 없으면 null. */
285
+ /*
286
+ * ── 되고 나면 옛 실패를 **내려놓는다** (2.0.2 · 530) ──────────────────────
287
+ *
288
+ * 잠그기가 한 번 지면 그 까닭이 프로세스 내내 남았다. 뒤에 같은 판에서 잠그기가 되어도
289
+ * (파워셸이 잠깐 막혔다 풀림 · 키체인이 잠겼다 열림) 보관방식() 은 평문 값을 보면 여전히
290
+ * 「잠그려다 실패했습니다」 라고 적었다 — 지금은 되는 장치를 안 된다고 말하는 것이다.
291
+ * 되면 지운다. config.js 가 「몇 개를 왜 못 잠갔나」 에 쓰는 까닭은 그쪽이 **진 순간에**
292
+ * 받아 둔다(잠금옮기기).
293
+ */
294
+ function 됐다(값) {
295
+ 못잠근까닭 = null;
296
+ 해보고졌나 = false;
297
+ return 값;
298
+ }
299
+
300
+ /** 마지막으로 잠그다 실패한 까닭. 한 번도 실패한 적이 없으면(또는 그 뒤로 됐으면) null. */
286
301
  export function 잠그다실패한까닭() { return 못잠근까닭; }
287
302
 
288
303
  /** 사람이 스스로 껐나. 껐으면 이 판에서는 **아무것도 안 해 본다.** */
@@ -116,7 +116,7 @@ function 이름마디들(이름) {
116
116
  const 자격든주소 = /^[a-z][a-z0-9+.-]*:\/\/[^\s/?#@:]*:[^\s/?#@]+@/i;
117
117
 
118
118
  /** 이 값이 자격이 든 주소인가. 이름으로는 못 거르는 DATABASE_URL 같은 것을 거른다. */
119
- export function 비밀값인가(값) {
119
+ function 비밀값인가(값) {
120
120
  return typeof 값 === 'string' && 자격든주소.test(값.trim());
121
121
  }
122
122
 
@@ -1,7 +1,8 @@
1
1
  // 되돌리기. 승인 프롬프트를 안 쓰는 대신 이게 안전망이다.
2
2
  // 파일을 고치기 전에 항상 이전 내용을 떠 놓고, /undo 로 턴 단위로 되돌린다.
3
3
  import { join, dirname, basename, resolve, relative, isAbsolute, sep } from 'node:path';
4
- import { readFileSync, writeFileSync, existsSync, mkdirSync, rmSync, appendFileSync, statSync, chmodSync, renameSync, realpathSync, openSync, readSync, closeSync } from 'node:fs';
4
+ import { readFileSync, writeFileSync, existsSync, mkdirSync, rmSync, rmdirSync, appendFileSync, statSync, renameSync, realpathSync } from 'node:fs';
5
+ import { 줄로끝나나, 본인만잠그기 } from '../jsonl.js';
5
6
  import { looksBinary } from '../tools/encoding.js';
6
7
  import { 살림폴더만들기 } from './audit.js';
7
8
  import { 말 } from '../i18n/index.js';
@@ -86,6 +87,19 @@ export class History {
86
87
  this.잠금 = null;
87
88
  }
88
89
 
90
+ /** 이 경로의 조상 가운데 **아직 없는** 폴더들(뿌리 기준 상대 경로, 깊은 것부터). 뿌리 밖으로는 안 올라간다. */
91
+ #없는폴더들(abs) {
92
+ const 들 = [];
93
+ let d = dirname(resolve(String(abs)));
94
+ for (let i = 0; i < 64 && !existsSync(d); i++) {
95
+ const 상대 = this.#상대(d);
96
+ if (상대 === null) break;
97
+ 들.push(상대);
98
+ d = dirname(d);
99
+ }
100
+ return 들;
101
+ }
102
+
89
103
  /** 새 기록에 적을 상대 경로. 뿌리 밖이면 null — 그런 기록은 되돌릴 때 안 받는다. */
90
104
  #상대(abs) {
91
105
  const a = resolve(String(abs));
@@ -120,23 +134,11 @@ export class History {
120
134
  * 개행으로 끝내므로 한 번 본 것이 계속 맞다.
121
135
  */
122
136
  #줄끝봤나 = false;
123
- #줄로끝나나() {
124
- let fd = null;
125
- try {
126
- const 크기 = statSync(this.file).size;
127
- if (!크기) return true;
128
- fd = openSync(this.file, 'r');
129
- const 한바이트 = Buffer.alloc(1);
130
- readSync(fd, 한바이트, 0, 1, 크기 - 1);
131
- return 한바이트[0] === 0x0a;
132
- } catch { return true; } // 없거나 못 읽으면 붙일 반쪽도 없다
133
- finally { if (fd != null) { try { closeSync(fd); } catch { /* 닫다 터져도 적기는 한다 */ } } }
134
- }
135
137
 
136
138
  #덧붙이기(rec) {
137
139
  let 줄 = 적을줄(rec) + '\n';
138
140
  if (!this.#줄끝봤나) {
139
- if (!this.#줄로끝나나()) 줄 = '\n' + 줄;
141
+ if (!줄로끝나나(this.file)) 줄 = '\n' + 줄;
140
142
  this.#줄끝봤나 = true;
141
143
  }
142
144
  appendFileSync(this.file, 줄, 'utf8');
@@ -166,11 +168,7 @@ export class History {
166
168
  #잠그기() {
167
169
  if (this.#잠갔나) return;
168
170
  this.#잠갔나 = true;
169
- // 윈도우에서 chmod 는 아무 일도 안 하고 **성공한다.** 그 성공을 적으면 위 머리말이 금지한
170
- // 「잠근 척」 이다 — agent/store.js 와 같이 안 걸었다고 적는다.
171
- if (process.platform === 'win32') { this.잠금 = { 못함: 'windows' }; return; }
172
- try { chmodSync(this.file, 0o600); this.잠금 = { 모드: 0o600 }; }
173
- catch (err) { this.잠금 = { 못함: err?.code ?? String(err) }; }
171
+ this.잠금 = 본인만잠그기(this.file);
174
172
  }
175
173
 
176
174
  /**
@@ -236,6 +234,22 @@ export class History {
236
234
  * 그림·문서가 /undo 뒤에도 남았다. 이 표가 있으면 정말 없던 자리라 바이너리여도 지운다.
237
235
  */
238
236
  if (없던) rec.없던 = true;
237
+ /*
238
+ * ── 이 파일과 **같이 생길 폴더**도 적는다 (2.0.2 · B3) ─────────────────────
239
+ *
240
+ * 폴더를 통째로 옮긴 판(`dirA` → `dirB`)을 되돌리면 파일은 `dirA` 로 돌아오고
241
+ * `dirB/f.txt` 는 「원래 없던 파일」 로 지워지는데, **빈 `dirB` 가 남았다.** 되돌린
242
+ * 뒤의 폴더가 되돌리기 전과 다르다. 그 폴더가 이번에 생겼다는 것을 아무 데도
243
+ * 안 적어서 지울 근거가 없었다.
244
+ *
245
+ * 원래 있던 빈 폴더에 파일을 만든 판과 가르려면 **뜰 때** 봐야 한다 — 되돌릴 때는
246
+ * 둘 다 「빈 폴더」 라 구별이 안 된다. 그래서 여기서 아직 없는 조상 폴더를 깊은
247
+ * 것부터 적어 두고, 되돌릴 때 그 폴더가 **비었을 때만** 지운다.
248
+ */
249
+ if (없던) {
250
+ const 새폴더 = this.#없는폴더들(absPath);
251
+ if (새폴더.length) rec.새폴더 = 새폴더;
252
+ }
239
253
  // 작업 폴더 기준 자리. 폴더를 옮기거나 복사해도 이 이력은 **그 폴더**를 되돌린다 (위 머리말).
240
254
  const 상대 = this.#상대(absPath);
241
255
  if (상대 !== null) rec.rel = 상대;
@@ -618,6 +632,16 @@ export class History {
618
632
  }
619
633
  rmSync(path, { force: true });
620
634
  restored.push({ path, how: 말('undo.wayDeleted'), ok: true });
635
+ // 그 파일과 같이 생긴 폴더가 이제 비었으면 거둔다 (snapshot 의 새폴더 머리말). rmdirSync 는 **빈
636
+ // 폴더만** 지운다 — 그 뒤에 누가 넣은 것이 있으면 ENOTEMPTY 로 멈추고, 폴더가 아니면 ENOTDIR 다.
637
+ // 이력은 저장소에 딸려 올 수 있으니(파일 머리말) 폴더 자리도 밖·링크 너머면 안 건드린다.
638
+ // 파일이 아니라서 되돌린 수에는 안 센다.
639
+ for (const 상대 of rec.새폴더 ?? []) {
640
+ const d = this.#자리풀기(null, 상대);
641
+ if (!d || 링크밖(d)) break;
642
+ try { rmdirSync(d); } catch { break; }
643
+ restored.push({ path: d, how: '빈 폴더 지움 (이번에 새로 생긴 폴더)', ok: true, 폴더: true });
644
+ }
621
645
  } else {
622
646
  /*
623
647
  * 담고 있던 폴더가 없어졌을 수 있다 — Move 로 폴더째 옮긴 경우다.
@@ -710,7 +734,7 @@ export class History {
710
734
  // 이력에서 못 읽은 줄 수. 그만큼은 되돌릴 길이 애초에 없었다.
711
735
  깨진줄: this.깨진줄,
712
736
  // 화면·감사기록이 쓰는 수. **진짜로 되돌아간 것만** 센다.
713
- 되돌린수: restored.filter((x) => x.ok === true).length,
737
+ 되돌린수: restored.filter((x) => x.ok === true && !x.폴더).length,
714
738
  못한것: restored.filter((x) => x.ok === false),
715
739
  turns: turns.length,
716
740
  turnIds: turns.slice(),