deel-local-cli 1.20.12 → 2.0.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 (155) hide show
  1. package/README.ko.md +96 -77
  2. package/README.md +119 -80
  3. package/bin/deel.js +398 -32
  4. package/package.json +2 -1
  5. package/src/acp/jsonrpc.js +87 -7
  6. package/src/acp/map.js +62 -13
  7. package/src/acp/serve.js +338 -30
  8. package/src/agent/agents.js +110 -12
  9. package/src/agent/askcheck.js +50 -6
  10. package/src/agent/asks.js +59 -5
  11. package/src/agent/budget.js +9 -2
  12. package/src/agent/card.js +8 -2
  13. package/src/agent/commit.js +168 -16
  14. package/src/agent/compact.js +93 -21
  15. package/src/agent/{/354/213/240/353/242/260/353/217/204.js → confidence.js} +1 -1
  16. package/src/agent/effort.js +85 -15
  17. package/src/agent/evidence.js +63 -7
  18. package/src/agent/evolve.js +132 -24
  19. package/src/agent/grade.js +12 -3
  20. package/src/agent/loop.js +408 -81
  21. package/src/agent/memory.js +176 -29
  22. package/src/agent/mention.js +22 -5
  23. package/src/agent/models.js +31 -7
  24. package/src/agent/modes.js +25 -10
  25. package/src/agent/outschema.js +381 -56
  26. package/src/agent/pins.js +33 -2
  27. package/src/agent/project.js +73 -8
  28. package/src/agent/recall.js +40 -9
  29. package/src/agent/{/354/204/261/355/225/234/352/270/260/353/241/235.js → recordshape.js} +19 -8
  30. package/src/agent/review.js +143 -13
  31. package/src/agent/route.js +506 -32
  32. package/src/agent/salvage.js +2 -1
  33. package/src/agent/session.js +457 -31
  34. package/src/agent/store.js +232 -22
  35. package/src/agent/threads.js +52 -3
  36. package/src/backend/adapter.js +508 -85
  37. package/src/backend/azure.js +12 -1
  38. package/src/backend/cachemark.js +31 -5
  39. package/src/backend/clientcert.js +23 -1
  40. package/src/backend/ctxsize.js +56 -9
  41. package/src/backend/detect.js +252 -19
  42. package/src/backend/http.js +383 -42
  43. package/src/backend/learn.js +101 -11
  44. package/src/backend/mcp.js +333 -24
  45. package/src/backend/price.js +61 -13
  46. package/src/backend/probe.js +188 -36
  47. package/src/backend/proxy.js +108 -14
  48. package/src/backend/quota.js +226 -30
  49. package/src/backend/retry.js +46 -3
  50. package/src/backend/scan.js +47 -10
  51. package/src/backend/scanui.js +57 -7
  52. package/src/backend/toolfit.js +204 -22
  53. package/src/backend/vision.js +14 -1
  54. package/src/backend/wire.js +58 -10
  55. package/src/cmdnames.js +16 -0
  56. package/src/commands/common.js +70 -0
  57. package/src/commands/extend.js +300 -0
  58. package/src/commands/model.js +852 -0
  59. package/src/commands/view.js +328 -0
  60. package/src/commands/work.js +823 -0
  61. package/src/commands.js +193 -2082
  62. package/src/completion.js +45 -3
  63. package/src/config.js +452 -39
  64. package/src/configexplain.js +125 -14
  65. package/src/doctor.js +164 -26
  66. package/src/i18n/en.js +22 -0
  67. package/src/i18n/index.js +31 -1
  68. package/src/i18n/ja.js +25 -1
  69. package/src/i18n/ko.js +25 -2
  70. package/src/i18n/zh.js +25 -1
  71. package/src/lsp/client.js +111 -9
  72. package/src/lsp/servers.js +86 -22
  73. package/src/oneshot.js +325 -65
  74. package/src/pack/sbom.js +28 -5
  75. package/src/pack/selfpack.js +54 -5
  76. package/src/pack/sheet.en.js +24 -3
  77. package/src/pack/tar.js +45 -8
  78. package/src/pack/zip.js +103 -8
  79. package/src/plugins/manage.js +165 -14
  80. package/src/preview/serve.js +239 -23
  81. package/src/providers/bedrock.js +30 -5
  82. package/src/providers/gemini.js +8 -0
  83. package/src/providers/index.js +17 -1
  84. package/src/repl.js +254 -61
  85. package/src/report.js +7 -2
  86. package/src/reset.js +151 -25
  87. package/src/safety/audit.js +108 -8
  88. package/src/safety/authcmd.js +75 -5
  89. package/src/safety/guard.js +507 -15
  90. package/src/safety/hooks.js +172 -20
  91. package/src/safety/keystore.js +178 -18
  92. package/src/safety/network.js +209 -6
  93. package/src/safety/policy.js +377 -36
  94. package/src/safety/runmode.js +9 -2
  95. package/src/safety/secrets.js +243 -17
  96. package/src/safety/shellenv.js +64 -3
  97. package/src/safety/trust.js +298 -32
  98. package/src/safety/undo.js +409 -17
  99. package/src/setup.js +175 -17
  100. package/src/skills/discover.js +122 -19
  101. package/src/stats.js +57 -16
  102. package/src/tools/{/355/231/225/354/235/270/353/262/225.js → checkmethods.js} +26 -1
  103. package/src/tools/clipboard.js +53 -20
  104. package/src/tools/convert.js +118 -37
  105. package/src/tools/desc.en.js +48 -5
  106. package/src/tools/doc2md.js +11 -1
  107. package/src/tools/docs.js +94 -18
  108. package/src/tools/edit-match.js +212 -13
  109. package/src/tools/encoding.js +525 -28
  110. package/src/tools/excel-com.js +63 -15
  111. package/src/tools/excel.js +12 -7
  112. package/src/tools/fastgrep.js +444 -36
  113. package/src/tools/fig.js +40 -9
  114. package/src/tools/fsutil.js +235 -21
  115. package/src/tools/hwpxwrite.js +80 -19
  116. package/src/tools/ignore.js +55 -7
  117. package/src/tools/index.js +1029 -145
  118. package/src/tools/jobs.js +325 -50
  119. package/src/tools/kiwi.js +50 -5
  120. package/src/tools/lsp.js +164 -32
  121. package/src/tools/outline.js +52 -12
  122. package/src/tools/pdf.js +366 -56
  123. package/src/tools/shell.js +12 -4
  124. package/src/tools/spawn.js +203 -18
  125. package/src/tools/todo.js +73 -4
  126. package/src/tools/verify.js +312 -43
  127. package/src/tools/webfetch.js +184 -25
  128. package/src/tools/xlsx.js +232 -31
  129. package/src/ui/ansi.js +209 -15
  130. package/src/ui/banner.js +2 -1
  131. package/src/ui/complete.js +46 -4
  132. package/src/ui/diff.js +4 -2
  133. package/src/ui/export.js +91 -17
  134. package/src/ui/inputbox.js +58 -18
  135. package/src/ui/intro.js +9 -2
  136. package/src/ui/level.js +32 -7
  137. package/src/ui/md.js +101 -15
  138. package/src/ui/motion.js +5 -2
  139. package/src/ui/notify.js +7 -1
  140. package/src/ui/office.js +37 -5
  141. package/src/ui/pastechip.js +3 -2
  142. package/src/ui/pick.js +85 -12
  143. package/src/ui/prompt.js +176 -13
  144. package/src/ui/screen.js +2 -1
  145. package/src/ui/spinner.js +6 -1
  146. package/src/ui/status.js +80 -12
  147. package/src/ui/wrap.js +23 -37
  148. /package/src/agent/{/353/213/250/352/263/204.js" → phase.js} +0 -0
  149. /package/src/skills/builtin/{/352/271/212/354/235/264/354/236/210/352/262/214-/353/247/214/353/223/244/352/270/260/SKILL.md" → build-deep/SKILL.md} +0 -0
  150. /package/src/skills/builtin/{/354/260/250/352/267/274/354/260/250/352/267/274-/353/224/224/353/262/204/352/271/205/SKILL.md" → debug-step-by-step/SKILL.md} +0 -0
  151. /package/src/skills/builtin/{/353/201/235/352/271/214/354/247/200-/355/225/230/352/270/260/SKILL.md" → finish-all/SKILL.md} +0 -0
  152. /package/src/skills/builtin/{/354/212/244/354/212/244/353/241/234-/352/262/200/355/206/240/SKILL.md" → self-review/SKILL.md} +0 -0
  153. /package/src/skills/builtin/{/354/275/224/353/223/234-/354/244/204/354/235/264/352/270/260/SKILL.md" → simplify-code/SKILL.md} +0 -0
  154. /package/src/skills/builtin/{/354/260/224/353/237/254/353/263/264/352/270/260/SKILL.md" → spike/SKILL.md} +0 -0
  155. /package/src/skills/builtin/{/352/262/200/354/202/254-/353/250/274/354/240/200/SKILL.md" → test-first/SKILL.md} +0 -0
package/src/pack/sbom.js CHANGED
@@ -33,16 +33,18 @@ import { specEn, egressEn, keyStorageEn, auditLogEn, specSummaryEn } from './she
33
33
  *
34
34
  * 1.5 에서 1.7 로 올렸다. 판을 올린 것 자체가 목적이 아니라, CISA 가
35
35
  * 2026-07-29 로 「SBOM 최소 요소」 를 새로 냈고(2021년 NTIA 문서를 대체한다)
36
- * 거기서 더해진 네 가지를 적을 자리가 1.5 에는 없었다.
36
+ * 거기서 더해진 네 가지 중 **뒤의 둘**은 1.5 에 적을 자리가 아예 없었다.
37
37
  *
38
38
  * 부품 해시 알고리즘 hashes[].alg — 1.5 에도 있었고 이미 적고 있었다
39
39
  * 부품 라이선스 components[].licenses
40
40
  * 만든 도구 이름 metadata.tools.components[]
41
41
  * 만든 맥락 metadata.lifecycles[]
42
42
  *
43
- * 앞의 두 개는 자리가 있었고 우리가 안 적었다. 뒤의 두 개는 1.5 의 모양이
44
- * 다르다 — metadata.tools 가 1.5 에서는 {vendor,name,version} 배열이고
45
- * 1.6 부터 부품 목록으로 바뀌었으며, lifecycles 는 아예 없다.
43
+ * 앞의 두 개는 자리가 1.5 에도 있었다. 그중 해시는 이미 적고 있었고, 안 적은
44
+ * 것은 **부품 라이선스 하나뿐**이다 — 뿌리 부품에만 적고 파일에는 안 적었다.
45
+ * 뒤의 두 개는 1.5 의 모양이 다르다 — metadata.tools 가 1.5 에서는
46
+ * {vendor,name,version} 배열이고 1.6 부터 부품 목록으로 바뀌었으며,
47
+ * lifecycles 는 아예 없다.
46
48
  *
47
49
  * 판을 안 올리고 필드만 끼워 넣는 길도 있었지만 그러면 규격에 안 맞는
48
50
  * SBOM 이 된다. 스캐너가 1.5 로 검사하다 모르는 열쇠에서 멈추면, 담당자
@@ -143,8 +145,17 @@ export function sbom(a, { at = new Date(), serial = null, lang = 언어() } = {}
143
145
  *
144
146
  * 실제 서명은 npm 배포 때 붙는 SLSA 증명이고, 그건 우리 손을 안 거친다.
145
147
  * 확인하는 명령을 적어 두는 편이 서명 한 덩이보다 낫다.
148
+ *
149
+ * 이 값도 description 과 같이 말을 따라간다. 스캐너에 먹이는 파일이라도
150
+ * 속성 칸은 담당자가 눈으로 읽는다 — 영어 서류에 한글이 한 자리 남으면
151
+ * 그 문서는 통째로 미완성으로 읽힌다.
146
152
  */
147
- { name: 'deel:provenance', value: `npm audit signatures — ${a.name}@${a.version} (SLSA v1, npm 신뢰 배포)` },
153
+ {
154
+ name: 'deel:provenance',
155
+ value: lang === 'ko'
156
+ ? `npm audit signatures — ${a.name}@${a.version} (SLSA v1, npm 신뢰 배포)`
157
+ : `npm audit signatures — ${a.name}@${a.version} (SLSA v1, npm trusted publishing)`,
158
+ },
148
159
  ],
149
160
  },
150
161
  // 파일마다 하나씩. 담긴 것이 담겨야 할 것과 같은지 여기서 대조한다.
@@ -202,6 +213,18 @@ export function 통신명세(a) {
202
213
  막는법: '--offline 이면 통째로 막힙니다.',
203
214
  소스: 자리('net'),
204
215
  },
216
+ /*
217
+ * 넷째 길. 심사서 본문(selfpack.js 의 [D])은 네 갈래라고 적는데 이 목록은
218
+ * 셋이었다 — 담당자가 둘을 나란히 놓으면 제일 못 막는 길이 기계 목록에서만 빠져 있었다.
219
+ */
220
+ {
221
+ 갈래: 'MCP 서버',
222
+ 언제: '.deel/mcp.json 에 사람이 적은 서버만, 대화를 켤 때',
223
+ 어디로: '그 서버 프로그램이 정합니다 — 딴 자식 프로세스라 우리 허용 목록을 거치지 않습니다',
224
+ 무엇이: '모델이 그 서버의 도구에 넘긴 인자',
225
+ 막는법: '기본은 꺼져 있습니다. --offline 이면 서버 자체를 띄우지 않습니다.',
226
+ 소스: 자리('exec'),
227
+ },
205
228
  ],
206
229
  여는포트: {
207
230
  개수: (a.calls?.listen ?? []).length,
@@ -79,19 +79,48 @@ export function scanCalls(root = repoRoot(), files = shippedFiles(root)) {
79
79
  * 소스에서 불러오는 모듈 이름만 뽑는다.
80
80
  *
81
81
  * 그냥 정규식으로 import 를 찾으면 화면 문구 안의 "외부 import" 같은 글자까지 잡힌다.
82
- * 실제로 그렇게 잡혀서 심사서에 없는 의존성이 적혔다. 그래서 두 가지를 함께 본다.
82
+ * 실제로 그렇게 잡혀서 심사서에 없는 의존성이 적혔다. 그래서 세 가지를 함께 본다.
83
83
  * 1) import / require 가 낱말로 서 있을 것
84
84
  * 2) 따온 값이 모듈 이름처럼 생겼을 것 (공백·괄호·${ 가 없다)
85
+ * 3) 그 자리가 **따옴표 밖**일 것
86
+ *
87
+ * 셋째가 없던 동안 앞의 둘은 `import 'foo'` 를 안내문에 적어 둔 줄을 못 걸렀다.
88
+ * 그 줄 하나로 심사서 1절에 없는 남의 코드가 한 줄 늘어난다 — 담당자가 그걸
89
+ * 찾아보면 아무 데도 없고, 그 순간 나머지 숫자도 다 못 믿을 것이 된다.
85
90
  */
86
91
  const MODULE_NAME = /^[@\w./:-]+$/;
87
92
 
93
+ /**
94
+ * 줄 처음부터 이 자리까지 걸어와서, 지금 따옴표 **안**인가.
95
+ *
96
+ * 한 줄만 본다. 여러 줄 템플릿 안까지 따라가려면 소스를 통째로 읽는 자가
97
+ * 있어야 하는데, 여기서 막으려는 것은 화면에 찍는 안내문 한 줄이다.
98
+ * 진짜 import 문은 줄 앞머리에 열린 따옴표가 있을 수 없다.
99
+ */
100
+ function 따옴표안인가(앞) {
101
+ let 열린 = null;
102
+ for (let i = 0; i < 앞.length; i += 1) {
103
+ const c = 앞[i];
104
+ if (열린) {
105
+ if (c === '\\') { i += 1; continue; } // 이스케이프된 글자는 닫지 못한다
106
+ if (c === 열린) 열린 = null;
107
+ continue;
108
+ }
109
+ if (c === "'" || c === '"' || c === '`') 열린 = c;
110
+ }
111
+ return 열린 !== null;
112
+ }
113
+
88
114
  export function importSpecs(text) {
89
115
  const out = [];
90
116
  // 동적으로 부르는 import 도 문이다 — from 이 없어서 못 잡던 것을 잡는다.
91
117
  const re = /(?:^|[\s;{(])(?:import[^'"()]*from\s*|import\s*\(\s*|import\s*|require\s*\(\s*)(['"])([^'"]+)\1/g;
92
118
  for (const m of text.matchAll(re)) {
93
119
  const spec = m[2];
94
- if (MODULE_NAME.test(spec)) out.push(spec);
120
+ if (!MODULE_NAME.test(spec)) continue;
121
+ const 줄머리 = text.lastIndexOf('\n', m.index) + 1;
122
+ if (따옴표안인가(text.slice(줄머리, m.index))) continue;
123
+ out.push(spec);
95
124
  }
96
125
  return out;
97
126
  }
@@ -156,7 +185,8 @@ export function reviewSheet(a, at, { lang = 언어() } = {}) {
156
185
  L.push(a.lifecycle.length
157
186
  ? ` 있음: ${a.lifecycle.join(', ')} ← 심사 필요`
158
187
  : ' 없음 ← preinstall / install / postinstall / prepare 전부 없습니다');
159
- L.push(' 압축을 풀고 `node bin/deel.js` 로 바로 씁니다. 설치 절차가 없습니다.');
188
+ // zip 안에서는 소스가 deel/ 아래에 담긴다(아래 packSelf). 읽어주세요.txt 와 같은 자리를 가리킨다 (6회차 S4).
189
+ L.push(' 압축을 풀고 `node deel/bin/deel.js` 로 바로 씁니다. 설치 절차가 없습니다.');
160
190
  L.push('');
161
191
 
162
192
  L.push('3. 바깥으로 나가는 자리 (소스를 훑어 찾은 전부)');
@@ -202,10 +232,29 @@ export function reviewSheet(a, at, { lang = 언어() } = {}) {
202
232
  L.push(' (검증: npm test 안의 network / web / mcp 검사가 이를 확인합니다 — 항목 수는 그 출력에 있습니다)');
203
233
  L.push('');
204
234
 
235
+ /*
236
+ * 여기가 「이 묶음에는 스킬도 플러그인도 들어 있지 않습니다」 였다.
237
+ *
238
+ * 사실이 아니다. `src/skills/builtin/` 의 방법론 SKILL.md 는 그대로 실려 나가고,
239
+ * test/no-bundle.test.js 는 「내 방법론은 제대로 실린다」 고 못 박고 있다 —
240
+ * 두 자리가 반대 방향으로 초록이었다. 심사자가 zip 을 풀어 SKILL.md 를 찾아내면
241
+ * 이 종이의 나머지 숫자도 다 못 믿을 것이 된다. 세어서 적는다.
242
+ */
243
+ const 방법론 = (a.files ?? [])
244
+ .map((f) => /^src\/skills\/builtin\/([^/]+)\/SKILL\.md$/.exec(f.path)?.[1])
245
+ .filter(Boolean)
246
+ .sort();
205
247
  L.push('4. 스킬·플러그인');
206
248
  L.push(줄());
207
- L.push(' 이 묶음에는 스킬도 플러그인도 들어 있지 않습니다.');
208
- L.push(' 설치된 PC 의 ~/.claude, ~/.deel, 프로젝트 폴더를 읽어서 쓸 뿐입니다.');
249
+ if (방법론.length) {
250
+ L.push(` deel 이 품고 가는 방법론 ${방법론.length}개가 들어 있습니다 (src/skills/builtin).`);
251
+ L.push(` ${방법론.join(', ')}`);
252
+ L.push(' 모델에게 읽히는 글일 뿐입니다 — 코드를 돌리지도, 바깥으로 나가지도 않습니다.');
253
+ } else {
254
+ L.push(' 이 묶음에는 스킬이 들어 있지 않습니다.');
255
+ }
256
+ L.push(' 남의 스킬·플러그인은 하나도 안 담았습니다. 설치된 PC 의 ~/.claude, ~/.deel,');
257
+ L.push(' 프로젝트 폴더를 읽어서 쓸 뿐입니다.');
209
258
  L.push(' (검증: npm test 안의 no-bundle 검사가 이를 확인합니다)');
210
259
  L.push('');
211
260
 
@@ -62,7 +62,7 @@ export function reviewSheetEn(a, at, probes) {
62
62
  L.push(a.lifecycle.length
63
63
  ? ` Present: ${a.lifecycle.join(', ')} <- needs review`
64
64
  : ' None <- no preinstall / install / postinstall / prepare');
65
- L.push(' Unzip and run `node bin/deel.js`. There is no install step.');
65
+ L.push(' Unzip and run `node deel/bin/deel.js`. There is no install step.');
66
66
  L.push('');
67
67
 
68
68
  L.push('3. Every outbound call site (found by scanning the source)');
@@ -109,10 +109,23 @@ export function reviewSheetEn(a, at, probes) {
109
109
  L.push(' (Verified by the network / web / mcp checks in npm test; the count is in that output.)');
110
110
  L.push('');
111
111
 
112
+ // 한국어 쪽(selfpack.js 의 4절)과 같은 사실을 적는다 — 「스킬이 하나도 없다」 는
113
+ // 거짓이었다. src/skills/builtin 의 방법론은 실제로 실려 나간다.
114
+ const builtins = (a.files ?? [])
115
+ .map((f) => /^src\/skills\/builtin\/([^/]+)\/SKILL\.md$/.exec(f.path)?.[1])
116
+ .filter(Boolean)
117
+ .sort();
112
118
  L.push('4. Skills and plugins');
113
119
  L.push(rule());
114
- L.push(' This package contains no skills and no plugins.');
115
- L.push(' It only reads ~/.claude, ~/.deel and the project folder on the machine it runs on.');
120
+ if (builtins.length) {
121
+ L.push(` It ships ${builtins.length} built-in methodology skills (src/skills/builtin).`);
122
+ L.push(` ${builtins.join(', ')}`);
123
+ L.push(' They are prose the model reads - they run no code and reach no network.');
124
+ } else {
125
+ L.push(' This package contains no skills.');
126
+ }
127
+ L.push(' It bundles no other people\'s skills or plugins: it only reads ~/.claude, ~/.deel');
128
+ L.push(' and the project folder on the machine it runs on.');
116
129
  L.push(' (Verified by the no-bundle check in npm test.)');
117
130
  L.push('');
118
131
 
@@ -224,6 +237,14 @@ export function egressEn(자리, a) {
224
237
  control: 'Blocked entirely under --offline.',
225
238
  source: 자리('net'),
226
239
  },
240
+ {
241
+ lane: 'MCP servers',
242
+ when: 'Only servers you listed in .deel/mcp.json, when a chat starts',
243
+ where: 'Decided by that server program - a separate child process, so it does not pass our allow-list',
244
+ what: 'The arguments the model passed to that server\'s tools',
245
+ control: 'Off by default. Under --offline the servers are not started at all.',
246
+ source: 자리('exec'),
247
+ },
227
248
  ],
228
249
  listeningPorts: {
229
250
  count: (a.calls?.listen ?? []).length,
package/src/pack/tar.js CHANGED
@@ -8,7 +8,8 @@ const BLOCK = 512;
8
8
  function str(buf, off, len) {
9
9
  const end = buf.indexOf(0, off);
10
10
  const stop = end >= 0 && end < off + len ? end : off + len;
11
- return buf.toString('utf8', off, stop).trim();
11
+ // 깎지 않는다 — 이름 앞뒤 빈칸도 이름이다 (6회차 Gemini 묶음6). 수 칸의 빈칸은 octal 이 걸러 낸다.
12
+ return buf.toString('utf8', off, stop);
12
13
  }
13
14
 
14
15
  function octal(buf, off, len) {
@@ -47,7 +48,9 @@ export function untargz(gz, { 상한 = 푼것상한 } = {}) {
47
48
  // 조용히 죽는 대신 왜 안 되는지 말한다. 이 말은 install 이 그대로 화면에 올린다.
48
49
  if (err?.code === 'ERR_BUFFER_TOO_LARGE') {
49
50
  // MB 로만 적으면 작은 상한이 「0MB」 가 된다 — 사람에게 아무 말도 안 하는 숫자다.
50
- const 상한말 = 상한 >= 1024 * 1024 ? `${Math.round(상한 / 1024 / 1024)}MB` : `${Math.round(상한 / 1024)}KB`;
51
+ // KB 도 같다: 1KB 밑을 KB 로 반올림하면 「0KB」 다. 그 밑은 바이트로 적는다 (6회차 Gemini 타르6bg).
52
+ const 상한말 = 상한 >= 1024 * 1024 ? `${Math.round(상한 / 1024 / 1024)}MB`
53
+ : 상한 >= 1024 ? `${Math.round(상한 / 1024)}KB` : `${상한}바이트`;
51
54
  throw new Error(`푼 크기가 상한(${상한말})을 넘습니다`
52
55
  + ' — 압축만 작고 풀면 몇 GB 가 되는 묶음입니다. 저장소가 맞는지 확인하세요.');
53
56
  }
@@ -59,17 +62,49 @@ export function untargz(gz, { 상한 = 푼것상한 } = {}) {
59
62
 
60
63
  while (pos + BLOCK <= buf.length) {
61
64
  const head = buf.subarray(pos, pos + BLOCK);
62
- // 빈 블록 두 개면 끝.
65
+ // 빈 머리 블록이면 끝. 규격은 끝에 빈 블록 **두 개**를 두지만 첫 개에서 멈춘다 — 참 머리는
66
+ // 체크섬 칸이 있어 모두 0일 수 없고 알맹이는 크기만큼 건너뛰므로, 머리 자리의 빈 블록은
67
+ // 끝에서만 나온다. GNU tar 도 `-i` 없이는 여기서 멈춘다 (6회차 Gemini 타르6bg).
63
68
  if (head.every((b) => b === 0)) break;
64
69
 
65
70
  const size = octal(head, 124, 12);
66
- const type = String.fromCharCode(head[156]) || '0';
71
+ /*
72
+ * 갈래(typeflag)는 **여기 한 자리에서만** 정한다.
73
+ *
74
+ * 규격은 정규 파일을 `'0'` 과 NUL 둘 다로 적게 허락한다(옛 tar 가 NUL 로
75
+ * 적는다). 전에는 `String.fromCharCode(head[156]) || '0'` 이었는데,
76
+ * `String.fromCharCode(0)` 은 빈 글자가 아니라 NUL 한 글자라 언제나 참이다 —
77
+ * 그 `|| '0'` 은 어떤 묶음에도 안 걸리는 죽은 폴백이었고, 실제로 NUL 을
78
+ * 살린 것은 아래 「type === '\0'」 한 마디였다. 한 가지를 두 자리에서
79
+ * 따지면 한쪽만 고치는 날이 오고, 그날 NUL 로 지은 묶음은 파일 0개로
80
+ * 조용히 풀린다. 그래서 읽는 즉시 '0' 으로 맞춰 두고 아래는 '0' 만 본다.
81
+ */
82
+ const type = head[156] === 0 ? '0' : String.fromCharCode(head[156]);
67
83
  const mode = octal(head, 100, 8);
68
- const prefix = str(head, 345, 155);
84
+ // 345 자리가 이름 앞머리인 것은 POSIX ustar(매직 「ustar」 + NUL)뿐이다. GNU 머리(「ustar」 + 빈칸 둘)는
85
+ // 거기에 atime 을 적어, 증분 묶음이면 `14325671234/file.txt` 로 풀렸다 (6회차 Gemini 묶음6).
86
+ const posix = head[262] === 0 && head.toString('latin1', 257, 262) === 'ustar';
87
+ const prefix = posix ? str(head, 345, 155) : '';
69
88
  let name = str(head, 0, 100);
70
89
  if (prefix) name = `${prefix}/${name}`;
71
90
 
72
91
  pos += BLOCK;
92
+ /*
93
+ * ── 머리가 적은 크기만큼 알맹이가 있나 ──────────────────────────────
94
+ *
95
+ * `subarray` 는 끝을 넘으면 **있는 데까지만** 준다. 그래서 머리에 100000
96
+ * 바이트라고 적힌 파일이 몇 바이트짜리로 조용히 풀렸다 (2.0.0 4회차 사냥).
97
+ * 스크립트가 반만 깔린 플러그인은 멀쩡해 보이다가 엉뚱한 자리에서 선다.
98
+ * 반쪽을 깔아 두느니 안 까는 편이 낫다 — 던지면 install 이 이 말을 화면에 올린다.
99
+ *
100
+ * 알맹이 뒤의 자투리(512 바이트 맞춤)나 끝의 빈 블록이 없는 것은 봐준다.
101
+ * 그런 묶음은 흔하고, 파일 내용은 다 있다.
102
+ */
103
+ if (pos + size > buf.length) {
104
+ const 보일이름 = longName && !'Lxg'.includes(type) ? longName : name;
105
+ throw new Error(`묶음이 중간에 잘렸습니다 — '${보일이름}' 은 머리에 ${size}바이트라고 적혀 있는데 `
106
+ + `${Math.max(0, buf.length - pos)}바이트뿐입니다. 받다가 끊겼거나 망가진 묶음입니다.`);
107
+ }
73
108
  const data = buf.subarray(pos, pos + size);
74
109
  pos += Math.ceil(size / BLOCK) * BLOCK;
75
110
 
@@ -78,16 +113,18 @@ export function untargz(gz, { 상한 = 푼것상한 } = {}) {
78
113
  continue;
79
114
  }
80
115
  if (type === 'x' || type === 'g') { // pax 확장 머리 — path 만 본다
81
- const m = /\d+ path=([^\n]+)\n/.exec(data.toString('utf8'));
116
+ // 기록의 머리(맨 앞이나 줄바꿈 뒤)에서만 본다. 다른 값 안의 「20 path=…」 를 이름으로 읽었다 (6회차 Gemini 묶음6).
117
+ const m = /(?:^|\n)\d+ path=([^\n]+)\n/.exec(data.toString('utf8'));
82
118
  if (m) longName = m[1];
83
119
  continue;
84
120
  }
85
121
  if (longName) { name = longName; longName = null; }
86
122
 
87
- if (type === '0' || type === '\0' || type === '') {
123
+ if (type === '0') {
88
124
  out.push({ name: name.replace(/\\/g, '/'), data: Buffer.from(data), mode: mode || 0o644 });
89
125
  }
90
- // 폴더('5')·링크 등은 건너뛴다. 파일만 있으면 폴더는 만들면서 채운다.
126
+ // 위에서 '0' 으로 맞춰 둔 정규 파일만 담는다. 폴더('5')·링크 등은 건너뛴다 —
127
+ // 파일만 있으면 폴더는 만들면서 채운다.
91
128
  }
92
129
  return out;
93
130
  }
package/src/pack/zip.js CHANGED
@@ -31,9 +31,31 @@ function dosTime(d) {
31
31
  function dosDate(d) {
32
32
  return (((d.getFullYear() - 1980) << 9) | ((d.getMonth() + 1) << 5) | d.getDate()) & 0xffff;
33
33
  }
34
+ /*
35
+ * DOS 날짜의 해는 1980 부터 일곱 비트, 즉 2107 까지다 (6회차 Gemini 묶음6).
36
+ *
37
+ * 묶기는 파일 mtime 을 그대로 싣는데, 1970 시각인 파일(재현 빌드·풀어 온 묶음)은 `-10 << 9` 가 위 비트를
38
+ * 채워 목록에 **2098 년**으로 적혔고, 2108 년은 0 으로 돌아 1980 이 됐다. 담을 수 있는 끝에 붙인다.
39
+ */
40
+ function 담을시각(d) {
41
+ const 해 = d.getFullYear();
42
+ if (!(해 >= 1980)) return new Date(1980, 0, 1);
43
+ if (해 > 2107) return new Date(2107, 11, 31, 23, 59, 58);
44
+ return d;
45
+ }
34
46
 
35
47
  const UTF8_NAMES = 0x0800; // 플래그 11번 비트 — 이름이 UTF-8 이라는 표시
36
48
 
49
+ /*
50
+ * 한 꾸러미에서 풀어 줄 크기의 **합** (2.0.0 3회차 사냥).
51
+ *
52
+ * readZip 은 항목 하나를 64MB 에서 멈춘다. 그런데 합은 안 봤다 — 64MB 로 부푸는 몇십 KB 짜리 조각을
53
+ * 수백 개 담은 .docx 하나면 푼 버퍼가 전부 Map 에 붙들린 채 메모리가 바닥나 대화까지 같이 죽는다.
54
+ * 모델이 시키는 대로 여는 파일이라 남이 정한 바이트다. tar 쪽 푼것상한(256MB)과 같은 크기로 둔다.
55
+ * 넘는 항목은 건너뛴 것(skipped)으로 이름과 까닭을 남긴다.
56
+ */
57
+ export const 풀기총상한 = 256 * 1024 * 1024;
58
+
37
59
  /**
38
60
  * @param {Array<{name:string, data:Buffer, mtime?:Date, mode?:number}>} entries
39
61
  * name 은 zip 안에서의 경로. 구분자는 항상 '/'.
@@ -56,7 +78,7 @@ export function makeZip(entries) {
56
78
  const body = useDeflate ? packed : raw;
57
79
  const method = useDeflate ? 8 : 0;
58
80
 
59
- const when = e.mtime ?? new Date();
81
+ const when = 담을시각(e.mtime ?? new Date());
60
82
  const time = dosTime(when);
61
83
  const date = dosDate(when);
62
84
 
@@ -91,7 +113,9 @@ export function makeZip(entries) {
91
113
  ch.writeUInt16LE(0, 34);
92
114
  ch.writeUInt16LE(0, 36);
93
115
  // 바깥 속성 위쪽 16비트에 유닉스 권한. bin 의 실행 권한이 여기 실린다.
94
- ch.writeUInt32LE(((e.mode ?? 0o644) & 0xffff) << 16, 38);
116
+ // `<< 16` 은 부호 있는 32비트라 파일 종류 비트(statSync 의 0o100755)가 붙으면 음수가 되어
117
+ // writeUInt32LE 가 던졌다 — `>>> 0` 으로 부호 없는 수로 돌린다 (6회차 Gemini 묶음6).
118
+ ch.writeUInt32LE((((e.mode ?? 0o644) & 0xffff) << 16) >>> 0, 38);
95
119
  ch.writeUInt32LE(offset, 42);
96
120
  central.push(ch, nameBuf);
97
121
 
@@ -124,10 +148,18 @@ const LOC_SIG = 0x04034b50;
124
148
  function findEocd(buf) {
125
149
  // 주석이 붙어 있을 수 있어서 끝에서 최대 64KB 를 뒤로 훑는다.
126
150
  const 끝 = Math.max(0, buf.length - 22 - 0xffff);
151
+ /*
152
+ * 주석은 아무 바이트나 담는다 — 같은 네 바이트가 주석 안에 있으면 뒤에서 처음 만난 그것을 끝표식으로
153
+ * 읽어 목록 0개를 조용히 냈다 (6회차 Gemini 묶음6). 진짜 끝표식은 **목록이 바로 앞에서 끝난다**
154
+ * (시작 + 크기 = 제자리). 그런 것이 없으면(앞에 딴 바이트가 붙은 자체 풀림 exe 등) 예전대로 처음 것.
155
+ */
156
+ let 처음 = -1;
127
157
  for (let i = buf.length - 22; i >= 끝; i--) {
128
- if (buf.readUInt32LE(i) === EOCD_SIG) return i;
158
+ if (buf.readUInt32LE(i) !== EOCD_SIG) continue;
159
+ if (처음 < 0) 처음 = i;
160
+ if (buf.readUInt32LE(i + 16) + buf.readUInt32LE(i + 12) === i) return i;
129
161
  }
130
- return -1;
162
+ return 처음;
131
163
  }
132
164
 
133
165
  /**
@@ -141,9 +173,41 @@ function findEocd(buf) {
141
173
  * @param {{ only?: (name:string)=>boolean }} [opt] 필요한 것만 풀고 싶을 때
142
174
  * @returns {{ files: Map<string,Buffer>, skipped: Array<{name:string, why:string}> }}
143
175
  */
144
- export function readZip(buf, { only = null } = {}) {
176
+ export function readZip(buf, { only = null, 총상한 = 풀기총상한 } = {}) {
145
177
  const files = new Map();
146
178
  const skipped = [];
179
+ // 푼 크기의 합. 항목마다 64MB 상한만 두면 64MB 로 부푸는 조각 수백 개로 메모리가 바닥난다(풀기총상한 머리말).
180
+ let 푼합 = 0;
181
+ /*
182
+ * 건너뛴 까닭을 **둘로 가른다** (2.0.0 8회차 판정).
183
+ *
184
+ * 여태 한 가지 말만 남겼다 — 「나머지는 안 풀었습니다」. 그런데 코드는
185
+ * `continue` 라서 **뒤따르는 작은 항목은 그대로 푼다.** 동작은 옳다(자리에
186
+ * 들어가는 만큼은 준다). 틀린 것은 말이었다. 받는 쪽은 그 뒤가 비었다고 읽는다.
187
+ *
188
+ * · 자리가 아예 없다(남은 ≤ 0) → 이 뒤로는 정말 아무것도 안 푼다
189
+ * · 이 항목 하나가 자리보다 크다 → 이것만 건너뛰고 뒤엣것은 계속 푼다
190
+ */
191
+ const MB = Math.round(총상한 / 1024 / 1024 * 10) / 10;
192
+ const 자리없음 = () => `푼 크기의 합이 상한(${MB}MB)을 다 씀 — 이 뒤로는 아무것도 안 풀었습니다`;
193
+ const 혼자큼 = (크기, 남은) => `이 항목 하나가 남은 자리보다 큼 (${크기} > ${남은}, 상한 ${MB}MB) — 이것만 건너뛰고 뒤엣것은 계속 풉니다`;
194
+ /*
195
+ * ── 푼 내용이 목록의 CRC32 와 맞나 ─────────────────────────────────────
196
+ *
197
+ * 크기만 봤다 (2.0.0 4회차 사냥). 담기(store)로 들어간 알맹이 한 바이트가 망가져도,
198
+ * deflate 가 크기는 맞게 풀리는데 내용이 틀려도 그대로 「읽었다」 로 내놨다. xlsx 라면
199
+ * 숫자 하나가 소리 없이 바뀐 표다. 틀리면 건너뛴 것으로 이름과 까닭을 남긴다 —
200
+ * 이 함수 머리말대로 조용히 넘기지 않는다.
201
+ *
202
+ * 값: 표 기반 JS CRC32 가 64MB 에 0.1초 남짓이라 풀기총상한(256MB)을 다 채워도 1초 안이다.
203
+ */
204
+ const CRC틀림 = (name, 내용, 적힌) => {
205
+ const 실제 = crc32(내용);
206
+ if (실제 === 적힌) return false;
207
+ const 열여섯 = (n) => n.toString(16).padStart(8, '0');
208
+ skipped.push({ name, why: `CRC 가 안 맞음 — 내용이 망가졌습니다 (목록 ${열여섯(적힌)} ≠ 실제 ${열여섯(실제)})` });
209
+ return true;
210
+ };
147
211
 
148
212
  const eocd = findEocd(buf);
149
213
  if (eocd < 0) throw new Error('zip 이 아닙니다 — 끝에 있어야 할 표식을 못 찾았습니다');
@@ -163,6 +227,9 @@ export function readZip(buf, { only = null } = {}) {
163
227
  }
164
228
  const method = buf.readUInt16LE(p + 10);
165
229
  const flags = buf.readUInt16LE(p + 8);
230
+ // 로컬 머리의 CRC 가 아니라 중앙 목록의 것을 쓴다. 플래그 3번 비트(뒤에 붙는 기술자)가
231
+ // 선 항목은 로컬 머리에 0 이 적혀 있고, 중앙 목록에는 늘 참값이 있다.
232
+ const 적힌crc = buf.readUInt32LE(p + 16);
166
233
  const compSize = buf.readUInt32LE(p + 20);
167
234
  const rawSize = buf.readUInt32LE(p + 24);
168
235
  const nameLen = buf.readUInt16LE(p + 28);
@@ -171,7 +238,9 @@ export function readZip(buf, { only = null } = {}) {
171
238
  const localAt = buf.readUInt32LE(p + 42);
172
239
  // 이름은 UTF-8 표시가 있으면 UTF-8, 없으면 예전 zip 관례대로 그냥 바이트다.
173
240
  // xlsx 는 안쪽 이름이 전부 ASCII 라 어느 쪽이든 같다.
174
- const name = buf.subarray(p + 46, p + 46 + nameLen).toString('utf8');
241
+ // 옛 .NET(ZipFile, 4.6.1 전)은 이름을 역슬래시로 적는다. 목록에서 'xl/workbook.xml' 을 찾는 쪽이
242
+ // 못 찾아 표가 비어 보였다 — 표준(APPNOTE 4.4.17)의 '/' 로 맞춘다 (6회차 Gemini 묶음6).
243
+ const name = buf.subarray(p + 46, p + 46 + nameLen).toString('utf8').replace(/\\/g, '/');
175
244
  p += 46 + nameLen + extraLen + commentLen;
176
245
 
177
246
  if (name.endsWith('/')) continue; // 폴더
@@ -190,8 +259,21 @@ export function readZip(buf, { only = null } = {}) {
190
259
  const at = localAt + 30 + lnLen + leLen;
191
260
  const body = buf.subarray(at, at + compSize);
192
261
 
262
+ const 남은 = 총상한 - 푼합;
263
+ const 이크기 = method === 0 ? body.length : rawSize;
264
+ if (남은 <= 0) { skipped.push({ name, why: 자리없음() }); continue; }
265
+ if (이크기 > 남은) { skipped.push({ name, why: 혼자큼(이크기, 남은) }); continue; }
193
266
  if (method === 0) {
267
+ // 담기(store)는 누른 크기와 푼 크기가 같아야 한다. 목록이 다른 숫자를
268
+ // 적어 뒀으면 둘 중 하나가 거짓말이다 — 어느 쪽이든 그대로 쓰면 안 된다.
269
+ if (body.length !== rawSize) {
270
+ skipped.push({ name, why: `푼 크기가 안 맞음 (${body.length} ≠ ${rawSize})` });
271
+ continue;
272
+ }
273
+ // 알맹이가 버퍼 끝에서 잘려 짧아진 것도 여기서 CRC 로 걸린다.
274
+ if (CRC틀림(name, body, 적힌crc)) continue;
194
275
  files.set(name, Buffer.from(body));
276
+ 푼합 += body.length;
195
277
  } else if (method === 8) {
196
278
  try {
197
279
  /*
@@ -211,13 +293,26 @@ export function readZip(buf, { only = null } = {}) {
211
293
  * 것과 달리 **메모리를 안 쓰고** 멈춘다.
212
294
  */
213
295
  const 한항목상한 = 64 * 1024 * 1024; // 이 프로그램이 여는 문서의 현실적 위쪽
214
- const 상한 = Math.min(rawSize > 0 ? rawSize : 한항목상한, 한항목상한);
296
+ const 상한 = Math.min(rawSize > 0 ? rawSize : 한항목상한, 한항목상한, 남은);
215
297
  const out = inflateRawSync(body, { maxOutputLength: 상한 });
216
- if (rawSize && out.length !== rawSize) {
298
+ /*
299
+ * `rawSize &&` 가 앞에 있었다 (2.0.0 8회차 판정).
300
+ *
301
+ * 목록이 「이 항목은 0바이트」 라고 우기면 `&&` 가 거기서 멈춰 **크기
302
+ * 대조를 통째로 건너뛰었다.** 0 이라고 적힌 항목이 몇십 바이트로 풀려
303
+ * 나와도 그대로 「읽었다」 가 됐다. 크기를 안 보던 4회차 그 자리와 같은 꼴이다.
304
+ *
305
+ * 가운데 목록(central directory)의 크기 칸은 흘려보내기(data descriptor)를
306
+ * 쓴 묶음에서도 늘 채워져 있다. 그래서 조건 없이 대 봐도 된다 —
307
+ * 진짜 0바이트 파일은 `out.length` 도 0 이라 그대로 지나간다.
308
+ */
309
+ if (out.length !== rawSize) {
217
310
  skipped.push({ name, why: `푼 크기가 안 맞음 (${out.length} ≠ ${rawSize})` });
218
311
  continue;
219
312
  }
313
+ if (CRC틀림(name, out, 적힌crc)) continue;
220
314
  files.set(name, out);
315
+ 푼합 += out.length;
221
316
  } catch (err) {
222
317
  skipped.push({ name, why: `풀지 못함 — ${err.message}` });
223
318
  }