deel-local-cli 1.8.0 → 1.12.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 (61) hide show
  1. package/README.ko.md +10 -25
  2. package/README.md +43 -51
  3. package/bin/deel.js +56 -13
  4. package/package.json +60 -60
  5. package/src/acp/serve.js +810 -732
  6. package/src/agent/budget.js +156 -153
  7. package/src/agent/compact.js +420 -296
  8. package/src/agent/effort.js +308 -177
  9. package/src/agent/evidence.js +2 -0
  10. package/src/agent/evolve.js +263 -213
  11. package/src/agent/filemem.js +155 -0
  12. package/src/agent/grade.js +51 -1
  13. package/src/agent/loop.js +1707 -1349
  14. package/src/agent/memory.js +156 -152
  15. package/src/agent/mention.js +210 -210
  16. package/src/agent/session.js +1049 -832
  17. package/src/agent/store.js +186 -9
  18. package/src/agent/threads.js +26 -1
  19. package/src/backend/adapter.js +1159 -615
  20. package/src/backend/cachemark.js +150 -0
  21. package/src/backend/detect.js +327 -288
  22. package/src/backend/learn.js +24 -0
  23. package/src/backend/mcp.js +96 -5
  24. package/src/backend/probe.js +131 -61
  25. package/src/backend/quota.js +250 -133
  26. package/src/backend/retry.js +30 -6
  27. package/src/backend/tokens.js +37 -0
  28. package/src/backend/toolfit.js +352 -0
  29. package/src/backend/wire.js +600 -0
  30. package/src/commands.js +3135 -2918
  31. package/src/config.js +47 -2
  32. package/src/i18n/en.js +544 -466
  33. package/src/i18n/index.js +18 -0
  34. package/src/i18n/ja.js +497 -417
  35. package/src/i18n/ko.js +591 -466
  36. package/src/i18n/zh.js +497 -417
  37. package/src/lsp/client.js +49 -5
  38. package/src/oneshot.js +620 -471
  39. package/src/pack/sbom.js +30 -4
  40. package/src/pack/selfpack.js +25 -9
  41. package/src/pack/sheet.en.js +288 -0
  42. package/src/pack/tar.js +65 -2
  43. package/src/plugins/manage.js +46 -9
  44. package/src/providers/bedrock.js +17 -0
  45. package/src/repl.js +2602 -2286
  46. package/src/safety/audit.js +92 -8
  47. package/src/safety/authcmd.js +14 -3
  48. package/src/safety/guard.js +143 -0
  49. package/src/safety/keystore.js +62 -39
  50. package/src/safety/undo.js +24 -6
  51. package/src/tools/fsutil.js +265 -250
  52. package/src/tools/index.js +239 -34
  53. package/src/tools/jobs.js +158 -29
  54. package/src/tools/verify.js +358 -328
  55. package/src/tools/webfetch.js +92 -9
  56. package/src/ui/md.js +201 -5
  57. package/src/ui/motion.js +0 -1
  58. package/src/ui/pastechip.js +50 -3
  59. package/src/ui/pick.js +115 -0
  60. package/src/ui/screen.js +23 -3
  61. package/src/ui/status.js +613 -610
@@ -1,22 +1,106 @@
1
1
  // 감사 로그. 무엇을 언제 어떻게 했는지 전부 남긴다.
2
2
  // 자율 실행을 사내에 설득할 때 이 파일이 근거가 된다.
3
3
  import { join } from 'node:path';
4
- import { appendFileSync, mkdirSync, existsSync, readFileSync } from 'node:fs';
4
+ import { appendFileSync, mkdirSync, existsSync, readFileSync, chmodSync } from 'node:fs';
5
+ import { 가리기 } from './secrets.js';
6
+
7
+ /**
8
+ * 감사기록에 넘길 '아는 열쇠' 를 묻는 길. loop.js 가 대화 쪽에서 쓰는 것과 같은 값이다.
9
+ * 값이 아니라 길로 주는 까닭은 Audit 만들기 머리말에 적어 뒀다.
10
+ */
11
+ export const 열쇠묻기 = (conn) => () => [conn?.key, process.env.DEEL_API_KEY].filter(Boolean);
5
12
 
