deel-local-cli 0.5.0 → 0.9.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 +738 -132
- package/README.md +742 -147
- package/bin/deel.js +96 -4
- 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 +252 -0
- package/src/agent/route.js +156 -0
- package/src/agent/session.js +28 -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/ctxsize.js +174 -0
- package/src/backend/http.js +44 -3
- package/src/backend/probe.js +15 -14
- package/src/backend/scan.js +256 -0
- package/src/backend/scanui.js +147 -0
- package/src/commands.js +658 -33
- 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 +297 -40
- package/src/report.js +1 -1
- 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 +97 -5
- package/src/ui/level.js +106 -0
- package/src/ui/prompt.js +34 -0
- package/src/ui/status.js +161 -0
package/src/setup.js
CHANGED
|
@@ -6,6 +6,7 @@ import { detect } from './backend/detect.js';
|
|
|
6
6
|
import { probe } from './backend/probe.js';
|
|
7
7
|
import { renderHeader, renderLine, verdict, renderVerdict, plainReport } from './report.js';
|
|
8
8
|
import { load, save, upsert, slug, resolveKey, activeProfile, configPath } from './config.js';
|
|
9
|
+
import { allowEndpoint } from './safety/network.js';
|
|
9
10
|
import { writeFileSync } from 'node:fs';
|
|
10
11
|
|
|
11
12
|
export function banner() {
|
|
@@ -17,6 +18,8 @@ export function banner() {
|
|
|
17
18
|
// 주소와 키를 받아 연결을 찾아낸다. 실패하면 null.
|
|
18
19
|
async function connect(url, key) {
|
|
19
20
|
const s = spin(`${url} 확인 중...`);
|
|
21
|
+
// 사용자가 방금 적어 넣은 주소다. 확인하는 동안만 문을 연다.
|
|
22
|
+
allowEndpoint(/^https?:\/\//i.test(url) ? url : 'http://' + url);
|
|
20
23
|
const found = await detect(url, key);
|
|
21
24
|
if (!found.kind) {
|
|
22
25
|
s.stop(` ${mark.no} ${c.red('연결 실패')}`);
|
|
@@ -51,6 +54,7 @@ async function chooseModel(found) {
|
|
|
51
54
|
|
|
52
55
|
// 진단을 돌리고 결과를 화면에 그린다.
|
|
53
56
|
export async function runProbe(conn, { out = null } = {}) {
|
|
57
|
+
allowEndpoint(conn.base); // 진단도 이 주소 하나로만 나간다
|
|
54
58
|
renderHeader({ shape: conn.kind, base: conn.base, auth: conn.auth, model: conn.model });
|
|
55
59
|
const { facts, results } = await probe(conn, renderLine);
|
|
56
60
|
const v = verdict(facts, results);
|
|
@@ -0,0 +1,333 @@
|
|
|
1
|
+
// 글자 인코딩을 알아보고, 읽고, 읽은 그대로 되돌려 쓴다.
|
|
2
|
+
//
|
|
3
|
+
// 왜 필요한가:
|
|
4
|
+
// 사내 문서는 UTF-8 이 아닌 경우가 흔하다. 윈도우 메모장이 오래 쓰던 '완성형'
|
|
5
|
+
// (한국은 CP949, 일본은 CP932, 중국은 GBK…) 으로 저장된 파일이 그대로 남아 있다.
|
|
6
|
+
// 그걸 UTF-8 로 읽으면 통째로 깨진다. '한글' → '�ѱ�'
|
|
7
|
+
//
|
|
8
|
+
// 더 위험한 건 쓸 때다. 깨진 채로 읽고 UTF-8 로 저장하면 원본이 상한다.
|
|
9
|
+
// 그래서 이 파일의 규칙은 하나다 — **읽은 인코딩으로 되돌려 쓴다.**
|
|
10
|
+
//
|
|
11
|
+
// 의존성 0개를 어떻게 지키나:
|
|
12
|
+
// 해독은 Node 내장 TextDecoder 가 해 준다 (euc-kr, shift_jis, gbk, big5 …).
|
|
13
|
+
// 그런데 TextEncoder 는 UTF-8 밖에 못 만든다. 되돌려 쓸 방법이 없다.
|
|
14
|
+
// 그래서 해독기로 역표를 만든다 — 두 바이트 조합을 전부 해독해서
|
|
15
|
+
// '글자 → 바이트' 표를 얻는다. 남의 코드를 들이지 않고 인코더를 얻는 방법이다.
|
|
16
|
+
// 표는 처음 쓸 때 한 번만 만들고 넣어 둔다.
|
|
17
|
+
import { execFileSync } from 'node:child_process';
|
|
18
|
+
|
|
19
|
+
// 알아볼 인코딩들. TextDecoder 가 아는 이름만 쓴다.
|
|
20
|
+
// 순서는 '이 자리에서 가장 그럴듯한 것' 순이 아니라, 그냥 아는 목록이다.
|
|
21
|
+
export const LEGACY = [
|
|
22
|
+
{ id: 'euc-kr', label: '완성형 (한국)', cp: 949 },
|
|
23
|
+
{ id: 'shift_jis', label: 'Shift_JIS (일본)', cp: 932 },
|
|
24
|
+
{ id: 'gbk', label: 'GBK (중국)', cp: 936 },
|
|
25
|
+
{ id: 'big5', label: 'Big5 (대만)', cp: 950 },
|
|
26
|
+
{ id: 'windows-1252', label: '서유럽', cp: 1252 },
|
|
27
|
+
];
|
|
28
|
+
|
|
29
|
+
const BOMS = [
|
|
30
|
+
{ id: 'utf-8', bytes: [0xEF, 0xBB, 0xBF] },
|
|
31
|
+
{ id: 'utf-16le', bytes: [0xFF, 0xFE] },
|
|
32
|
+
{ id: 'utf-16be', bytes: [0xFE, 0xFF] },
|
|
33
|
+
];
|
|
34
|
+
|
|
35
|
+
/** 앞머리에 표식(BOM)이 있나. 있으면 그게 답이다 — 짐작할 필요가 없다. */
|
|
36
|
+
export function bomOf(buf) {
|
|
37
|
+
for (const b of BOMS) {
|
|
38
|
+
if (buf.length >= b.bytes.length && b.bytes.every((v, i) => buf[i] === v)) {
|
|
39
|
+
return { id: b.id, size: b.bytes.length };
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
return null;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* 이 바이트들이 UTF-8 로 말이 되나.
|
|
47
|
+
*
|
|
48
|
+
* UTF-8 은 규칙이 빡빡해서, 아무 바이트나 UTF-8 인 척할 수 없다.
|
|
49
|
+
* 그 빡빡함을 그대로 검사한다. 통과하면 UTF-8 이라고 봐도 된다.
|
|
50
|
+
* (ASCII 만 있는 파일도 통과한다. 그건 어느 인코딩으로 읽어도 같으니 상관없다.)
|
|
51
|
+
*/
|
|
52
|
+
export function isUtf8(buf) {
|
|
53
|
+
let i = 0;
|
|
54
|
+
while (i < buf.length) {
|
|
55
|
+
const c = buf[i];
|
|
56
|
+
if (c <= 0x7F) { i++; continue; }
|
|
57
|
+
|
|
58
|
+
let len;
|
|
59
|
+
let min;
|
|
60
|
+
if (c >= 0xC2 && c <= 0xDF) { len = 2; min = 0x80; }
|
|
61
|
+
else if (c >= 0xE0 && c <= 0xEF) { len = 3; min = 0x800; }
|
|
62
|
+
else if (c >= 0xF0 && c <= 0xF4) { len = 4; min = 0x10000; }
|
|
63
|
+
else return false; // 0xC0·0xC1·0xF5~0xFF 는 UTF-8 에 없다
|
|
64
|
+
|
|
65
|
+
if (i + len > buf.length) return false;
|
|
66
|
+
let cp = c & (0xFF >> (len + 1));
|
|
67
|
+
for (let k = 1; k < len; k++) {
|
|
68
|
+
const t = buf[i + k];
|
|
69
|
+
if ((t & 0xC0) !== 0x80) return false; // 뒤따르는 바이트는 10xxxxxx 여야 한다
|
|
70
|
+
cp = (cp << 6) | (t & 0x3F);
|
|
71
|
+
}
|
|
72
|
+
if (cp < min) return false; // 짧게 쓸 수 있는 걸 길게 쓴 것 (보안 문제라 거절)
|
|
73
|
+
if (cp > 0x10FFFF) return false;
|
|
74
|
+
if (cp >= 0xD800 && cp <= 0xDFFF) return false;
|
|
75
|
+
i += len;
|
|
76
|
+
}
|
|
77
|
+
return true;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** 글자가 아닌 파일인가. 0 바이트가 있으면 그림·실행파일 같은 것이다. */
|
|
81
|
+
export function looksBinary(buf) {
|
|
82
|
+
const n = Math.min(buf.length, 8000);
|
|
83
|
+
for (let i = 0; i < n; i++) if (buf[i] === 0) return true;
|
|
84
|
+
return false;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// ── 내용을 보고 짐작하기 ────────────────────────────────────────────────
|
|
88
|
+
//
|
|
89
|
+
// 전에는 'UTF-8 이 아니면 이 컴퓨터 기본 코드페이지' 였다. 한국 윈도우에서만
|
|
90
|
+
// 맞는 코드였다. 우분투에서는 windows-1252 로 떨어지고, 미국 윈도우도 마찬가지다.
|
|
91
|
+
// 같은 CP949 문서가 사람 컴퓨터마다 다르게 읽혔다.
|
|
92
|
+
//
|
|
93
|
+
// 그래서 컴퓨터 설정 말고 **내용** 을 본다. 후보마다 엄격하게 해독해 보고,
|
|
94
|
+
// 나온 글이 '그 인코딩으로 쓴 진짜 글' 처럼 보이는지 점수를 매긴다.
|
|
95
|
+
//
|
|
96
|
+
// 핵심은 인코딩마다 기대치가 다르다는 것이다. CP949 로 쓴 한국어 문서는
|
|
97
|
+
// 한글이 많고 한자는 거의 없다. GBK 문서는 한자뿐이다. 그래서 CP949 문서를
|
|
98
|
+
// GBK 로 잘못 읽으면 한자가 나오긴 하는데 — 반대로 GBK 문서를 CP949 로 읽으면
|
|
99
|
+
// 한글과 희귀 한자가 뒤섞인다. 이 비대칭이 둘을 가른다.
|
|
100
|
+
//
|
|
101
|
+
// 표본 21개로 재서 21개를 맞혔다. 가장 아슬아슬한 것도 0.50 점 차였다.
|
|
102
|
+
|
|
103
|
+
const 한글음절 = (c) => c >= 0xAC00 && c <= 0xD7A3;
|
|
104
|
+
const 한글자모 = (c) => c >= 0x3130 && c <= 0x318F;
|
|
105
|
+
const 가나 = (c) => c >= 0x3040 && c <= 0x30FF;
|
|
106
|
+
const 한자 = (c) => (c >= 0x4E00 && c <= 0x9FFF) || (c >= 0xF900 && c <= 0xFAFF);
|
|
107
|
+
const 라틴 = (c) => c >= 0xC0 && c <= 0x24F;
|
|
108
|
+
const 동아부호 = (c) => (c >= 0x3000 && c <= 0x303F) || (c >= 0xFF00 && c <= 0xFFEF);
|
|
109
|
+
const 로마자 = (c) => (c >= 65 && c <= 90) || (c >= 97 && c <= 122);
|
|
110
|
+
|
|
111
|
+
// '이 인코딩으로 쓴 글이면 이런 글자가 나온다' 를 점수로 적은 것.
|
|
112
|
+
// 음수는 '그럴 리 없다' 는 뜻이다. Shift_JIS 문서에 한글이 나올 리 없다.
|
|
113
|
+
const 기대 = {
|
|
114
|
+
'euc-kr': { 한글: 4, 자모: 2, 가나: -3, 한자: -2, 라틴: -3, 부호: 1 },
|
|
115
|
+
'shift_jis': { 한글: -4, 자모: -2, 가나: 4, 한자: 2, 라틴: -3, 부호: 1 },
|
|
116
|
+
'gbk': { 한글: -3, 자모: -2, 가나: -1, 한자: 3, 라틴: -3, 부호: 1 },
|
|
117
|
+
'big5': { 한글: -3, 자모: -2, 가나: -1, 한자: 3, 라틴: -3, 부호: 1 },
|
|
118
|
+
'windows-1252': { 한글: 0, 자모: 0, 가나: 0, 한자: 0, 라틴: 3, 부호: 0 },
|
|
119
|
+
};
|
|
120
|
+
|
|
121
|
+
// 자주 쓰는 글자. 동점을 가르는 것은 결국 이것이다.
|
|
122
|
+
// 깨진 글에서 나오는 한글·한자는 희귀한 것들이라 여기에 거의 안 걸린다.
|
|
123
|
+
const 흔한한글 = new Set([...'이다는에하고지의있을로가사서대시한를수요리어아스나자기인정부상도문그무전등성니습해개년월일시분초원건확인요청결재보고회의첨부']);
|
|
124
|
+
const 흔한한자 = new Set([...'的一是不了人我在有他这中大来上国个到说们为子和你地出道也时年得就那要下以生会自着去之过家学对可里后小么心多天而能好都然没日于起还发成事只作当想看文无开手十用主行方又如前所本见经头面公同三已老从动两长知民样进最新報告書項目度務部社長株式會員請查收謝件附這測試繁體簡']);
|
|
125
|
+
|
|
126
|
+
/** 후보를 점수순으로. 첫 번째가 가장 그럴듯한 것이다. */
|
|
127
|
+
export function guess(buf) {
|
|
128
|
+
const 후보 = [];
|
|
129
|
+
for (const cand of LEGACY) {
|
|
130
|
+
let text;
|
|
131
|
+
// 엄격 모드로 해독한다. 없는 조합이 하나라도 있으면 그 인코딩이 아니다.
|
|
132
|
+
// windows-1252 도 정의 안 된 바이트가 다섯 개 있어서 여기서 걸러진다.
|
|
133
|
+
try { text = new TextDecoder(cand.id, { fatal: true }).decode(buf); } catch { continue; }
|
|
134
|
+
후보.push({ id: cand.id, score: 점수(text, cand.id) });
|
|
135
|
+
}
|
|
136
|
+
후보.sort((a, b) => b.score - a.score);
|
|
137
|
+
return 후보;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function 점수(s, id) {
|
|
141
|
+
const w = 기대[id];
|
|
142
|
+
let 합 = 0;
|
|
143
|
+
let 수 = 0;
|
|
144
|
+
const cs = [...s];
|
|
145
|
+
for (let i = 0; i < cs.length; i++) {
|
|
146
|
+
const ch = cs[i];
|
|
147
|
+
const c = ch.codePointAt(0);
|
|
148
|
+
if (c < 0x80) {
|
|
149
|
+
// 탭·줄바꿈 말고 제어문자가 글 속에 있으면 잘못 읽은 것이다.
|
|
150
|
+
if (c === 9 || c === 10 || c === 13 || c >= 0x20) continue;
|
|
151
|
+
합 -= 8; 수++; continue;
|
|
152
|
+
}
|
|
153
|
+
수++;
|
|
154
|
+
if (한글음절(c)) 합 += w.한글;
|
|
155
|
+
else if (한글자모(c)) 합 += w.자모;
|
|
156
|
+
else if (가나(c)) 합 += w.가나;
|
|
157
|
+
else if (한자(c)) 합 += w.한자;
|
|
158
|
+
else if (라틴(c)) 합 += w.라틴;
|
|
159
|
+
else if (동아부호(c)) 합 += w.부호;
|
|
160
|
+
else if (c >= 0xE000 && c <= 0xF8FF) 합 -= 8; // 사용자 영역 — 글에 나올 리 없다
|
|
161
|
+
else if (c >= 0x2000 && c <= 0x2BFF) 합 -= 1; // 괘선·기호 — 깨진 글에 흔하다
|
|
162
|
+
else 합 -= 2;
|
|
163
|
+
|
|
164
|
+
if ((한글음절(c) && 흔한한글.has(ch)) || (한자(c) && 흔한한자.has(ch))) 합 += 2;
|
|
165
|
+
|
|
166
|
+
// 로마자 단어 속에 동아시아 글자가 박혀 있으면 깨진 것이다.
|
|
167
|
+
// 'Über' 의 Ü 한 바이트가 뒷글자를 잡아먹고 '躡er' 이 되는 꼴을 이걸로 잡는다.
|
|
168
|
+
if (한글음절(c) || 가나(c) || 한자(c)) {
|
|
169
|
+
const 앞 = cs[i - 1]?.codePointAt(0);
|
|
170
|
+
const 뒤 = cs[i + 1]?.codePointAt(0);
|
|
171
|
+
if ((앞 !== undefined && 로마자(앞)) || (뒤 !== undefined && 로마자(뒤))) 합 -= 2;
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
return 수 ? 합 / 수 : 0;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
// 짐작이 이만큼 안에서 갈리면 사실상 동점이다. 그때는 힌트를 따른다.
|
|
178
|
+
const 박빙 = 0.5;
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* 이 바이트들이 어떤 인코딩인가.
|
|
182
|
+
*
|
|
183
|
+
* 순서가 중요하다.
|
|
184
|
+
* 1) 표식이 있으면 그것 — 확실함
|
|
185
|
+
* 2) UTF-8 규칙에 맞으면 UTF-8 — 규칙이 빡빡해서 우연히 맞기 어렵다
|
|
186
|
+
* 3) 아니면 내용을 보고 짐작 — 후보마다 점수를 매겨 고른다
|
|
187
|
+
*
|
|
188
|
+
* 3번은 짐작이다. 그래서 확신도를 같이 돌려준다. 확신이 없으면 부르는 쪽에서
|
|
189
|
+
* 파일을 고치지 않도록 한다 — 잘못 고치면 원본이 상한다.
|
|
190
|
+
*
|
|
191
|
+
* fallback 은 명령이 아니라 힌트다. 내용이 분명하면 내용이 이긴다.
|
|
192
|
+
* 짐작이 박빙일 때만 힌트가 결정을 한다 — 짧은 파일은 근거가 모자라기 때문이다.
|
|
193
|
+
*/
|
|
194
|
+
export function detect(buf, { fallback = null, system = null } = {}) {
|
|
195
|
+
if (!buf.length) return { id: 'utf-8', sure: true, why: '빈 파일' };
|
|
196
|
+
|
|
197
|
+
const bom = bomOf(buf);
|
|
198
|
+
if (bom) return { id: bom.id, sure: true, bom: bom.size, why: '앞머리 표식' };
|
|
199
|
+
|
|
200
|
+
if (isUtf8(buf)) {
|
|
201
|
+
const 한글밖 = buf.some((b) => b > 0x7F);
|
|
202
|
+
return { id: 'utf-8', sure: true, why: 한글밖 ? 'UTF-8 규칙에 맞음' : 'ASCII 뿐' };
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
const 힌트 = fallback ?? system ?? systemLegacy();
|
|
206
|
+
const 후보 = guess(buf);
|
|
207
|
+
if (!후보.length) {
|
|
208
|
+
return { id: 힌트, sure: false, why: '어느 인코딩으로도 말이 안 됨 — 힌트로 봄' };
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
const 으뜸 = 후보[0];
|
|
212
|
+
const 힌트것 = 후보.find((x) => x.id === 힌트);
|
|
213
|
+
if (힌트것 && 힌트것 !== 으뜸 && 으뜸.score - 힌트것.score < 박빙) {
|
|
214
|
+
return { id: 힌트것.id, sure: false, why: `${으뜸.id} 와 박빙이라 힌트를 따름`, 후보 };
|
|
215
|
+
}
|
|
216
|
+
const 여유 = 후보.length > 1 ? 으뜸.score - 후보[1].score : Infinity;
|
|
217
|
+
return { id: 으뜸.id, sure: false, why: `내용으로 짐작 (${여유 === Infinity ? '단독' : `${여유.toFixed(1)}점 차`})`, 후보 };
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
// 이 컴퓨터가 쓰는 옛 인코딩. 콘솔 코드페이지를 물어봐서 정한다.
|
|
221
|
+
let _sys = null;
|
|
222
|
+
export function systemLegacy() {
|
|
223
|
+
if (_sys) return _sys;
|
|
224
|
+
const cp = consoleCodepage();
|
|
225
|
+
_sys = LEGACY.find((x) => x.cp === cp)?.id ?? 'windows-1252';
|
|
226
|
+
return _sys;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/**
|
|
230
|
+
* 윈도우 콘솔이 쓰는 코드페이지.
|
|
231
|
+
*
|
|
232
|
+
* 명령을 돌려 알아낸다. 한 번만 하고 기억한다. 윈도우가 아니면 UTF-8 이다.
|
|
233
|
+
* 이 값이 필요한 이유가 둘이다 — 옛 인코딩 짐작의 기본값이고, Bash 도구가
|
|
234
|
+
* 받아오는 명령 출력을 해독하는 데도 쓴다.
|
|
235
|
+
*/
|
|
236
|
+
let _cp = null;
|
|
237
|
+
export function consoleCodepage() {
|
|
238
|
+
if (_cp !== null) return _cp;
|
|
239
|
+
if (process.platform !== 'win32') { _cp = 65001; return _cp; }
|
|
240
|
+
try {
|
|
241
|
+
// 여기서만 동기로 부른다. 값이 안 바뀌므로 한 번이면 된다.
|
|
242
|
+
const out = execFileSync('chcp.com', [], { encoding: 'latin1', timeout: 3000, windowsHide: true });
|
|
243
|
+
const m = out.match(/(\d{3,5})/);
|
|
244
|
+
_cp = m ? Number(m[1]) : 65001;
|
|
245
|
+
} catch { _cp = 65001; }
|
|
246
|
+
return _cp;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
/** 바이트를 글로. 무엇으로 읽었는지도 같이 돌려준다. */
|
|
250
|
+
export function decode(buf, { fallback = null, system = null } = {}) {
|
|
251
|
+
const found = detect(buf, { fallback, system });
|
|
252
|
+
const body = found.bom ? buf.subarray(found.bom) : buf;
|
|
253
|
+
let text;
|
|
254
|
+
try {
|
|
255
|
+
text = new TextDecoder(found.id, { fatal: false }).decode(body);
|
|
256
|
+
} catch {
|
|
257
|
+
text = body.toString('utf8');
|
|
258
|
+
return { text, encoding: 'utf-8', sure: false, why: `${found.id} 를 이 Node 가 모름` };
|
|
259
|
+
}
|
|
260
|
+
return { text, encoding: found.id, sure: found.sure, why: found.why, bom: found.bom ?? 0 };
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
// ── 되돌려 쓰기 ─────────────────────────────────────────────────────────
|
|
264
|
+
//
|
|
265
|
+
// 해독기를 뒤집어 인코더를 만든다.
|
|
266
|
+
// 두 바이트로 될 수 있는 조합을 전부 해독해서 '글자 → 바이트' 표를 얻는다.
|
|
267
|
+
// 6만 번쯤 도는데 한 번만 하면 된다. 남의 패키지를 들이는 것보다 이게 낫다.
|
|
268
|
+
const _tables = new Map();
|
|
269
|
+
|
|
270
|
+
function reverseTable(id) {
|
|
271
|
+
if (_tables.has(id)) return _tables.get(id);
|
|
272
|
+
const dec = new TextDecoder(id, { fatal: true });
|
|
273
|
+
const map = new Map();
|
|
274
|
+
|
|
275
|
+
// 한 바이트짜리 (ASCII 및 그 인코딩의 반각 영역)
|
|
276
|
+
for (let b = 0; b < 0x100; b++) {
|
|
277
|
+
try {
|
|
278
|
+
const ch = dec.decode(Uint8Array.of(b));
|
|
279
|
+
if (ch.length === 1 && !map.has(ch)) map.set(ch, Uint8Array.of(b));
|
|
280
|
+
} catch { /* 이 바이트 혼자로는 글자가 아니다 */ }
|
|
281
|
+
}
|
|
282
|
+
// 두 바이트짜리
|
|
283
|
+
for (let hi = 0x81; hi <= 0xFE; hi++) {
|
|
284
|
+
for (let lo = 0x40; lo <= 0xFE; lo++) {
|
|
285
|
+
try {
|
|
286
|
+
const ch = dec.decode(Uint8Array.of(hi, lo));
|
|
287
|
+
if (ch.length === 1 && !map.has(ch)) map.set(ch, Uint8Array.of(hi, lo));
|
|
288
|
+
} catch { /* 없는 조합 */ }
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
_tables.set(id, map);
|
|
292
|
+
return map;
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
/**
|
|
296
|
+
* 글을 바이트로. 읽을 때와 같은 인코딩으로 되돌린다.
|
|
297
|
+
*
|
|
298
|
+
* 그 인코딩에 없는 글자가 있으면 바꾸지 않고 알린다. 물음표로 뭉개서 저장하면
|
|
299
|
+
* 사용자는 그 사실을 모른 채 원본을 잃는다. 조용히 망가뜨리느니 멈추는 게 낫다.
|
|
300
|
+
*/
|
|
301
|
+
export function encode(text, encoding = 'utf-8') {
|
|
302
|
+
const id = String(encoding).toLowerCase();
|
|
303
|
+
|
|
304
|
+
if (id === 'utf-8' || id === 'utf8') return { buf: Buffer.from(text, 'utf8'), lost: [] };
|
|
305
|
+
if (id === 'utf-8-bom') {
|
|
306
|
+
return { buf: Buffer.concat([Buffer.from([0xEF, 0xBB, 0xBF]), Buffer.from(text, 'utf8')]), lost: [] };
|
|
307
|
+
}
|
|
308
|
+
if (id === 'utf-16le') return { buf: Buffer.concat([Buffer.from([0xFF, 0xFE]), Buffer.from(text, 'utf16le')]), lost: [] };
|
|
309
|
+
|
|
310
|
+
let map;
|
|
311
|
+
try { map = reverseTable(id); }
|
|
312
|
+
catch { return { buf: Buffer.from(text, 'utf8'), lost: [], fellBack: true }; }
|
|
313
|
+
|
|
314
|
+
const out = [];
|
|
315
|
+
const lost = new Set();
|
|
316
|
+
for (const ch of text) {
|
|
317
|
+
if (ch === '\n' || ch === '\r' || ch === '\t') { out.push(ch.charCodeAt(0)); continue; }
|
|
318
|
+
const bytes = map.get(ch);
|
|
319
|
+
if (bytes) { for (const b of bytes) out.push(b); }
|
|
320
|
+
else lost.add(ch);
|
|
321
|
+
}
|
|
322
|
+
return { buf: Buffer.from(out), lost: [...lost] };
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
/** 화면에 적을 짧은 이름. 'CP949' 처럼 사람이 아는 말로. */
|
|
326
|
+
export function label(id) {
|
|
327
|
+
if (id === 'utf-8') return 'UTF-8';
|
|
328
|
+
if (id === 'utf-8-bom') return 'UTF-8(BOM)';
|
|
329
|
+
if (id === 'utf-16le') return 'UTF-16LE';
|
|
330
|
+
if (id === 'utf-16be') return 'UTF-16BE';
|
|
331
|
+
const f = LEGACY.find((x) => x.id === id);
|
|
332
|
+
return f ? `CP${f.cp}` : String(id).toUpperCase();
|
|
333
|
+
}
|
|
@@ -0,0 +1,254 @@
|
|
|
1
|
+
// 엑셀을 시켜서 파일을 표로 뽑아낸다. 암호가 걸린 파일과 옛 .xls 용이다.
|
|
2
|
+
//
|
|
3
|
+
// 왜 엑셀을 시키나:
|
|
4
|
+
// 암호가 걸린 엑셀 파일은 zip 이 아니라 통째로 암호화된 덩어리다. 푸는 데
|
|
5
|
+
// AES 와 해시 반복이 필요하고, 그걸 직접 구현하는 건 이 도구가 할 일이 아니다.
|
|
6
|
+
// 옛 .xls 도 형식이 완전히 다르다. 두 경우 다 엑셀이 이미 할 줄 안다.
|
|
7
|
+
//
|
|
8
|
+
// 암호를 어디에도 안 남긴다 — 이게 이 파일의 첫 번째 규칙이다:
|
|
9
|
+
// - 명령줄 인자로 안 넘긴다. 작업 관리자에서 남의 명령줄이 보인다.
|
|
10
|
+
// - 파일에 안 쓴다. 임시 스크립트에도 안 넣는다.
|
|
11
|
+
// - 설정·세션·감사기록에 안 남긴다.
|
|
12
|
+
// 오직 자식 프로세스의 표준입력으로만 건넨다. 메모리에서만 산다.
|
|
13
|
+
//
|
|
14
|
+
// 한국어 윈도우에서 알아낸 것 두 가지 (실제로 여기서 막혔던 것들):
|
|
15
|
+
// 1) 스레드 문화권을 en-US 로 맞춰야 한다. 안 그러면 Open() 이 조용히 null 을
|
|
16
|
+
// 돌려주거나 엉뚱한 형변환 오류가 난다.
|
|
17
|
+
// 2) 엑셀은 바쁘면 호출을 거절한다(0x80010001, 0x800AC472). 잘못된 호출이
|
|
18
|
+
// 아니라 '지금은 말고' 라는 뜻이라, 쉬었다 다시 부르면 된다.
|
|
19
|
+
import { spawn } from 'node:child_process';
|
|
20
|
+
import { mkdtempSync, readFileSync, readdirSync, rmSync } from 'node:fs';
|
|
21
|
+
import { tmpdir } from 'node:os';
|
|
22
|
+
import { join } from 'node:path';
|
|
23
|
+
import { decode } from './encoding.js';
|
|
24
|
+
|
|
25
|
+
// 엑셀에게 시킬 일. 암호는 여기 없다 — 표준입력으로 들어온다.
|
|
26
|
+
// 경로도 명령줄이 아니라 환경변수로 준다. 따옴표 문제도 없고 남의 눈에도 안 띈다.
|
|
27
|
+
const SCRIPT = `
|
|
28
|
+
$ErrorActionPreference = 'Stop'
|
|
29
|
+
[System.Threading.Thread]::CurrentThread.CurrentCulture = New-Object System.Globalization.CultureInfo 'en-US'
|
|
30
|
+
|
|
31
|
+
function Try-Com([scriptblock]$fn, [int]$tries = 12) {
|
|
32
|
+
for ($i = 1; $i -le $tries; $i++) {
|
|
33
|
+
try { return & $fn }
|
|
34
|
+
catch {
|
|
35
|
+
if ($_.Exception.Message -match '0x80010001|0x800AC472|rejected by callee') {
|
|
36
|
+
Start-Sleep -Milliseconds (150 * $i); continue
|
|
37
|
+
}
|
|
38
|
+
throw
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
throw 'BUSY'
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
$src = $env:DEEL_XL_IN
|
|
45
|
+
$dst = $env:DEEL_XL_OUT
|
|
46
|
+
$pw = [Console]::In.ReadLine()
|
|
47
|
+
if ($null -eq $pw) { $pw = '' }
|
|
48
|
+
|
|
49
|
+
$xl = $null
|
|
50
|
+
$wb = $null
|
|
51
|
+
try {
|
|
52
|
+
$xl = New-Object -ComObject Excel.Application
|
|
53
|
+
Try-Com { $xl.Visible = $false }
|
|
54
|
+
Try-Com { $xl.DisplayAlerts = $false }
|
|
55
|
+
# 매크로가 저절로 돌지 않게 막는다. 남의 파일을 여는 일이다.
|
|
56
|
+
Try-Com { $xl.EnableEvents = $false }
|
|
57
|
+
Try-Com { $xl.AutomationSecurity = 3 }
|
|
58
|
+
|
|
59
|
+
$pwArg = if ([string]::IsNullOrEmpty($pw)) { [Type]::Missing } else { $pw }
|
|
60
|
+
# ReadOnly = true. 사용자의 원본은 어떤 경우에도 안 건드린다.
|
|
61
|
+
$wb = Try-Com { $xl.Workbooks.Open($src, 0, $true, [Type]::Missing, $pwArg, $pwArg, $true) }
|
|
62
|
+
if ($null -eq $wb) { throw 'OPENFAIL' }
|
|
63
|
+
|
|
64
|
+
$i = 0
|
|
65
|
+
foreach ($ws in $wb.Worksheets) {
|
|
66
|
+
$i++
|
|
67
|
+
$name = $ws.Name
|
|
68
|
+
# 시트 하나만 든 새 통합문서로 복사해서 내보낸다.
|
|
69
|
+
Try-Com { $ws.Copy() }
|
|
70
|
+
$tmp = $xl.ActiveWorkbook
|
|
71
|
+
$path = Join-Path $dst ("$i.txt")
|
|
72
|
+
# 42 = xlUnicodeText. UTF-16 에 탭 구분이라 한글이 어디서도 안 깨진다.
|
|
73
|
+
Try-Com { $tmp.SaveAs($path, 42) }
|
|
74
|
+
Try-Com { $tmp.Close($false) }
|
|
75
|
+
Write-Output ("SHEET\`t$i\`t$name")
|
|
76
|
+
}
|
|
77
|
+
Write-Output 'OK'
|
|
78
|
+
}
|
|
79
|
+
catch {
|
|
80
|
+
$m = $_.Exception.Message
|
|
81
|
+
# 작은따옴표는 PowerShell 에서 이스케이프를 안 푼다. 탭을 넣으려면 큰따옴표여야 한다.
|
|
82
|
+
if ($m -match 'password|암호|protected') { Write-Output "ERR\`tPASSWORD" }
|
|
83
|
+
elseif ($m -eq 'BUSY') { Write-Output "ERR\`tBUSY" }
|
|
84
|
+
elseif ($m -eq 'OPENFAIL') { Write-Output "ERR\`tPASSWORD" }
|
|
85
|
+
else { Write-Output ("ERR\`tOTHER\`t" + $m) }
|
|
86
|
+
}
|
|
87
|
+
finally {
|
|
88
|
+
if ($null -ne $wb) { try { Try-Com { $wb.Close($false) } } catch {} }
|
|
89
|
+
if ($null -ne $xl) { try { Try-Com { $xl.Quit() } } catch {} }
|
|
90
|
+
}
|
|
91
|
+
`;
|
|
92
|
+
|
|
93
|
+
/** 이 컴퓨터에서 엑셀을 시킬 수 있나. 없으면 그렇다고 말해야 한다. */
|
|
94
|
+
export function canUseExcel() {
|
|
95
|
+
return process.platform === 'win32';
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* '엑셀이 없다' 는 뜻의 오류인가.
|
|
100
|
+
*
|
|
101
|
+
* 오류 글은 이 컴퓨터 언어로 오므로 말로 알아보려 하면 안 된다. 숫자로 본다.
|
|
102
|
+
* 80040154 REGDB_E_CLASSNOTREG — 그런 COM 클래스가 등록돼 있지 않다
|
|
103
|
+
* 80080005 CO_E_SERVER_EXEC_FAILURE — 서버를 띄우지 못했다
|
|
104
|
+
* 이걸 그대로 사용자에게 내보내면 알아볼 수 없는 글자만 남는다.
|
|
105
|
+
*/
|
|
106
|
+
function 없는엑셀(s) {
|
|
107
|
+
return /80040154|80080005|REGDB_E_CLASSNOTREG|CO_E_SERVER_EXEC_FAILURE/i.test(String(s ?? ''));
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* 엑셀로 파일을 열어 시트별 표를 뽑는다.
|
|
112
|
+
*
|
|
113
|
+
* @param {string} 경로 읽을 엑셀 파일 (읽기 전용으로 연다)
|
|
114
|
+
* @param {{ password?: string, timeout?: number }} opt
|
|
115
|
+
* password 는 메모리에만 있어야 한다. 어디에도 적지 말 것.
|
|
116
|
+
* @returns {Promise<{ ok: boolean, reason?: string, sheets?: Array<{name:string, rows:string[][]}> }>}
|
|
117
|
+
*/
|
|
118
|
+
export async function excelToTables(경로, { password = '', timeout = 90000 } = {}) {
|
|
119
|
+
if (!canUseExcel()) {
|
|
120
|
+
return { ok: false, reason: 'no-excel', message: '이 파일은 엑셀이 있어야 읽을 수 있는데, 윈도우가 아닙니다' };
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
const 밖 = mkdtempSync(join(tmpdir(), 'deel-xl-'));
|
|
124
|
+
try {
|
|
125
|
+
const 결과 = await 돌리기(경로, 밖, password, timeout);
|
|
126
|
+
if (!결과.ok) return 결과;
|
|
127
|
+
|
|
128
|
+
const sheets = [];
|
|
129
|
+
for (const { i, name } of 결과.시트들) {
|
|
130
|
+
const p = join(밖, `${i}.txt`);
|
|
131
|
+
let buf;
|
|
132
|
+
try { buf = readFileSync(p); } catch { continue; }
|
|
133
|
+
// 엑셀이 UTF-16 으로 썼다. decode 가 앞머리 표식을 보고 알아서 푼다.
|
|
134
|
+
sheets.push({ name, rows: tsv(decode(buf).text) });
|
|
135
|
+
}
|
|
136
|
+
if (!sheets.length) {
|
|
137
|
+
return { ok: false, reason: 'empty', message: '엑셀이 열긴 했는데 뽑아낼 시트가 없습니다' };
|
|
138
|
+
}
|
|
139
|
+
return { ok: true, sheets };
|
|
140
|
+
} finally {
|
|
141
|
+
// 여기 들어 있던 것은 암호를 푼 내용이다. 반드시 지운다.
|
|
142
|
+
try { rmSync(밖, { recursive: true, force: true }); } catch { /* 임시 폴더다 */ }
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function 돌리기(경로, 밖, password, timeout) {
|
|
147
|
+
return new Promise((done) => {
|
|
148
|
+
// 스크립트는 -EncodedCommand 로 넘긴다. 임시 .ps1 파일을 안 만들려는 것이다 —
|
|
149
|
+
// 파일로 남으면 사내 보안 도구가 막을 수도 있고, 지우기 전에 죽으면 남는다.
|
|
150
|
+
const enc = Buffer.from(SCRIPT, 'utf16le').toString('base64');
|
|
151
|
+
const kid = spawn('powershell.exe', ['-NoProfile', '-NonInteractive', '-EncodedCommand', enc], {
|
|
152
|
+
windowsHide: true,
|
|
153
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
154
|
+
env: { ...process.env, DEEL_XL_IN: 경로, DEEL_XL_OUT: 밖 },
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
// PowerShell 은 UTF-8 이 아니라 이 컴퓨터 콘솔 인코딩으로 뱉는다.
|
|
158
|
+
// utf8 이라고 하고 받으면 오류 메시지가 통째로 깨져서, 무엇이 잘못됐는지
|
|
159
|
+
// 알아볼 수 없는 글자가 사용자에게 그대로 간다. 바이트로 모아 뒤에 푼다.
|
|
160
|
+
const 밖조각 = [];
|
|
161
|
+
const 오류조각 = [];
|
|
162
|
+
let 끝남 = false;
|
|
163
|
+
kid.stdout.on('data', (b) => 밖조각.push(b));
|
|
164
|
+
kid.stderr.on('data', (b) => 오류조각.push(b));
|
|
165
|
+
const 풀기 = (조각) => (조각.length ? decode(Buffer.concat(조각)).text : '');
|
|
166
|
+
|
|
167
|
+
// 엑셀이 창을 띄우고 기다리는 경우가 있다. 그러면 영원히 안 끝난다.
|
|
168
|
+
// 잘못된 암호를 넣었을 때가 특히 그렇다. 시간을 정해 두고 끊는다.
|
|
169
|
+
const 시계 = setTimeout(() => {
|
|
170
|
+
if (끝남) return;
|
|
171
|
+
끝남 = true;
|
|
172
|
+
try { kid.kill(); } catch {}
|
|
173
|
+
done({ ok: false, reason: 'timeout', message: `엑셀이 ${Math.round(timeout / 1000)}초 안에 답하지 않았습니다 — 암호가 틀렸거나 엑셀이 무언가를 묻고 있을 수 있습니다` });
|
|
174
|
+
}, timeout);
|
|
175
|
+
|
|
176
|
+
kid.on('error', (e) => {
|
|
177
|
+
if (끝남) return;
|
|
178
|
+
끝남 = true;
|
|
179
|
+
clearTimeout(시계);
|
|
180
|
+
done({ ok: false, reason: 'no-excel', message: `엑셀을 실행하지 못했습니다: ${e.message}` });
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
kid.on('close', () => {
|
|
184
|
+
if (끝남) return;
|
|
185
|
+
끝남 = true;
|
|
186
|
+
clearTimeout(시계);
|
|
187
|
+
const out = 풀기(밖조각);
|
|
188
|
+
const err = 풀기(오류조각);
|
|
189
|
+
const 줄들 = out.split(/\r?\n/).map((s) => s.trim()).filter(Boolean);
|
|
190
|
+
const 오류 = 줄들.find((l) => l.startsWith('ERR\t'));
|
|
191
|
+
if (오류) {
|
|
192
|
+
const [, 갈래, 말] = 오류.split('\t');
|
|
193
|
+
if (갈래 === 'PASSWORD') return done({ ok: false, reason: 'password', message: '암호가 맞지 않거나, 이 파일에는 암호가 필요합니다' });
|
|
194
|
+
if (갈래 === 'BUSY') return done({ ok: false, reason: 'busy', message: '엑셀이 계속 바쁘다고 합니다 — 열려 있는 엑셀 창을 닫고 다시 해보세요' });
|
|
195
|
+
// COM 클래스를 못 찾는다 = 이 컴퓨터에 엑셀이 없다.
|
|
196
|
+
// 이걸 그대로 내보내면 사용자는 알아볼 수 없는 오류 코드만 받는다.
|
|
197
|
+
if (없는엑셀(말)) {
|
|
198
|
+
return done({ ok: false, reason: 'no-excel', message: '이 컴퓨터에 엑셀이 설치되어 있지 않습니다 — 암호가 걸린 파일과 옛 .xls 는 엑셀이 있어야 읽을 수 있습니다' });
|
|
199
|
+
}
|
|
200
|
+
return done({ ok: false, reason: 'other', message: `엑셀이 열지 못했습니다: ${말 ?? ''}`.trim() });
|
|
201
|
+
}
|
|
202
|
+
if (!줄들.includes('OK')) {
|
|
203
|
+
const 첫줄 = err.split('\n')[0] ?? '';
|
|
204
|
+
if (없는엑셀(err)) {
|
|
205
|
+
return done({ ok: false, reason: 'no-excel', message: '이 컴퓨터에 엑셀이 설치되어 있지 않습니다' });
|
|
206
|
+
}
|
|
207
|
+
return done({ ok: false, reason: 'other', message: `엑셀이 끝내지 못했습니다${첫줄 ? ` — ${첫줄}` : ''}` });
|
|
208
|
+
}
|
|
209
|
+
const 시트들 = 줄들.filter((l) => l.startsWith('SHEET\t')).map((l) => {
|
|
210
|
+
const [, i, ...name] = l.split('\t');
|
|
211
|
+
return { i: Number(i), name: name.join('\t') || `시트${i}` };
|
|
212
|
+
});
|
|
213
|
+
done({ ok: true, 시트들 });
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
// 암호는 여기로만 나간다. 줄바꿈까지 보내고 바로 닫는다.
|
|
217
|
+
try {
|
|
218
|
+
kid.stdin.write(`${password ?? ''}\n`, 'utf8');
|
|
219
|
+
kid.stdin.end();
|
|
220
|
+
} catch { /* 이미 죽었으면 close 쪽에서 처리된다 */ }
|
|
221
|
+
});
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/**
|
|
225
|
+
* 엑셀이 내놓은 탭 구분 글을 표로.
|
|
226
|
+
*
|
|
227
|
+
* 엑셀은 탭·줄바꿈·따옴표가 든 칸만 따옴표로 감싸고, 안의 따옴표는 두 번 쓴다.
|
|
228
|
+
* CSV 와 규칙이 같고 구분자만 탭이다.
|
|
229
|
+
*/
|
|
230
|
+
export function tsv(text) {
|
|
231
|
+
const rows = [];
|
|
232
|
+
let row = [];
|
|
233
|
+
let 칸 = '';
|
|
234
|
+
let 따옴표안 = false;
|
|
235
|
+
const s = text.replace(/\r\n/g, '\n').replace(/\r/g, '\n');
|
|
236
|
+
for (let i = 0; i < s.length; i++) {
|
|
237
|
+
const ch = s[i];
|
|
238
|
+
if (따옴표안) {
|
|
239
|
+
if (ch === '"') {
|
|
240
|
+
if (s[i + 1] === '"') { 칸 += '"'; i++; }
|
|
241
|
+
else 따옴표안 = false;
|
|
242
|
+
} else 칸 += ch;
|
|
243
|
+
continue;
|
|
244
|
+
}
|
|
245
|
+
if (ch === '"' && 칸 === '') { 따옴표안 = true; continue; }
|
|
246
|
+
if (ch === '\t') { row.push(칸); 칸 = ''; continue; }
|
|
247
|
+
if (ch === '\n') { row.push(칸); rows.push(row); row = []; 칸 = ''; continue; }
|
|
248
|
+
칸 += ch;
|
|
249
|
+
}
|
|
250
|
+
if (칸 !== '' || row.length) { row.push(칸); rows.push(row); }
|
|
251
|
+
// 엑셀은 마지막에 빈 줄을 하나 남긴다. 표에 넣을 것은 아니다.
|
|
252
|
+
while (rows.length && rows[rows.length - 1].every((x) => x === '')) rows.pop();
|
|
253
|
+
return rows;
|
|
254
|
+
}
|