deel-local-cli 1.8.0 → 1.10.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.
Files changed (49) hide show
  1. package/README.ko.md +1166 -1182
  2. package/README.md +1223 -1232
  3. package/bin/deel.js +55 -12
  4. package/package.json +3 -3
  5. package/src/acp/serve.js +40 -3
  6. package/src/agent/compact.js +314 -296
  7. package/src/agent/effort.js +27 -7
  8. package/src/agent/evidence.js +2 -0
  9. package/src/agent/filemem.js +141 -0
  10. package/src/agent/grade.js +51 -1
  11. package/src/agent/loop.js +202 -35
  12. package/src/agent/session.js +121 -5
  13. package/src/agent/store.js +186 -9
  14. package/src/agent/threads.js +26 -1
  15. package/src/backend/adapter.js +201 -18
  16. package/src/backend/learn.js +24 -0
  17. package/src/backend/mcp.js +96 -5
  18. package/src/backend/probe.js +131 -61
  19. package/src/backend/quota.js +29 -5
  20. package/src/backend/toolfit.js +325 -0
  21. package/src/commands.js +107 -27
  22. package/src/config.js +47 -2
  23. package/src/i18n/en.js +56 -2
  24. package/src/i18n/index.js +18 -0
  25. package/src/i18n/ja.js +58 -2
  26. package/src/i18n/ko.js +103 -2
  27. package/src/i18n/zh.js +58 -2
  28. package/src/lsp/client.js +49 -5
  29. package/src/oneshot.js +81 -3
  30. package/src/pack/sbom.js +30 -4
  31. package/src/pack/selfpack.js +25 -9
  32. package/src/pack/sheet.en.js +288 -0
  33. package/src/pack/tar.js +65 -2
  34. package/src/plugins/manage.js +46 -9
  35. package/src/repl.js +264 -35
  36. package/src/safety/audit.js +92 -8
  37. package/src/safety/authcmd.js +14 -3
  38. package/src/safety/guard.js +143 -0
  39. package/src/safety/keystore.js +62 -39
  40. package/src/safety/undo.js +24 -6
  41. package/src/tools/index.js +239 -34
  42. package/src/tools/jobs.js +158 -29
  43. package/src/tools/verify.js +33 -4
  44. package/src/tools/webfetch.js +92 -9
  45. package/src/ui/md.js +201 -5
  46. package/src/ui/motion.js +0 -1
  47. package/src/ui/pastechip.js +50 -3
  48. package/src/ui/pick.js +115 -0
  49. package/src/ui/screen.js +23 -3
