deel-local-cli 0.5.0 → 0.8.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.
- package/README.en.md +526 -135
- package/README.md +652 -149
- package/bin/deel.js +67 -1
- package/package.json +4 -3
- package/src/agent/compact.js +138 -0
- package/src/agent/effort.js +139 -0
- package/src/agent/loop.js +186 -60
- package/src/agent/modes.js +153 -0
- package/src/agent/session.js +14 -3
- package/src/agent/sessionui.js +59 -0
- package/src/agent/store.js +193 -0
- package/src/backend/adapter.js +12 -2
- package/src/backend/http.js +21 -3
- package/src/backend/scan.js +162 -0
- package/src/backend/scanui.js +147 -0
- package/src/commands.js +307 -17
- package/src/config.js +19 -6
- package/src/pack/selfpack.js +248 -0
- package/src/pack/tar.js +69 -0
- package/src/pack/zip.js +217 -0
- package/src/plugins/manage.js +272 -0
- package/src/repl.js +230 -38
- package/src/safety/network.js +95 -0
- package/src/safety/undo.js +46 -1
- package/src/setup.js +4 -0
- package/src/tools/encoding.js +333 -0
- package/src/tools/excel-com.js +254 -0
- package/src/tools/excel.js +118 -0
- package/src/tools/fsutil.js +47 -4
- package/src/tools/index.js +128 -14
- package/src/tools/todo.js +92 -0
- package/src/tools/webfetch.js +110 -0
- package/src/tools/xlsx.js +319 -0
- package/src/ui/ansi.js +78 -4
- package/src/ui/level.js +104 -0
- package/src/ui/prompt.js +34 -0
- package/src/ui/status.js +151 -0
|
@@ -0,0 +1,248 @@
|
|
|
1
|
+
// deel 자기 자신을 사내 반입용으로 묶는다.
|
|
2
|
+
//
|
|
3
|
+
// 반입 심사에서 실제로 물어보는 것은 셋이다.
|
|
4
|
+
// 1) 남의 코드가 섞여 있나 → 의존성 목록
|
|
5
|
+
// 2) 설치만 해도 뭔가 도나 → 생명주기 스크립트
|
|
6
|
+
// 3) 바깥 어디로 말을 거나 → 네트워크·외부 명령 호출 자리
|
|
7
|
+
// 이 세 가지를 사람이 읽을 수 있는 심사서로 뽑아서 소스와 같이 담는다.
|
|
8
|
+
// 소스를 읽고 쓰는 것은 전부 코드에서 훑는다 — 손으로 적으면 언젠가 사실과 어긋난다.
|
|
9
|
+
import { createHash } from 'node:crypto';
|
|
10
|
+
import { readFileSync, readdirSync, statSync, existsSync, mkdirSync, writeFileSync } from 'node:fs';
|
|
11
|
+
import { join, dirname, relative } from 'node:path';
|
|
12
|
+
import { fileURLToPath } from 'node:url';
|
|
13
|
+
import { makeZip } from './zip.js';
|
|
14
|
+
|
|
15
|
+
export const repoRoot = () => join(dirname(fileURLToPath(import.meta.url)), '..', '..');
|
|
16
|
+
|
|
17
|
+
const SKIP_DIRS = new Set(['node_modules', '.git', '.github', 'test', '.deel']);
|
|
18
|
+
const SHIP = ['bin', 'src'];
|
|
19
|
+
const SHIP_FILES = ['package.json', 'README.md', 'README.en.md', 'LICENSE'];
|
|
20
|
+
|
|
21
|
+
function walk(dir, base, out) {
|
|
22
|
+
for (const e of readdirSync(dir, { withFileTypes: true })) {
|
|
23
|
+
if (e.name.startsWith('.') && e.name !== '.gitattributes') continue;
|
|
24
|
+
const full = join(dir, e.name);
|
|
25
|
+
if (e.isDirectory()) {
|
|
26
|
+
if (!SKIP_DIRS.has(e.name)) walk(full, base, out);
|
|
27
|
+
continue;
|
|
28
|
+
}
|
|
29
|
+
out.push(relative(base, full).split(/[\\/]/).join('/'));
|
|
30
|
+
}
|
|
31
|
+
return out;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function shippedFiles(root = repoRoot()) {
|
|
35
|
+
const out = [];
|
|
36
|
+
for (const d of SHIP) if (existsSync(join(root, d))) walk(join(root, d), root, out);
|
|
37
|
+
for (const f of SHIP_FILES) if (existsSync(join(root, f))) out.push(f);
|
|
38
|
+
return out.sort();
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const sha256 = (buf) => createHash('sha256').update(buf).digest('hex');
|
|
42
|
+
|
|
43
|
+
// 바깥과 말을 섞을 수 있는 자리를 소스에서 직접 찾는다.
|
|
44
|
+
// 앞에 점이나 글자가 붙은 것은 뺀다. 그러지 않으면 정규식의 .exec( 까지
|
|
45
|
+
// '외부 명령 실행' 으로 세어 심사서가 거짓말을 한다.
|
|
46
|
+
const PROBES = [
|
|
47
|
+
{ id: 'net', label: '네트워크 요청', re: /(?<![.\w])fetch\s*\(/g,
|
|
48
|
+
note: '사용자가 setup 에서 넣은 주소로만 나갑니다' },
|
|
49
|
+
{ id: 'exec', label: '외부 명령 실행', re: /(?<![.\w])(execFile|execFileSync|execSync|spawn|spawnSync|exec)\s*\(/g,
|
|
50
|
+
note: '사용자·모델이 지시한 명령, 그리고 플러그인 받을 때의 git' },
|
|
51
|
+
{ id: 'listen', label: '포트 열기', re: /(?<![.\w])(createServer)\s*\(/g,
|
|
52
|
+
note: '없어야 정상입니다' },
|
|
53
|
+
{ id: 'eval', label: '문자열 실행', re: /(?<![.\w])(eval|new\s+Function)\s*\(/g,
|
|
54
|
+
note: '없어야 정상입니다' },
|
|
55
|
+
];
|
|
56
|
+
|
|
57
|
+
export function scanCalls(root = repoRoot(), files = shippedFiles(root)) {
|
|
58
|
+
const found = Object.fromEntries(PROBES.map((p) => [p.id, []]));
|
|
59
|
+
for (const f of files) {
|
|
60
|
+
if (!f.endsWith('.js')) continue;
|
|
61
|
+
const lines = readFileSync(join(root, f), 'utf8').split(/\r?\n/);
|
|
62
|
+
lines.forEach((line, i) => {
|
|
63
|
+
if (/^\s*(\/\/|\*)/.test(line)) return; // 주석은 세지 않는다
|
|
64
|
+
for (const p of PROBES) {
|
|
65
|
+
p.re.lastIndex = 0;
|
|
66
|
+
if (p.re.test(line)) found[p.id].push({ file: f, line: i + 1, text: line.trim().slice(0, 78) });
|
|
67
|
+
}
|
|
68
|
+
});
|
|
69
|
+
}
|
|
70
|
+
return found;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* 소스에서 불러오는 모듈 이름만 뽑는다.
|
|
75
|
+
*
|
|
76
|
+
* 그냥 정규식으로 import 를 찾으면 화면 문구 안의 "외부 import" 같은 글자까지 잡힌다.
|
|
77
|
+
* 실제로 그렇게 잡혀서 심사서에 없는 의존성이 적혔다. 그래서 두 가지를 함께 본다.
|
|
78
|
+
* 1) import / require 가 낱말로 서 있을 것
|
|
79
|
+
* 2) 따온 값이 모듈 이름처럼 생겼을 것 (공백·괄호·${ 가 없다)
|
|
80
|
+
*/
|
|
81
|
+
const MODULE_NAME = /^[@\w./:-]+$/;
|
|
82
|
+
|
|
83
|
+
export function importSpecs(text) {
|
|
84
|
+
const out = [];
|
|
85
|
+
const re = /(?:^|[\s;{(])(?:import[^'"()]*from\s*|import\s*|require\s*\(\s*)(['"])([^'"]+)\1/g;
|
|
86
|
+
for (const m of text.matchAll(re)) {
|
|
87
|
+
const spec = m[2];
|
|
88
|
+
if (MODULE_NAME.test(spec)) out.push(spec);
|
|
89
|
+
}
|
|
90
|
+
return out;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export function audit(root = repoRoot()) {
|
|
94
|
+
const pkg = JSON.parse(readFileSync(join(root, 'package.json'), 'utf8'));
|
|
95
|
+
const files = shippedFiles(root);
|
|
96
|
+
const LIFECYCLE = ['preinstall', 'install', 'postinstall', 'prepare', 'prepublish'];
|
|
97
|
+
|
|
98
|
+
const 외부모듈 = [];
|
|
99
|
+
for (const f of files) {
|
|
100
|
+
if (!f.endsWith('.js')) continue;
|
|
101
|
+
for (const s of importSpecs(readFileSync(join(root, f), 'utf8'))) {
|
|
102
|
+
if (s.startsWith('node:') || s.startsWith('.') || s.startsWith('/')) continue;
|
|
103
|
+
외부모듈.push(`${f} → ${s}`);
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
return {
|
|
108
|
+
name: pkg.name,
|
|
109
|
+
version: pkg.version,
|
|
110
|
+
license: pkg.license,
|
|
111
|
+
node: pkg.engines?.node ?? '(지정 없음)',
|
|
112
|
+
deps: Object.keys(pkg.dependencies ?? {}),
|
|
113
|
+
devDeps: Object.keys(pkg.devDependencies ?? {}),
|
|
114
|
+
lifecycle: LIFECYCLE.filter((k) => pkg.scripts?.[k]),
|
|
115
|
+
외부모듈,
|
|
116
|
+
files: files.map((f) => {
|
|
117
|
+
const buf = readFileSync(join(root, f));
|
|
118
|
+
return { path: f, bytes: buf.length, sha: sha256(buf) };
|
|
119
|
+
}),
|
|
120
|
+
calls: scanCalls(root, files),
|
|
121
|
+
};
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
const 줄 = (n = 74) => '-'.repeat(n);
|
|
125
|
+
|
|
126
|
+
export function reviewSheet(a, at) {
|
|
127
|
+
const L = [];
|
|
128
|
+
L.push('deel 사내 반입 심사 자료');
|
|
129
|
+
L.push(줄());
|
|
130
|
+
L.push(`이름 ${a.name}`);
|
|
131
|
+
L.push(`판 ${a.version}`);
|
|
132
|
+
L.push(`라이선스 ${a.license}`);
|
|
133
|
+
L.push(`실행 환경 Node ${a.node} (표준 내장 기능만 사용)`);
|
|
134
|
+
L.push(`만든 시각 ${at}`);
|
|
135
|
+
L.push('');
|
|
136
|
+
|
|
137
|
+
L.push('1. 외부 의존성');
|
|
138
|
+
L.push(줄());
|
|
139
|
+
L.push(` dependencies ${a.deps.length}개${a.deps.length ? ' ' + a.deps.join(', ') : ' ← 남의 코드를 함께 들여오지 않습니다'}`);
|
|
140
|
+
L.push(` devDependencies ${a.devDeps.length}개`);
|
|
141
|
+
L.push(` 소스의 외부 import ${a.외부모듈.length}건${a.외부모듈.length ? '' : ' ← node: 내장과 자기 파일만 부릅니다'}`);
|
|
142
|
+
for (const x of a.외부모듈) L.push(` ${x}`);
|
|
143
|
+
L.push('');
|
|
144
|
+
|
|
145
|
+
L.push('2. 설치할 때 저절로 도는 코드');
|
|
146
|
+
L.push(줄());
|
|
147
|
+
L.push(a.lifecycle.length
|
|
148
|
+
? ` 있음: ${a.lifecycle.join(', ')} ← 심사 필요`
|
|
149
|
+
: ' 없음 ← preinstall / install / postinstall / prepare 전부 없습니다');
|
|
150
|
+
L.push(' 압축을 풀고 `node bin/deel.js` 로 바로 씁니다. 설치 절차가 없습니다.');
|
|
151
|
+
L.push('');
|
|
152
|
+
|
|
153
|
+
L.push('3. 바깥으로 나가는 자리 (소스를 훑어 찾은 전부)');
|
|
154
|
+
L.push(줄());
|
|
155
|
+
for (const p of PROBES) {
|
|
156
|
+
const hits = a.calls[p.id];
|
|
157
|
+
L.push(` [${p.label}] ${hits.length}건 ${p.note}`);
|
|
158
|
+
for (const h of hits) L.push(` ${h.file}:${h.line} ${h.text}`);
|
|
159
|
+
if (!hits.length) L.push(' (없음)');
|
|
160
|
+
}
|
|
161
|
+
L.push('');
|
|
162
|
+
L.push(' ※ 접속 주소는 소스에 박혀 있지 않습니다. deel setup 에서 넣은 값만 씁니다.');
|
|
163
|
+
L.push(' 설정 파일: ~/.deel/config.json (또는 환경변수 DEEL_API_KEY)');
|
|
164
|
+
L.push('');
|
|
165
|
+
|
|
166
|
+
L.push('3-1. 나가는 길은 두 갈래이고 서로 섞이지 않습니다');
|
|
167
|
+
L.push(줄());
|
|
168
|
+
L.push(' [A] 모델 게이트웨이 — 소스 코드가 실려 나가는 유일한 길');
|
|
169
|
+
L.push(' · 주소: deel setup 에서 정한 곳 딱 한 자리');
|
|
170
|
+
L.push(' · 그 한 자리만 허용 목록에 오릅니다. 모델을 바꾸면 앞의 자리는 닫힙니다.');
|
|
171
|
+
L.push(' · src/safety/network.js 가 요청마다 확인하고, 목록에 없으면 요청 자체를');
|
|
172
|
+
L.push(' 만들지 않습니다.');
|
|
173
|
+
L.push('');
|
|
174
|
+
L.push(' [B] 웹 읽기 (WebFetch 도구) — 받아 오기만 하는 길');
|
|
175
|
+
L.push(' · GET 만 씁니다. 본문을 실어 보내지 않습니다 (소스·대화가 나갈 수 없음).');
|
|
176
|
+
L.push(' · 이 컴퓨터·사내망(127.*, 10.*, 192.168.*, 172.16~31.*) 주소는 거절합니다.');
|
|
177
|
+
L.push(' · 쓰는 동안만 그 자리를 열고 끝나면 바로 닫습니다.');
|
|
178
|
+
L.push(' · 다녀온 주소는 전부 기록에 남습니다.');
|
|
179
|
+
L.push('');
|
|
180
|
+
L.push(' [C] 플러그인 받기 (github) — 사용자가 /plugin install 을 칠 때만');
|
|
181
|
+
L.push(' · 그 명령이 도는 동안만 열리고 끝나면 닫힙니다.');
|
|
182
|
+
L.push('');
|
|
183
|
+
L.push(' --offline 으로 켜면 [B] 와 [C] 가 모두 막히고, 이 컴퓨터 안으로만 다닙니다.');
|
|
184
|
+
L.push(' (검증: npm test 안의 network / web 검사 54항목이 이를 확인합니다)');
|
|
185
|
+
L.push('');
|
|
186
|
+
|
|
187
|
+
L.push('4. 스킬·플러그인');
|
|
188
|
+
L.push(줄());
|
|
189
|
+
L.push(' 이 묶음에는 스킬도 플러그인도 들어 있지 않습니다.');
|
|
190
|
+
L.push(' 설치된 PC 의 ~/.claude, ~/.deel, 프로젝트 폴더를 읽어서 쓸 뿐입니다.');
|
|
191
|
+
L.push(' (검증: npm test 안의 no-bundle 검사가 이를 확인합니다)');
|
|
192
|
+
L.push('');
|
|
193
|
+
|
|
194
|
+
L.push(`5. 담긴 파일 ${a.files.length}개 — SHA-256`);
|
|
195
|
+
L.push(줄());
|
|
196
|
+
const w = Math.max(...a.files.map((f) => f.path.length));
|
|
197
|
+
for (const f of a.files) {
|
|
198
|
+
L.push(` ${f.path.padEnd(w)} ${String(f.bytes).padStart(7)}B ${f.sha}`);
|
|
199
|
+
}
|
|
200
|
+
L.push('');
|
|
201
|
+
L.push(' 위 값은 다음으로 다시 확인할 수 있습니다:');
|
|
202
|
+
L.push(' sha256sum <파일> (리눅스·맥)');
|
|
203
|
+
L.push(' certutil -hashfile <파일> SHA256 (윈도우)');
|
|
204
|
+
L.push('');
|
|
205
|
+
return L.join('\n');
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/**
|
|
209
|
+
* 심사서 + 소스를 zip 하나로 묶는다.
|
|
210
|
+
*/
|
|
211
|
+
export function packSelf(outFile, { root = repoRoot(), at = new Date() } = {}) {
|
|
212
|
+
const a = audit(root);
|
|
213
|
+
const stamp = at.toISOString().replace('T', ' ').slice(0, 19);
|
|
214
|
+
const sheet = reviewSheet(a, stamp);
|
|
215
|
+
|
|
216
|
+
const entries = [{ name: '반입심사서.txt', data: Buffer.from(sheet, 'utf8'), mtime: at }];
|
|
217
|
+
for (const f of a.files) {
|
|
218
|
+
entries.push({
|
|
219
|
+
name: `deel/${f.path}`,
|
|
220
|
+
data: readFileSync(join(root, f.path)),
|
|
221
|
+
mtime: statSync(join(root, f.path)).mtime,
|
|
222
|
+
mode: f.path.startsWith('bin/') ? 0o755 : 0o644,
|
|
223
|
+
});
|
|
224
|
+
}
|
|
225
|
+
entries.push({
|
|
226
|
+
name: '읽어주세요.txt',
|
|
227
|
+
data: Buffer.from([
|
|
228
|
+
'deel — 로컬 모델·사내 게이트웨이 코딩 에이전트',
|
|
229
|
+
'',
|
|
230
|
+
'쓰는 법 (설치 절차 없음)',
|
|
231
|
+
' 1. 이 zip 을 아무 폴더에나 풉니다.',
|
|
232
|
+
' 2. Node 20 이상이 깔려 있는지 봅니다: node -v',
|
|
233
|
+
' 3. 연결을 정합니다: node deel/bin/deel.js setup',
|
|
234
|
+
' 4. 대화를 시작합니다: node deel/bin/deel.js',
|
|
235
|
+
'',
|
|
236
|
+
'심사 담당자께',
|
|
237
|
+
' 같이 담긴 반입심사서.txt 에 의존성·설치 스크립트·네트워크 호출 자리가',
|
|
238
|
+
' 전부 적혀 있습니다. 파일별 SHA-256 도 있습니다.',
|
|
239
|
+
'',
|
|
240
|
+
].join('\n'), 'utf8'),
|
|
241
|
+
mtime: at,
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
const zip = makeZip(entries);
|
|
245
|
+
mkdirSync(dirname(outFile), { recursive: true });
|
|
246
|
+
writeFileSync(outFile, zip);
|
|
247
|
+
return { out: outFile, bytes: zip.length, files: entries.length, audit: a, sheet };
|
|
248
|
+
}
|
package/src/pack/tar.js
ADDED
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
// tar 읽기. GitHub 가 주는 tarball 을 풀기 위한 최소 구현.
|
|
2
|
+
// git 이 없는 기기에서도 플러그인을 받을 수 있어야 해서 필요하다.
|
|
3
|
+
import { gunzipSync } from 'node:zlib';
|
|
4
|
+
|
|
5
|
+
const BLOCK = 512;
|
|
6
|
+
|
|
7
|
+
function str(buf, off, len) {
|
|
8
|
+
const end = buf.indexOf(0, off);
|
|
9
|
+
const stop = end >= 0 && end < off + len ? end : off + len;
|
|
10
|
+
return buf.toString('utf8', off, stop).trim();
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
function octal(buf, off, len) {
|
|
14
|
+
const s = str(buf, off, len).replace(/[^0-7]/g, '');
|
|
15
|
+
return s ? parseInt(s, 8) : 0;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* gzip 된 tar 를 풀어 파일 목록을 돌려준다.
|
|
20
|
+
* @returns {Array<{name:string, data:Buffer, mode:number}>}
|
|
21
|
+
*/
|
|
22
|
+
export function untargz(gz) {
|
|
23
|
+
const buf = gunzipSync(gz);
|
|
24
|
+
const out = [];
|
|
25
|
+
let pos = 0;
|
|
26
|
+
let longName = null;
|
|
27
|
+
|
|
28
|
+
while (pos + BLOCK <= buf.length) {
|
|
29
|
+
const head = buf.subarray(pos, pos + BLOCK);
|
|
30
|
+
// 빈 블록 두 개면 끝.
|
|
31
|
+
if (head.every((b) => b === 0)) break;
|
|
32
|
+
|
|
33
|
+
const size = octal(head, 124, 12);
|
|
34
|
+
const type = String.fromCharCode(head[156]) || '0';
|
|
35
|
+
const mode = octal(head, 100, 8);
|
|
36
|
+
const prefix = str(head, 345, 155);
|
|
37
|
+
let name = str(head, 0, 100);
|
|
38
|
+
if (prefix) name = `${prefix}/${name}`;
|
|
39
|
+
|
|
40
|
+
pos += BLOCK;
|
|
41
|
+
const data = buf.subarray(pos, pos + size);
|
|
42
|
+
pos += Math.ceil(size / BLOCK) * BLOCK;
|
|
43
|
+
|
|
44
|
+
if (type === 'L') { // GNU 긴 이름
|
|
45
|
+
longName = data.toString('utf8').replace(/\0+$/, '');
|
|
46
|
+
continue;
|
|
47
|
+
}
|
|
48
|
+
if (type === 'x' || type === 'g') { // pax 확장 머리 — path 만 본다
|
|
49
|
+
const m = /\d+ path=([^\n]+)\n/.exec(data.toString('utf8'));
|
|
50
|
+
if (m) longName = m[1];
|
|
51
|
+
continue;
|
|
52
|
+
}
|
|
53
|
+
if (longName) { name = longName; longName = null; }
|
|
54
|
+
|
|
55
|
+
if (type === '0' || type === '\0' || type === '') {
|
|
56
|
+
out.push({ name: name.replace(/\\/g, '/'), data: Buffer.from(data), mode: mode || 0o644 });
|
|
57
|
+
}
|
|
58
|
+
// 폴더('5')·링크 등은 건너뛴다. 파일만 있으면 폴더는 만들면서 채운다.
|
|
59
|
+
}
|
|
60
|
+
return out;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// GitHub tarball 은 최상위에 <repo>-<커밋> 폴더 한 겹이 더 있다. 그걸 벗긴다.
|
|
64
|
+
export function stripTop(files) {
|
|
65
|
+
if (!files.length) return files;
|
|
66
|
+
const first = files[0].name.split('/')[0];
|
|
67
|
+
if (!files.every((f) => f.name.startsWith(first + '/'))) return files;
|
|
68
|
+
return files.map((f) => ({ ...f, name: f.name.slice(first.length + 1) }));
|
|
69
|
+
}
|
package/src/pack/zip.js
ADDED
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
// ZIP 읽기·쓰기. Node 내장 zlib 만 쓴다.
|
|
2
|
+
//
|
|
3
|
+
// 윈도우의 Compress-Archive 는 한글 파일 이름을 보장하지 못한다.
|
|
4
|
+
// 여기서는 이름을 UTF-8 로 쓰고 플래그 11번 비트를 세워, 어디서 풀어도 이름이 살아 있게 한다.
|
|
5
|
+
//
|
|
6
|
+
// 읽기는 나중에 붙였다. xlsx 가 사실은 zip 이기 때문이다 — 엑셀 파일을 열려면
|
|
7
|
+
// 먼저 이걸 풀어야 한다. 형식이 같으니 한자리에 둔다.
|
|
8
|
+
import { deflateRawSync, inflateRawSync } from 'node:zlib';
|
|
9
|
+
|
|
10
|
+
// --- CRC32 ---------------------------------------------------------------
|
|
11
|
+
const TABLE = (() => {
|
|
12
|
+
const t = new Int32Array(256);
|
|
13
|
+
for (let i = 0; i < 256; i++) {
|
|
14
|
+
let c = i;
|
|
15
|
+
for (let k = 0; k < 8; k++) c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1;
|
|
16
|
+
t[i] = c;
|
|
17
|
+
}
|
|
18
|
+
return t;
|
|
19
|
+
})();
|
|
20
|
+
|
|
21
|
+
export function crc32(buf) {
|
|
22
|
+
let c = -1;
|
|
23
|
+
for (let i = 0; i < buf.length; i++) c = TABLE[(c ^ buf[i]) & 0xff] ^ (c >>> 8);
|
|
24
|
+
return (c ^ -1) >>> 0;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
// --- DOS 날짜·시각 --------------------------------------------------------
|
|
28
|
+
function dosTime(d) {
|
|
29
|
+
return ((d.getHours() << 11) | (d.getMinutes() << 5) | (d.getSeconds() >> 1)) & 0xffff;
|
|
30
|
+
}
|
|
31
|
+
function dosDate(d) {
|
|
32
|
+
return (((d.getFullYear() - 1980) << 9) | ((d.getMonth() + 1) << 5) | d.getDate()) & 0xffff;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const UTF8_NAMES = 0x0800; // 플래그 11번 비트 — 이름이 UTF-8 이라는 표시
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* @param {Array<{name:string, data:Buffer, mtime?:Date, mode?:number}>} entries
|
|
39
|
+
* name 은 zip 안에서의 경로. 구분자는 항상 '/'.
|
|
40
|
+
* mode 는 유닉스 권한(예: 0o755). 실행 파일에 필요하다.
|
|
41
|
+
* @returns {Buffer}
|
|
42
|
+
*/
|
|
43
|
+
export function makeZip(entries) {
|
|
44
|
+
const locals = [];
|
|
45
|
+
const central = [];
|
|
46
|
+
let offset = 0;
|
|
47
|
+
|
|
48
|
+
for (const e of entries) {
|
|
49
|
+
const nameBuf = Buffer.from(e.name.replace(/\\/g, '/'), 'utf8');
|
|
50
|
+
const raw = e.data;
|
|
51
|
+
const crc = crc32(raw);
|
|
52
|
+
|
|
53
|
+
// 압축해서 더 커지면 그냥 담는다(store).
|
|
54
|
+
const packed = deflateRawSync(raw, { level: 9 });
|
|
55
|
+
const useDeflate = packed.length < raw.length;
|
|
56
|
+
const body = useDeflate ? packed : raw;
|
|
57
|
+
const method = useDeflate ? 8 : 0;
|
|
58
|
+
|
|
59
|
+
const when = e.mtime ?? new Date();
|
|
60
|
+
const time = dosTime(when);
|
|
61
|
+
const date = dosDate(when);
|
|
62
|
+
|
|
63
|
+
const lh = Buffer.alloc(30);
|
|
64
|
+
lh.writeUInt32LE(0x04034b50, 0);
|
|
65
|
+
lh.writeUInt16LE(20, 4);
|
|
66
|
+
lh.writeUInt16LE(UTF8_NAMES, 6);
|
|
67
|
+
lh.writeUInt16LE(method, 8);
|
|
68
|
+
lh.writeUInt16LE(time, 10);
|
|
69
|
+
lh.writeUInt16LE(date, 12);
|
|
70
|
+
lh.writeUInt32LE(crc, 14);
|
|
71
|
+
lh.writeUInt32LE(body.length, 18);
|
|
72
|
+
lh.writeUInt32LE(raw.length, 22);
|
|
73
|
+
lh.writeUInt16LE(nameBuf.length, 26);
|
|
74
|
+
lh.writeUInt16LE(0, 28);
|
|
75
|
+
locals.push(lh, nameBuf, body);
|
|
76
|
+
|
|
77
|
+
const ch = Buffer.alloc(46);
|
|
78
|
+
ch.writeUInt32LE(0x02014b50, 0);
|
|
79
|
+
ch.writeUInt16LE(0x031e, 4); // 만든 쪽: 유닉스, 버전 3.0
|
|
80
|
+
ch.writeUInt16LE(20, 6);
|
|
81
|
+
ch.writeUInt16LE(UTF8_NAMES, 8);
|
|
82
|
+
ch.writeUInt16LE(method, 10);
|
|
83
|
+
ch.writeUInt16LE(time, 12);
|
|
84
|
+
ch.writeUInt16LE(date, 14);
|
|
85
|
+
ch.writeUInt32LE(crc, 16);
|
|
86
|
+
ch.writeUInt32LE(body.length, 20);
|
|
87
|
+
ch.writeUInt32LE(raw.length, 24);
|
|
88
|
+
ch.writeUInt16LE(nameBuf.length, 28);
|
|
89
|
+
ch.writeUInt16LE(0, 30);
|
|
90
|
+
ch.writeUInt16LE(0, 32);
|
|
91
|
+
ch.writeUInt16LE(0, 34);
|
|
92
|
+
ch.writeUInt16LE(0, 36);
|
|
93
|
+
// 바깥 속성 위쪽 16비트에 유닉스 권한. bin 의 실행 권한이 여기 실린다.
|
|
94
|
+
ch.writeUInt32LE(((e.mode ?? 0o644) & 0xffff) << 16, 38);
|
|
95
|
+
ch.writeUInt32LE(offset, 42);
|
|
96
|
+
central.push(ch, nameBuf);
|
|
97
|
+
|
|
98
|
+
offset += lh.length + nameBuf.length + body.length;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
const cd = Buffer.concat(central);
|
|
102
|
+
const eocd = Buffer.alloc(22);
|
|
103
|
+
eocd.writeUInt32LE(0x06054b50, 0);
|
|
104
|
+
eocd.writeUInt16LE(0, 4);
|
|
105
|
+
eocd.writeUInt16LE(0, 6);
|
|
106
|
+
eocd.writeUInt16LE(entries.length, 8);
|
|
107
|
+
eocd.writeUInt16LE(entries.length, 10);
|
|
108
|
+
eocd.writeUInt32LE(cd.length, 12);
|
|
109
|
+
eocd.writeUInt32LE(offset, 16);
|
|
110
|
+
eocd.writeUInt16LE(0, 20);
|
|
111
|
+
|
|
112
|
+
return Buffer.concat([...locals, cd, eocd]);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// --- ZIP 읽기 -------------------------------------------------------------
|
|
116
|
+
//
|
|
117
|
+
// 뒤에서부터 읽는다. zip 은 목록(중앙 디렉터리)이 파일 끝에 있고, 그 위치를
|
|
118
|
+
// 알려주는 표식(EOCD)이 맨 끝에 있다. 앞에서부터 훑지 않는 이유가 이것이다.
|
|
119
|
+
|
|
120
|
+
const EOCD_SIG = 0x06054b50;
|
|
121
|
+
const CEN_SIG = 0x02014b50;
|
|
122
|
+
const LOC_SIG = 0x04034b50;
|
|
123
|
+
|
|
124
|
+
function findEocd(buf) {
|
|
125
|
+
// 주석이 붙어 있을 수 있어서 끝에서 최대 64KB 를 뒤로 훑는다.
|
|
126
|
+
const 끝 = Math.max(0, buf.length - 22 - 0xffff);
|
|
127
|
+
for (let i = buf.length - 22; i >= 끝; i--) {
|
|
128
|
+
if (buf.readUInt32LE(i) === EOCD_SIG) return i;
|
|
129
|
+
}
|
|
130
|
+
return -1;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* zip 안의 파일들을 이름 → 내용(Buffer) 으로 돌려준다.
|
|
135
|
+
*
|
|
136
|
+
* 필요한 것만 만들었다 — 담기(store)와 deflate 두 가지. 그게 xlsx 가 쓰는 전부다.
|
|
137
|
+
* 모르는 것을 만나면 조용히 넘기지 않고 무엇을 못 했는지 말한다.
|
|
138
|
+
* 조용히 넘기면 '표가 비어 있다' 로 보이고, 그때는 원인을 찾을 수 없다.
|
|
139
|
+
*
|
|
140
|
+
* @param {Buffer} buf
|
|
141
|
+
* @param {{ only?: (name:string)=>boolean }} [opt] 필요한 것만 풀고 싶을 때
|
|
142
|
+
* @returns {{ files: Map<string,Buffer>, skipped: Array<{name:string, why:string}> }}
|
|
143
|
+
*/
|
|
144
|
+
export function readZip(buf, { only = null } = {}) {
|
|
145
|
+
const files = new Map();
|
|
146
|
+
const skipped = [];
|
|
147
|
+
|
|
148
|
+
const eocd = findEocd(buf);
|
|
149
|
+
if (eocd < 0) throw new Error('zip 이 아닙니다 — 끝에 있어야 할 표식을 못 찾았습니다');
|
|
150
|
+
|
|
151
|
+
let 개수 = buf.readUInt16LE(eocd + 10);
|
|
152
|
+
let 시작 = buf.readUInt32LE(eocd + 16);
|
|
153
|
+
// 0xffff/0xffffffff 는 'ZIP64 를 보라' 는 표시다. 여기서는 다루지 않는다.
|
|
154
|
+
// 못 다룬다고 말하는 편이 반쯤 읽어 놓고 맞다고 하는 것보다 낫다.
|
|
155
|
+
if (개수 === 0xffff || 시작 === 0xffffffff) {
|
|
156
|
+
throw new Error('ZIP64 형식입니다 — 이 읽기는 4GB 미만 zip 만 다룹니다');
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
let p = 시작;
|
|
160
|
+
for (let i = 0; i < 개수; i++) {
|
|
161
|
+
if (p + 46 > buf.length || buf.readUInt32LE(p) !== CEN_SIG) {
|
|
162
|
+
throw new Error(`zip 목록이 깨졌습니다 (${i + 1}번째 항목)`);
|
|
163
|
+
}
|
|
164
|
+
const method = buf.readUInt16LE(p + 10);
|
|
165
|
+
const flags = buf.readUInt16LE(p + 8);
|
|
166
|
+
const compSize = buf.readUInt32LE(p + 20);
|
|
167
|
+
const rawSize = buf.readUInt32LE(p + 24);
|
|
168
|
+
const nameLen = buf.readUInt16LE(p + 28);
|
|
169
|
+
const extraLen = buf.readUInt16LE(p + 30);
|
|
170
|
+
const commentLen = buf.readUInt16LE(p + 32);
|
|
171
|
+
const localAt = buf.readUInt32LE(p + 42);
|
|
172
|
+
// 이름은 UTF-8 표시가 있으면 UTF-8, 없으면 예전 zip 관례대로 그냥 바이트다.
|
|
173
|
+
// xlsx 는 안쪽 이름이 전부 ASCII 라 어느 쪽이든 같다.
|
|
174
|
+
const name = buf.subarray(p + 46, p + 46 + nameLen).toString('utf8');
|
|
175
|
+
p += 46 + nameLen + extraLen + commentLen;
|
|
176
|
+
|
|
177
|
+
if (name.endsWith('/')) continue; // 폴더
|
|
178
|
+
if (only && !only(name)) continue;
|
|
179
|
+
|
|
180
|
+
// 암호가 걸린 항목은 첫 비트가 서 있다. xlsx 전체 암호와는 다른 것이지만,
|
|
181
|
+
// 어느 쪽이든 여기서는 못 푼다.
|
|
182
|
+
if (flags & 0x0001) { skipped.push({ name, why: '항목에 암호가 걸려 있음' }); continue; }
|
|
183
|
+
|
|
184
|
+
if (localAt + 30 > buf.length || buf.readUInt32LE(localAt) !== LOC_SIG) {
|
|
185
|
+
skipped.push({ name, why: '내용 위치가 어긋남' });
|
|
186
|
+
continue;
|
|
187
|
+
}
|
|
188
|
+
const lnLen = buf.readUInt16LE(localAt + 26);
|
|
189
|
+
const leLen = buf.readUInt16LE(localAt + 28);
|
|
190
|
+
const at = localAt + 30 + lnLen + leLen;
|
|
191
|
+
const body = buf.subarray(at, at + compSize);
|
|
192
|
+
|
|
193
|
+
if (method === 0) {
|
|
194
|
+
files.set(name, Buffer.from(body));
|
|
195
|
+
} else if (method === 8) {
|
|
196
|
+
try {
|
|
197
|
+
const out = inflateRawSync(body);
|
|
198
|
+
if (rawSize && out.length !== rawSize) {
|
|
199
|
+
skipped.push({ name, why: `푼 크기가 안 맞음 (${out.length} ≠ ${rawSize})` });
|
|
200
|
+
continue;
|
|
201
|
+
}
|
|
202
|
+
files.set(name, out);
|
|
203
|
+
} catch (err) {
|
|
204
|
+
skipped.push({ name, why: `풀지 못함 — ${err.message}` });
|
|
205
|
+
}
|
|
206
|
+
} else {
|
|
207
|
+
skipped.push({ name, why: `모르는 압축 방식 ${method}` });
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
return { files, skipped };
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/** 앞머리 네 바이트로 zip 인지 본다. 엑셀 암호 파일과 구분하는 데 쓴다. */
|
|
215
|
+
export function looksZip(buf) {
|
|
216
|
+
return buf.length >= 4 && buf.readUInt32LE(0) === LOC_SIG;
|
|
217
|
+
}
|