6
13
  export class Audit {
7
- constructor(root) {
14
+ /**
15
+ * @param {string} root 작업 폴더
16
+ * @param {object} o
17
+ * @param {string[]|(() => string[])} o.열쇠들 정확히 아는 비밀 (설정에 든 게이트웨이 열쇠).
18
+ * 값이 아니라 **묻는 길**로 받아도 된다 — /model 로 갈아타면 conn 이 통째로
19
+ * 바뀌므로, 만들 때 붙잡아 둔 값은 그 판이 끝나기 전에 옛것이 된다.
20
+ */
21
+ constructor(root, { 열쇠들 = [] } = {}) {
8
22
  const dir = join(root, '.deel');
9
23
  mkdirSync(dir, { recursive: true });
10
24
  this.file = join(dir, 'audit.jsonl');
11
25
  this.session = `${new Date().toISOString().slice(0, 19).replace(/[:T]/g, '')}`;
26
+ /** 적으려다 못 적은 건수와 첫 까닭. 0 이 아니면 이 세션의 기록은 불완전하다. */
27
+ this.못쓴수 = 0;
28
+ this.못쓴까닭 = null;
29
+ this.열쇠들 = 열쇠들;
30
+ /** 파일 권한을 건 결과. `{모드}` 면 걸었고, `{못함}` 이면 못 걸었다. */
31
+ this.잠금 = null;
32
+ }
33
+
34
+ /*
35
+ * ── 적기 전에 가린다 ────────────────────────────────────────────────
36
+ *
37
+ * 여기 적히는 target 은 Bash 명령줄 그 자체다. 모델은 열쇠를 헤더로
38
+ * (`-H "Authorization: Bearer …"`), 환경변수로(`DEEL_API_KEY=… node x`),
39
+ * 주소 안에 박아서(`https://사람:토큰@github.com/…`) 넘긴다.
40
+ *
41
+ * 도구 **결과**는 loop.js 가 secrets.js 로 가려서 대화에 싣는데, 감사기록은
42
+ * 그 길을 안 지난다. 그래서 대화에서는 지운 값이 디스크에는 평문으로 남았다 —
43
+ * 그것도 `deel audit` 로 사내 심사 자료에 딸려 나가는 파일에.
44
+ *
45
+ * 가리는 것은 자유롭게 적히는 글뿐이다. 도구 이름·성패 같은 정해진 값은
46
+ * 안 건드린다 — 넓게 잡으면 「무엇을 했는지」 가 «가림» 으로 뭉개진다.
47
+ */
48
+ #가린것(값) {
49
+ if (값 == null) return 값;
50
+ const 것들 = typeof this.열쇠들 === 'function' ? this.열쇠들() : this.열쇠들;
51
+ return 가리기(String(값), { 열쇠들: 것들 ?? [] }).글;
12
52
  }
13
53
 
54
+ /*
55
+ * 못 적어도 던지지 않는다 — 기록을 남기려다 작업을 죽이면 본말이 뒤집힌다.
56
+ *
57
+ * 다만 **조용히 삼키면 안 된다.** 이 파일의 첫 줄이 파는 문장이
58
+ * 「무엇을 언제 어떻게 했는지 전부 남는다」 이고, /mcp 화면은 대놓고
59
+ * `.deel/audit.jsonl 에 남습니다` 라고 적어 준다. 디스크가 차거나 폴더가
60
+ * 읽기 전용이면 그 두 문장이 거짓이 되는데, 그때도 화면은 아무 말이 없었다.
61
+ * 더 나쁜 건 증거모으기다 — 기록이 안 적히면 「고쳤는데 확인 안 됨」이
62
+ * 아니라 아예 **안 고친 것처럼** 보고서가 짧아진다.
63
+ * 그래서 못 적은 건수를 세어 두고, 보고서와 화면이 그걸 말하게 한다.
64
+ */
14
65
  write(kind, data) {
15
66
  const rec = { at: new Date().toISOString(), session: this.session, kind, ...data };
16
- try { appendFileSync(this.file, JSON.stringify(rec) + '\n', 'utf8'); } catch {}
67
+ try {
68
+ appendFileSync(this.file, JSON.stringify(rec) + '\n', 'utf8');
69
+ this.#잠그기();
70
+ } catch (err) {
71
+ this.못쓴수 += 1;
72
+ if (!this.못쓴까닭) this.못쓴까닭 = err?.message ?? String(err);
73
+ }
17
74
  return rec;
18
75
  }
19
76
 
77
+ /*
78
+ * 만들자마자 본인만 읽게 잠근다 (config.js 가 설정 파일에 하는 것과 같다).
79
+ *
80
+ * 가리기를 지나도 여기에는 무엇을 언제 어디서 했는지가 남는다 — 파일 경로,
81
+ * 돌린 명령, 사람이 친 말. 같은 PC 를 여럿이 쓰거나 홈이 공유 폴더에 있으면
82
+ * 그게 그대로 남에게 읽힌다. 한 번만 건다 — 줄마다 걸 일이 아니다.
83
+ * 윈도우(NTFS)에서는 chmod 가 아무 일도 안 한다. 거기서는 못 잠근 것이지
84
+ * 잠근 척하지 않는다 — 보관방식 화면이 그 사실을 따로 말한다.
85
+ *
86
+ * 건 결과를 `잠금` 에 남긴다. "본인만 읽게 잠근다" 는 말은 지켜지는지
87
+ * **밖에서 볼 수 있어야** 뜻이 있다 (keystore.js 의 마지막명령줄 과 같은
88
+ * 까닭). 윈도우에서 값이 남는다고 실제로 잠겼다는 뜻은 아니다 —
89
+ * 우리가 걸어는 봤다는 뜻이다.
90
+ */
91
+ #잠갔나 = false;
92
+ #잠그기() {
93
+ if (this.#잠갔나) return;
94
+ this.#잠갔나 = true;
95
+ try { chmodSync(this.file, 0o600); this.잠금 = { 모드: 0o600 }; }
96
+ catch (err) { this.잠금 = { 못함: err?.code ?? String(err) }; }
97
+ }
98
+
99
+ /** 기록이 새고 있나. 새고 있으면 `{수, 까닭}`, 멀쩡하면 null. */
100
+ 못쓴것() {
101
+ return this.못쓴수 ? { 수: this.못쓴수, 까닭: this.못쓴까닭 } : null;
102
+ }
103
+
20
104
  tool(name, args, result) {
21
105
  return this.write('tool', {
22
106
  tool: name,
@@ -24,15 +108,15 @@ export class Audit {
24
108
  // '하위 작업을 돌렸다' 만 남고 **무엇을** 돌렸는지가 안 남는다.
25
109
  // 옛 이름(목적)도 같이 본다 — 이름을 바꾼 판 이전의 기록이 남아 있고,
26
110
  // 옛 이름으로 부르는 모델도 있다.
27
- target: args?.file_path ?? args?.path ?? args?.pattern ?? args?.command
28
- ?? args?.purpose ?? args?.목적 ?? null,
111
+ target: this.#가린것(args?.file_path ?? args?.path ?? args?.pattern ?? args?.command
112
+ ?? args?.purpose ?? args?.목적 ?? null),
29
113
  ok: !result?.error,
30
- note: result?.error ?? result?.summary ?? null,
114
+ note: this.#가린것(result?.error ?? result?.summary ?? null),
31
115
  });
32
116
  }
33
117
 
34
- turn(text) { return this.write('turn', { text: String(text).slice(0, 500) }); }
35
- blocked(why, what) { return this.write('blocked', { why, what: String(what).slice(0, 300) }); }
118
+ turn(text) { return this.write('turn', { text: this.#가린것(String(text).slice(0, 500)) }); }
119
+ blocked(why, what) { return this.write('blocked', { why, what: this.#가린것(String(what).slice(0, 300)) }); }
36
120
  undo(info) { return this.write('undo', info); }
37
121
 
38
122
  recent(n = 20) {
@@ -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