@@ -90,13 +90,24 @@ export function 지금상태() {
90
90
  * @returns {{명령: string, 수명: number, 곳: '정책'|'설정'}|null}
91
91
  */
92
92
  export function 받기설정(프로필, { 정책값 = null } = {}) {
93
+ /*
94
+ * 영어 이름도 받는다.
95
+ *
96
+ * 설정 파일은 **사람이 손으로 적는 것**이다. `열쇠받기`·`명령`·`수명` 은
97
+ * 한글 자판이 없는 사람에게는 옮겨 적을 수조차 없는 글자다. 화면을 영어로
98
+ * 켤 수 있게 해 놓고 설정만 한글로 두면, 그 사람은 이 기능을 못 쓴다.
99
+ *
100
+ * 코드 안의 이름은 그대로 한국어다 — 그건 이 저장소의 뜻이라 안 바꾼다.
101
+ * 바깥에서 들어오는 이름만 두 벌 받는다 (price.js 의 입력·in·input 과 같다).
102
+ */
93
103
  const 고르기 = (것, 곳) => {
94
- const 명령 = String(것?.명령 ?? '').trim();
104
+ const 명령 = String(것?.명령 ?? 것?.command ?? '').trim();
95
105
  if (!명령) return null;
96
- const 수명 = Number(것?.수명);
106
+ const 수명 = Number(것?.수명 ?? 것?.ttl);
97
107
  return { 명령, 수명: Number.isFinite(수명) && 수명 > 0 ? Math.floor(수명) : 기본수명, 곳 };
98
108
  };
99
- return 고르기(정책값?.열쇠받기, '정책') ?? 고르기(프로필?.열쇠받기, '설정');
109
+ const 칸 = (x) => x?.열쇠받기 ?? x?.authCommand ?? null;
110
+ return 고르기(칸(정책값), '정책') ?? 고르기(칸(프로필), '설정');
100
111
  }
101
112
 
102
113
  /**
@@ -193,9 +193,15 @@ export function checkCommand(cmd) {
193
193
  */
194
194
  export function checkPaths(cmd, scope) {
195
195
  if (!scope) return true;
196
+ // 폴더를 옮기고 나면 뒤 낱말은 전부 맨 이름이라 아래 검사에 안 걸린다.
197
+ // 그래서 옮기는 것부터 먼저 본다 (폴더옮김검사 머리말).
198
+ const 옮긴자리 = 폴더옮김검사(String(cmd), scope);
196
199
  for (const t of 경로낱말(String(cmd))) {
197
200
  // 자료가 아닌 자리는 울타리를 안 묻는다 (봐주는자리 머리말).
198
201
  if (봐주는자리(t)) continue;
202
+ // cd 가 가리킨 자리는 위에서 **옮겨 간 자리 기준**으로 이미 봤다. 여기서
203
+ // 또 보면 뿌리 기준으로 풀려서 `cd src/a && cd ../..` 가 밖으로 읽힌다.
204
+ if (옮긴자리.has(t)) continue;
199
205
  let abs;
200
206
  try { abs = scope.resolve(t); }
201
207
  catch (e) {
@@ -369,6 +375,143 @@ export function 경로낱말(cmd) {
369
375
  return out;
370
376
  }
371
377
 
378
+ /*
379
+ * ── 폴더를 옮기면 그 뒤로는 볼 글자가 없다 ─────────────────────────────
380
+ *
381
+ * 위 경로낱말() 은 **경로처럼 생긴 것**만 고른다 — 빗금이 있거나 드라이브로
382
+ * 시작하는 것. 그래서 `cd .deel` 다음에 오는 `config.json` 은 낱말 후보로도
383
+ * 안 뽑혔고, 아무 검사도 안 받았다. 화면에서는 이랬다 —
384
+ *
385
+ * ▶ Bash(type .deel\config.json) └ 막힘 (열쇠가 든 파일입니다)
386
+ * ▶ Bash(cd .deel && type config.json) └ 그냥 읽혔다
387
+ *
388
+ * 같은 파일인데 한 줄은 막고 한 줄은 통과다. 그러면 잠근 뜻이 없을 뿐 아니라
389
+ * 더 나쁘다 — 시스템 프롬프트와 화면은 「막혀 있다」 고 말하는데 사실이
390
+ * 아니게 된다. 그 말이 거짓이 되면 모델은 그 말을 안 믿고, 사용자는 울타리가
391
+ * 있다고 믿는다. (checkPaths 머리말에 적어 둔 것과 같은 까닭이다.)
392
+ *
393
+ * 그래서 **들어가는 것 자체**를 위반으로 본다. 들어간 뒤에는 우리가 볼 수
394
+ * 있는 글자가 남지 않아서, 거기서 막을 방법이 아예 없기 때문이다.
395
+ *
396
+ * 여기서도 넓히지 않는다 — 안에서 도는 cd 는 전부 그대로 돈다. 모델이 하는
397
+ * 일의 절반이 `cd 하위폴더 && 무엇` 이라, 그걸 막으면 도구를 못 쓴다.
398
+ */
399
+ const 폴더옮김 = /^(cd|chdir|pushd|sl|set-location|push-location)$/i;
400
+ const 되돌리기 = /^(popd|pop-location)$/i;
401
+ /** 셸을 한 겹 씌워 부르는 것들. 안 들여다보면 `sh -c "cd .deel …"` 로 그냥 지나간다. */
402
+ const 셸꼴 = /^(?:.*[/\\])?(bash|sh|zsh|dash|ksh|fish|cmd|powershell|pwsh)(\.exe)?$/i;
403
+
404
+ /** 명령을 실행 단위로 자른다. 따옴표 안의 `;` 는 글자지 이음매가 아니다. */
405
+ function 마디들(cmd) {
406
+ const 마디 = [];
407
+ let 지금 = '';
408
+ let 따옴 = null;
409
+ for (let i = 0; i < cmd.length; i++) {
410
+ const c = cmd[i];
411
+ if (따옴) { 지금 += c; if (c === 따옴) 따옴 = null; continue; }
412
+ if (c === '"' || c === "'") { 따옴 = c; 지금 += c; continue; }
413
+ if (c === '\n' || c === ';') { 마디.push(지금); 지금 = ''; continue; }
414
+ if (c === '&' || c === '|') {
415
+ 마디.push(지금); 지금 = '';
416
+ if (cmd[i + 1] === c) i++; // && · ||
417
+ continue;
418
+ }
419
+ 지금 += c;
420
+ }
421
+ 마디.push(지금);
422
+ return 마디;
423
+ }
424
+
425
+ /** 한 마디를 낱말로. 따옴표는 벗기되, 벗겼다는 것은 남긴다(셸 한 겹을 알아보려고). */
426
+ function 낱말들(마디) {
427
+ const out = [];
428
+ const re = /"([^"]*)"|'([^']*)'|[^\s]+/g;
429
+ let m;
430
+ while ((m = re.exec(마디))) {
431
+ const 안 = m[1] ?? m[2];
432
+ out.push({ 글: 안 ?? m[0], 쌌나: 안 != null });
433
+ }
434
+ return out;
435
+ }
436
+
437
+ /**
438
+ * cd 가 어디로 가라는 것인가. 모르면 null — 모르는 것을 막으면 안 된다.
439
+ *
440
+ * 옵션은 건너뛴다: 파워셸의 `-Path`·`-LiteralPath`, 윈도우 cmd 의 `/d`.
441
+ * `/d` 만 콕 집어 본다 — `/etc` 도 빗금으로 시작하지만 그건 진짜 갈 자리다.
442
+ */
443
+ function 갈자리(뒤) {
444
+ for (const w of 뒤) {
445
+ if (!w.글) continue;
446
+ if (w.글.startsWith('-')) continue;
447
+ if (/^\/d$/i.test(w.글)) continue;
448
+ return w.글;
449
+ }
450
+ return null;
451
+ }
452
+
453
+ /**
454
+ * 폴더를 옮기는 명령을 따라가며, 옮겨 간 자리가 울타리 안인지 본다.
455
+ *
456
+ * @returns {Set<string>} 여기서 이미 본 cd 목적지들. 부르는 쪽은 이걸 두 번
457
+ * 보지 않는다 — 뿌리 기준으로 다시 풀면 `cd src/a && cd ../..` 가 밖이 된다.
458
+ */
459
+ function 폴더옮김검사(cmd, scope, 시작 = null, 깊이 = 0, 본것 = new Set()) {
460
+ let 여기 = 시작 ?? scope.root;
461
+ const 쌓은것 = [];
462
+ for (const 마디 of 마디들(cmd)) {
463
+ const 낱말 = 낱말들(마디);
464
+ if (!낱말.length) continue;
465
+ const 첫 = 낱말[0].글;
466
+
467
+ // 셸을 한 겹 씌운 것은 안을 들여다본다. 그 안의 cd 는 딴 살림이라
468
+ // (하위 셸이다) 우리 자리는 안 바뀐다 — 결과만 받고 여기는 그대로 둔다.
469
+ if (깊이 < 2 && 셸꼴.test(첫)) {
470
+ for (const w of 낱말.slice(1)) if (w.쌌나) 폴더옮김검사(w.글, scope, 여기, 깊이 + 1, 본것);
471
+ continue;
472
+ }
473
+ if (되돌리기.test(첫)) { 여기 = 쌓은것.pop() ?? 여기; continue; }
474
+ if (!폴더옮김.test(첫)) continue;
475
+
476
+ const 목적 = 갈자리(낱말.slice(1));
477
+ // 인자 없는 cd(집으로) · `cd -`(직전 자리) 는 어디로 가는지 여기서 모른다.
478
+ // 모르는 것을 막으면 멀쩡한 명령이 죽는다.
479
+ if (목적 == null || 목적 === '-') continue;
480
+ const 풀린 = 자리표풀기(목적);
481
+ // 못 푼 자리표가 남아 있으면(`$BUILD_DIR`) 역시 어디로 갈지 모른다.
482
+ if (/[$%]/.test(풀린)) continue;
483
+
484
+ const 열린 = MSYS풀기(풀린);
485
+ const abs = isAbsolute(열린) ? resolve(열린) : resolve(여기, 열린);
486
+ 본것.add(풀린);
487
+
488
+ // 프로그램이 있는 자리로 들어가는 것은 자료를 만지는 일이 아니다.
489
+ // 푼 값이 아니라 **적힌 글자**로 본다 — 윈도우에서 resolve('/usr/bin') 은
490
+ // `C:\usr\bin` 이 되어 목록에 안 걸린다 (checkPaths 도 낱말로 본다).
491
+ if (봐주는자리(풀린)) { 여기 = abs; continue; }
492
+ try { scope.resolve(abs); }
493
+ catch (e) {
494
+ if (e instanceof ScopeError) {
495
+ throw new ScopeError(`${e.message}\n 이 명령 안에 있습니다: ${cmd.slice(0, 120)}`);
496
+ }
497
+ throw e;
498
+ }
499
+ // `.deel` 폴더 자신은 내부살림() 이 안 잡는다 — 그건 그 **안의 파일**을
500
+ // 보는 검사다. 여기서는 폴더에 발을 들이는 것이 곧 위반이다.
501
+ const 조각 = abs.replace(/\\/g, '/').split('/');
502
+ const 이유 = 조각.includes('.deel')
503
+ ? 'deel 자신의 살림 폴더입니다. 그 안에 게이트웨이 열쇠가 든 config.json 이 있어'
504
+ + ' 들어가지 않습니다 — 들어가면 뒤 명령이 맨 이름만으로 그 파일에 닿습니다.'
505
+ + ' 연결 상태가 궁금하면 사용자에게 /status 를 쳐 보라고 하세요.'
506
+ : 내부살림(abs);
507
+ if (이유) throw new BlockedError(`${이유}\n 이 명령 안에 있습니다: ${cmd.slice(0, 120)}`);
508
+
509
+ if (/^(pushd|push-location)$/i.test(첫)) 쌓은것.push(여기);
510
+ 여기 = abs;
511
+ }
512
+ return 본것;
513
+ }
514
+
372
515
  /**
373
516
  * 변경성 동작은 실패해도 다시 실행하지 않는다 — 두 번 실행되면 사고다. (RPA 에서 얻은 원칙)
374
517
  *
@@ -36,12 +36,25 @@ import { join } from 'node:path';
36
36
  /** 잠긴 값의 꼴. `dpapi:<base64>` · `keychain:<이름>` */
37
37
  const 꼴 = /^(dpapi|keychain):(.*)$/s;
38
38
 
39
- /** 키체인에 넣을 때 쓰는 이름. 사람이 키체인 앱에서 찾아 지울 수 있어야 한다. */
40
- export const 키체인이름 = 'deel-gateway-key';
39
+ /** 키체인에 넣을 때 쓰는 기본 이름. 사람이 키체인 앱에서 찾아 지울 수 있어야 한다. */
40
+ export const 기본키체인이름 = 'deel-gateway-key';
41
41
 
42
- let 마지막탈 = null;
43
- /** 마지막으로 잠그거나 풀다 탈. 화면·심사서가 읽는다. */
44
- export function 열쇠탈() { return 마지막탈; }
42
+ /**
43
+ * 실제로 키체인 이름.
44
+ *
45
+ * 기본값은 **바꾸지 않는다.** 이미 넣어 둔 열쇠를 계속 찾아야 하기 때문이다 —
46
+ * 이름을 갈면 사용자의 열쇠가 키체인에 남은 채로 안 보이게 된다.
47
+ *
48
+ * DEEL_KEYCHAIN_NAME 은 **따로 두고 쓰라고** 있는 자리다. 이름이 고정이라
49
+ * DEEL_HOME 을 임시 폴더로 돌려놓아도 키체인만은 진짜 자리를 썼고, 맥에서
50
+ * 이 저장소의 검사를 돌리면 잠그기/풀기가 **사용자의 진짜 게이트웨이 열쇠를
51
+ * 덮어썼다.** 검사 하나가 사람의 열쇠를 지우는 셈이다. 그래서 검사는 제 이름을
52
+ * 주고 쓰고, 끝나면 제 것만 지운다. 윈도우(DPAPI)는 잠근 덩이가 설정 파일 안에
53
+ * 있어서 이 이름과 상관이 없다.
54
+ */
55
+ export function 키체인이름() {
56
+ return process.env.DEEL_KEYCHAIN_NAME || 기본키체인이름;
57
+ }
45
58
 
46
59
  /** 이 값이 우리가 잠근 것인가. 평문 열쇠와 헷갈리면 안 된다. */
47
60
  export function 잠긴것인가(값) { return 꼴.test(String(값 ?? '')); }
@@ -102,7 +115,7 @@ function 키체인넣기(글) {
102
115
  const 계정 = userInfo().username;
103
116
  // 값 자체는 base64 로 넣는다. 키체인 도구가 줄바꿈·따옴표를 만나면 거기서 끊긴다.
104
117
  const 값 = Buffer.from(글, 'utf8').toString('base64');
105
- const 명령 = `add-generic-password -a ${계정} -s ${키체인이름} -w ${값} -U\n`;
118
+ const 명령 = `add-generic-password -a ${계정} -s ${키체인이름()} -w ${값} -U\n`;
106
119
  마지막인자 = ['security', '-i'];
107
120
  const r = spawnSync('security', ['-i'], { input: 명령, encoding: 'utf8', timeout: 20000 });
108
121
  if (r.error) return { ok: false, err: r.error.message };
@@ -111,8 +124,8 @@ function 키체인넣기(글) {
111
124
 
112
125
  function 키체인읽기() {
113
126
  const 계정 = userInfo().username;
114
- 마지막인자 = ['security', 'find-generic-password', '-a', 계정, '-s', 키체인이름, '-w'];
115
- const r = spawnSync('security', ['find-generic-password', '-a', 계정, '-s', 키체인이름, '-w'], {
127
+ 마지막인자 = ['security', 'find-generic-password', '-a', 계정, '-s', 키체인이름(), '-w'];
128
+ const r = spawnSync('security', ['find-generic-password', '-a', 계정, '-s', 키체인이름(), '-w'], {
116
129
  encoding: 'utf8', timeout: 20000,
117
130
  });
118
131
  if (r.error) return { ok: false, text: '', err: r.error.message };
@@ -133,19 +146,19 @@ export function 쓸수있나() {
133
146
  // 꺼 뒀으면 켜져 있는 척하지 않는다. 이 값을 화면 문구도 그대로 쓰기 때문에,
134
147
  // 여기서 거짓으로 답하면 "다음 저장 때 잠급니다" 라고 해 놓고 안 잠근다.
135
148
  if (process.env.DEEL_KEYSTORE === 'off') {
136
- return { 되나: false, 방식: '파일', 왜: 'DEEL_KEYSTORE=off 로 꺼 두었습니다' };
149
+ return { 되나: false, 방식: '파일', 왜: 'DEEL_KEYSTORE=off 로 꺼 두었습니다', 왜영어: 'turned off with DEEL_KEYSTORE=off' };
137
150
  }
138
151
  if (process.platform === 'win32') {
139
152
  const r = 파워셸실행('[Console]::Out.Write("ok")', '');
140
153
  if (r.ok && r.out === 'ok') return { 되나: true, 방식: 'dpapi', 왜: '' };
141
- return { 되나: false, 방식: '파일', 왜: r.err || '파워셸을 못 돌렸습니다 (정책으로 막혔을 수 있습니다)' };
154
+ return { 되나: false, 방식: '파일', 왜: r.err || '파워셸을 못 돌렸습니다 (정책으로 막혔을 수 있습니다)', 왜영어: r.err || 'PowerShell could not be run (execution policy may block it)' };
142
155
  }
143
156
  if (process.platform === 'darwin') {
144
157
  const r = spawnSync('security', ['-h'], { encoding: 'utf8', timeout: 10000 });
145
158
  if (!r.error) return { 되나: true, 방식: 'keychain', 왜: '' };
146
- return { 되나: false, 방식: '파일', 왜: 'security 명령을 못 찾았습니다' };
159
+ return { 되나: false, 방식: '파일', 왜: 'security 명령을 못 찾았습니다', 왜영어: 'the security command was not found' };
147
160
  }
148
- return { 되나: false, 방식: '파일', 왜: '이 운영체제에서는 파일 권한(0600)으로만 둡니다' };
161
+ return { 되나: false, 방식: '파일', 왜: '이 운영체제에서는 파일 권한(0600)으로만 둡니다', 왜영어: 'this OS has no keystore, so file permissions (0600) are all there is' };
149
162
  }
150
163
 
151
164
  /**
@@ -157,19 +170,16 @@ export function 쓸수있나() {
157
170
  export function 잠그기(글) {
158
171
  const 값 = String(글 ?? '');
159
172
  if (!값) return null;
160
- 마지막탈 = null;
161
173
 
162
174
  if (process.platform === 'win32') {
163
175
  const r = 파워셸실행(잠그는스크립트, Buffer.from(값, 'utf8').toString('base64'));
164
176
  if (r.ok && r.out) return `dpapi:${r.out}`;
165
- 마지막탈 = `열쇠를 잠갔습니다${r.err || '파워셸이 답을 줬습니다'}`;
166
- return null;
177
+ return null; // 사유는 어디로도 간다 잠긴 것인지 아닌지는 보관방식() 따로 말한다
167
178
  }
168
179
  if (process.platform === 'darwin') {
169
180
  const r = 키체인넣기(값);
170
- if (r.ok) return `keychain:${키체인이름}`;
171
- 마지막탈 = `열쇠를 키체인에 넣었습니다${r.err || 'security 실패했습니다'}`;
172
- return null;
181
+ if (r.ok) return `keychain:${키체인이름()}`;
182
+ return null; // 사유는 어디로도 간다잠긴 것인지 아닌지는 보관방식() 이 따로 말한다
173
183
  }
174
184
  return null;
175
185
  }
@@ -187,53 +197,66 @@ export function 풀기(태그) {
187
197
  const m = 꼴.exec(String(태그 ?? ''));
188
198
  if (!m) return { ok: false, text: '', why: '잠긴 열쇠가 아닙니다' };
189
199
  const [, 갈래, 값] = m;
190
- 마지막탈 = null;
191
200
 
192
201
  if (갈래 === 'dpapi') {
193
202
  if (process.platform !== 'win32') {
194
- 마지막탈 = '이 열쇠는 윈도우에서 잠근 것이라 여기서는 못 풉니다 — deel setup 으로 다시 넣으세요.';
195
- return { ok: false, text: '', why: 마지막탈 };
203
+ return { ok: false, text: '', why: '이 열쇠는 윈도우에서 잠근 것이라 여기서는 못 풉니다 — deel setup 으로 다시 넣으세요.' };
196
204
  }
197
205
  const r = 파워셸실행(푸는스크립트, 값);
198
206
  if (r.ok && r.out) {
199
207
  try { return { ok: true, text: Buffer.from(r.out, 'base64').toString('utf8'), why: '' }; }
200
208
  catch { /* 아래로 */ }
201
209
  }
202
- 마지막탈 = '잠근 열쇠를 못 풉니다 — 이 PC 의 이 계정에서 잠근 것만 풀립니다. deel setup 으로 다시 넣으세요.';
203
- return { ok: false, text: '', why: 마지막탈 };
210
+ return { ok: false, text: '', why: '잠근 열쇠를 못 풉니다 — 이 PC 의 이 계정에서 잠근 것만 풀립니다. deel setup 으로 다시 넣으세요.' };
204
211
  }
205
212
 
206
213
  if (갈래 === 'keychain') {
207
214
  if (process.platform !== 'darwin') {
208
- 마지막탈 = '이 열쇠는 맥 키체인에 있습니다 — 여기서는 못 읽습니다. deel setup 으로 다시 넣으세요.';
209
- return { ok: false, text: '', why: 마지막탈 };
215
+ return { ok: false, text: '', why: '이 열쇠는 맥 키체인에 있습니다 — 여기서는 못 읽습니다. deel setup 으로 다시 넣으세요.' };
210
216
  }
211
217
  const r = 키체인읽기();
212
218
  if (r.ok) return { ok: true, text: r.text, why: '' };
213
- 마지막탈 = `키체인에서 열쇠를 못 읽었습니다 — ${r.err}`;
214
- return { ok: false, text: '', why: 마지막탈 };
219
+ return { ok: false, text: '', why: `키체인에서 열쇠를 못 읽었습니다 — ${r.err}` };
215
220
  }
216
221
 
217
222
  return { ok: false, text: '', why: '모르는 잠금 방식입니다' };
218
223
  }
219
224
 
220
- /** 화면·심사서에 그대로 쓰는 한 줄. 되는 척도 안 되는 척도 안 한다. */
221
- export function 보관방식(값 = null) {
225
+ /**
226
+ * 화면·심사서에 그대로 쓰는 한 줄. 되는 척도 안 되는 척도 안 한다.
227
+ *
228
+ * 영어 심사서(pack/sheet.en.js)도 **이 함수**를 쓴다. 사실을 가려내는 규칙은
229
+ * 하나여야 한다 — 영어판에 같은 판단을 다시 적어 두면, 잠금장치가 달라졌을 때
230
+ * 한쪽만 고쳐져서 두 서류가 서로 다른 말을 하게 된다.
231
+ */
232
+ export function 보관방식(값 = null, { lang = 'ko' } = {}) {
233
+ const 한 = lang === 'ko';
222
234
  if (값 && 잠긴것인가(값)) {
223
- return 값.startsWith('dpapi:')
224
- ? 'DPAPI — 이 PC 의 이 계정만 풉니다'
225
- : ' 키체인 계정의 로그인 키체인에 있습니다';
235
+ if (값.startsWith('dpapi:')) {
236
+ return 한 ? 'DPAPI — 이 PC 의 이 계정만 풉니다'
237
+ : 'DPAPI - only this account on this machine can unseal it';
238
+ }
239
+ return 한 ? '맥 키체인 — 이 계정의 로그인 키체인에 있습니다'
240
+ : 'macOS keychain - stored in this account\'s login keychain';
226
241
  }
227
242
  const 쓸수 = 쓸수있나();
228
243
  if (값) {
229
244
  // 평문인데 잠글 수는 있는 상태. 다음 저장에서 잠긴다.
230
- return 쓸수.되나
231
- ? `파일에 평문 — 다음 저장 때 ${쓸수.방식 === 'dpapi' ? 'DPAPI' : '키체인'} 로 잠급니다`
232
- : `파일에 평문 + 권한 0600 ${쓸수.왜}`;
245
+ const 장치 = 쓸수.방식 === 'dpapi' ? 'DPAPI' : (한 ? '키체인' : 'the keychain');
246
+ if (쓸수.되나) {
247
+ return 한 ? `파일에 평문 다음 저장 ${장치} 로 잠급니다`
248
+ : `Plain text in the file - it will be sealed with ${장치} on the next save`;
249
+ }
250
+ return 한 ? `파일에 평문 + 권한 0600 — ${쓸수.왜}`
251
+ : `Plain text in the file, mode 0600 - ${쓸수.왜영어 ?? 'no OS keystore is available here'}`;
252
+ }
253
+ if (process.platform === 'win32') {
254
+ return 한 ? 'DPAPI (윈도우) · 저장된 열쇠 없음' : 'DPAPI (Windows) - no key stored';
255
+ }
256
+ if (process.platform === 'darwin') {
257
+ return 한 ? '맥 키체인 · 저장된 열쇠 없음' : 'macOS keychain - no key stored';
233
258
  }
234
- if (process.platform === 'win32') return 'DPAPI (윈도우) · 저장된 열쇠 없음';
235
- if (process.platform === 'darwin') return '맥 키체인 · 저장된 열쇠 없음';
236
- return '파일 권한 0600 · 저장된 열쇠 없음';
259
+ return ? '파일 권한 0600 · 저장된 열쇠 없음' : 'File mode 0600 - no key stored';
237
260
  }
238
261
 
239
262
  /**
@@ -275,7 +298,7 @@ export function 잠금지우기(값 = null) {
275
298
  }
276
299
 
277
300
  const 계정 = userInfo().username;
278
- const 인자 = ['delete-generic-password', '-a', 계정, '-s', 키체인이름];
301
+ const 인자 = ['delete-generic-password', '-a', 계정, '-s', 키체인이름()];
279
302
  마지막인자 = ['security', ...인자];
280
303
  const r = spawnSync('security', 인자, { encoding: 'utf8', timeout: 20000 });
281
304
  if (r.error) return { 지움: false, 방식: 'keychain', 왜: r.error.message };
@@ -168,7 +168,7 @@ export class History {
168
168
  continue;
169
169
  }
170
170
  rmSync(path, { force: true });
171
- restored.push({ path, how: 말('undo.wayDeleted') });
171
+ restored.push({ path, how: 말('undo.wayDeleted'), ok: true });
172
172
  } else {
173
173
  /*
174
174
  * 담고 있던 폴더가 없어졌을 수 있다 — Move 로 폴더째 옮긴 경우다.
@@ -179,16 +179,34 @@ export class History {
179
179
  mkdirSync(dirname(path), { recursive: true });
180
180
  // enc 가 붙어 있으면 UTF-8 로 담을 수 없던 파일이다 — 바이트를 그대로 되돌린다.
181
181
  writeFileSync(path, rec.enc === 'b64' ? Buffer.from(rec.before, 'base64') : Buffer.from(rec.before, 'utf8'));
182
- restored.push({ path, how: 말('undo.wayRestored') });
182
+ restored.push({ path, how: 말('undo.wayRestored'), ok: true });
183
183
  }
184
184
  } catch (err) {
185
- restored.push({ path, how: `실패: ${err.message}` });
185
+ restored.push({ path, how: `실패: ${err.message}`, ok: false, 못했나: true });
186
186
  }
187
187
  }
188
- // 되돌린 기록은 잘라낸다.
189
- const keep = recs.filter((r) => !turns.includes(r.turn));
188
+ /*
189
+ * ── 되돌린 것의 기록은 **남긴다** ──────────────────────────────
190
+ *
191
+ * 전에는 성패를 안 가리고 그 턴의 기록을 통째로 잘라냈다. 그러면 한 번
192
+ * 실패한 파일은 스냅샷이 사라져서 **다시 시도할 길이 없다.** 화면은
193
+ * 「파일 N개를 되돌렸습니다」 라고 하고(실패한 것까지 셌다), 감사기록에도
194
+ * 그 부풀린 수가 남고, 부르는 쪽은 /diff 목록에서까지 그 파일을 지운다.
195
+ * 파일만 고쳐진 채로 남고 아무도 그걸 모른다.
196
+ *
197
+ * 되돌린 것만 잘라낸다. 못한 것은 그대로 두어 한 번 더 시도할 수 있게 한다.
198
+ */
199
+ const 못한것 = new Set(restored.filter((x) => x.ok === false).map((x) => x.path));
200
+ const keep = recs.filter((r) => !turns.includes(r.turn) || 못한것.has(r.path));
190
201
  writeFileSync(this.file, keep.map((r) => JSON.stringify(r)).join('\n') + (keep.length ? '\n' : ''), 'utf8');
191
- return { restored, turns: turns.length, turnIds: turns.slice() };
202
+ return {
203
+ restored,
204
+ // 화면·감사기록이 쓰는 수. **진짜로 되돌아간 것만** 센다.
205
+ 되돌린수: restored.filter((x) => x.ok === true).length,
206
+ 못한것: restored.filter((x) => x.ok === false),
207
+ turns: turns.length,
208
+ turnIds: turns.slice(),
209
+ };
192
210
  }
193
211
  }
194
212