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
|
@@ -0,0 +1,319 @@
|
|
|
1
|
+
// 엑셀 파일을 표로 읽는다.
|
|
2
|
+
//
|
|
3
|
+
// 왜 필요한가:
|
|
4
|
+
// 사내 문서 상당수가 엑셀이다. 그런데 엑셀 파일은 글이 아니라 압축 꾸러미라,
|
|
5
|
+
// 그냥 읽으면 '바이너리 파일입니다' 로 끝난다. 사람이 손으로 CSV 로 내보내
|
|
6
|
+
// 붙여넣어야 했다. 그걸 도구가 알아서 한다.
|
|
7
|
+
//
|
|
8
|
+
// 무엇을 안 하는가:
|
|
9
|
+
// 되돌려 쓰지 않는다. 엑셀 파일은 읽기만 한다. 서식·수식·차트·조건부서식이
|
|
10
|
+
// 들어 있는 파일을 CSV 로 왕복시키면 반드시 뭔가 잃는다. 잃는 걸 알면서
|
|
11
|
+
// 쓰느니 안 쓰는 편이 낫다. 고칠 일이 있으면 사람이 엑셀에서 한다.
|
|
12
|
+
//
|
|
13
|
+
// 의존성 0개:
|
|
14
|
+
// xlsx 는 사실 zip 이고, 그 안은 XML 이다. 둘 다 Node 내장으로 된다 —
|
|
15
|
+
// zip 은 zlib, XML 은 여기 아래에 필요한 만큼만 만든 작은 읽기다.
|
|
16
|
+
// 범용 XML 파서를 만들지 않았다. 이 형식이 쓰는 모양만 읽는다.
|
|
17
|
+
import { readZip, looksZip } from '../pack/zip.js';
|
|
18
|
+
|
|
19
|
+
// ── 아주 작은 XML 읽기 ──────────────────────────────────────────────────
|
|
20
|
+
//
|
|
21
|
+
// 엑셀이 내놓는 XML 은 모양이 정해져 있다. 주석도, CDATA 도, DTD 도 안 쓴다.
|
|
22
|
+
// 그래서 여는 태그·닫는 태그·글자만 훑으면 된다.
|
|
23
|
+
|
|
24
|
+
const 되돌림 = { lt: '<', gt: '>', amp: '&', quot: '"', apos: "'" };
|
|
25
|
+
|
|
26
|
+
export function unescapeXml(s) {
|
|
27
|
+
if (!s.includes('&')) return s;
|
|
28
|
+
return s.replace(/&(#x?[0-9a-fA-F]+|[a-z]+);/g, (전체, 안) => {
|
|
29
|
+
if (안[0] === '#') {
|
|
30
|
+
const n = 안[1] === 'x' || 안[1] === 'X' ? parseInt(안.slice(2), 16) : parseInt(안.slice(1), 10);
|
|
31
|
+
return Number.isFinite(n) && n >= 0 && n <= 0x10ffff ? String.fromCodePoint(n) : 전체;
|
|
32
|
+
}
|
|
33
|
+
return 되돌림[안] ?? 전체;
|
|
34
|
+
});
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** 태그를 앞에서부터 하나씩 내놓는다. { name, attrs, closing, selfClosing, text } */
|
|
38
|
+
export function* tags(xml) {
|
|
39
|
+
let i = 0;
|
|
40
|
+
while (i < xml.length) {
|
|
41
|
+
const 열림 = xml.indexOf('<', i);
|
|
42
|
+
if (열림 < 0) break;
|
|
43
|
+
if (열림 > i) {
|
|
44
|
+
const 글 = xml.slice(i, 열림);
|
|
45
|
+
if (글.trim()) yield { text: 글 };
|
|
46
|
+
else if (글) yield { text: 글, blank: true };
|
|
47
|
+
}
|
|
48
|
+
// <?xml ... ?> 와 <!-- --> 는 건너뛴다
|
|
49
|
+
if (xml[열림 + 1] === '?' || xml[열림 + 1] === '!') {
|
|
50
|
+
const 끝 = xml.indexOf('>', 열림);
|
|
51
|
+
i = 끝 < 0 ? xml.length : 끝 + 1;
|
|
52
|
+
continue;
|
|
53
|
+
}
|
|
54
|
+
const 닫힘 = xml.indexOf('>', 열림);
|
|
55
|
+
if (닫힘 < 0) break;
|
|
56
|
+
let 안 = xml.slice(열림 + 1, 닫힘);
|
|
57
|
+
const closing = 안[0] === '/';
|
|
58
|
+
if (closing) 안 = 안.slice(1);
|
|
59
|
+
const selfClosing = 안.endsWith('/');
|
|
60
|
+
if (selfClosing) 안 = 안.slice(0, -1);
|
|
61
|
+
const 빈칸 = 안.search(/[\s]/);
|
|
62
|
+
const name = 빈칸 < 0 ? 안 : 안.slice(0, 빈칸);
|
|
63
|
+
const attrs = 빈칸 < 0 ? {} : 속성(안.slice(빈칸));
|
|
64
|
+
yield { name, attrs, closing, selfClosing };
|
|
65
|
+
i = 닫힘 + 1;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function 속성(s) {
|
|
70
|
+
const out = {};
|
|
71
|
+
const re = /([\w:.-]+)\s*=\s*"([^"]*)"|([\w:.-]+)\s*=\s*'([^']*)'/g;
|
|
72
|
+
let m;
|
|
73
|
+
while ((m = re.exec(s))) out[m[1] ?? m[3]] = unescapeXml(m[2] ?? m[4]);
|
|
74
|
+
return out;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// ── 셀 주소 ─────────────────────────────────────────────────────────────
|
|
78
|
+
|
|
79
|
+
/** 'BC12' → { col: 54, row: 12 } (col 은 1부터) */
|
|
80
|
+
export function cellRef(ref) {
|
|
81
|
+
let col = 0;
|
|
82
|
+
let i = 0;
|
|
83
|
+
while (i < ref.length) {
|
|
84
|
+
const c = ref.charCodeAt(i);
|
|
85
|
+
if (c >= 65 && c <= 90) { col = col * 26 + (c - 64); i++; }
|
|
86
|
+
else if (c >= 97 && c <= 122) { col = col * 26 + (c - 96); i++; }
|
|
87
|
+
else break;
|
|
88
|
+
}
|
|
89
|
+
const row = Number(ref.slice(i)) || 0;
|
|
90
|
+
return { col, row };
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// ── 조각들 읽기 ─────────────────────────────────────────────────────────
|
|
94
|
+
|
|
95
|
+
function 공용문자열(xml) {
|
|
96
|
+
// <si> 하나가 문자열 하나다. 안에 <t> 가 여러 개면 이어 붙인다(서식이 섞인 글).
|
|
97
|
+
// <rPh> 는 일본어 읽기(후리가나)라서 본문이 아니다 — 넣으면 글자가 겹쳐 보인다.
|
|
98
|
+
const out = [];
|
|
99
|
+
let 모으는중 = null;
|
|
100
|
+
let 무시깊이 = 0;
|
|
101
|
+
let t = false;
|
|
102
|
+
for (const n of tags(xml)) {
|
|
103
|
+
if (n.text !== undefined) { if (t && !무시깊이 && 모으는중 !== null) 모으는중 += unescapeXml(n.text); continue; }
|
|
104
|
+
if (n.name === 'si') {
|
|
105
|
+
if (n.closing) { out.push(모으는중 ?? ''); 모으는중 = null; }
|
|
106
|
+
else if (n.selfClosing) out.push('');
|
|
107
|
+
else 모으는중 = '';
|
|
108
|
+
continue;
|
|
109
|
+
}
|
|
110
|
+
if (n.name === 'rPh') { 무시깊이 += n.closing ? -1 : (n.selfClosing ? 0 : 1); continue; }
|
|
111
|
+
if (n.name === 't') t = !n.closing && !n.selfClosing;
|
|
112
|
+
}
|
|
113
|
+
return out;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function 시트목록(workbookXml, rels) {
|
|
117
|
+
const out = [];
|
|
118
|
+
for (const n of tags(workbookXml)) {
|
|
119
|
+
if (n.name === 'sheet' && !n.closing) {
|
|
120
|
+
const rid = n.attrs['r:id'] ?? n.attrs.id;
|
|
121
|
+
out.push({
|
|
122
|
+
name: n.attrs.name ?? `시트${out.length + 1}`,
|
|
123
|
+
// state="hidden" 인 시트도 담는다. 숨겨진 데 진짜 값이 있는 경우가 있다.
|
|
124
|
+
hidden: n.attrs.state === 'hidden' || n.attrs.state === 'veryHidden',
|
|
125
|
+
path: rels.get(rid) ?? null,
|
|
126
|
+
});
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
return out;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function 관계(relsXml) {
|
|
133
|
+
const m = new Map();
|
|
134
|
+
for (const n of tags(relsXml)) {
|
|
135
|
+
if (n.name === 'Relationship' && !n.closing && n.attrs.Id) {
|
|
136
|
+
let t = n.attrs.Target ?? '';
|
|
137
|
+
if (t.startsWith('/')) t = t.slice(1);
|
|
138
|
+
else if (!t.startsWith('xl/')) t = `xl/${t}`;
|
|
139
|
+
m.set(n.attrs.Id, t.replace(/^xl\/\.\.\//, ''));
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
return m;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// 엑셀 기본 서식 중 날짜·시각인 것들. 사용자가 만든 서식은 styles.xml 에서 읽는다.
|
|
146
|
+
const 기본날짜서식 = new Set([14, 15, 16, 17, 18, 19, 20, 21, 22, 27, 30, 36, 45, 46, 47, 50, 57]);
|
|
147
|
+
|
|
148
|
+
function 날짜스타일(stylesXml) {
|
|
149
|
+
// numFmtId → 날짜인가
|
|
150
|
+
const 날짜Fmt = new Set(기본날짜서식);
|
|
151
|
+
for (const n of tags(stylesXml)) {
|
|
152
|
+
if (n.name === 'numFmt' && !n.closing) {
|
|
153
|
+
const id = Number(n.attrs.numFmtId);
|
|
154
|
+
const code = n.attrs.formatCode ?? '';
|
|
155
|
+
// 서식 문자열에 연·월·일·시가 들어 있으면 날짜로 본다.
|
|
156
|
+
// 따옴표 안의 글자는 그냥 붙는 말이라 빼고 본다.
|
|
157
|
+
const 순수 = code.replace(/"[^"]*"/g, '').replace(/\[[^\]]*\]/g, '');
|
|
158
|
+
if (/[ymdhs]/i.test(순수) && !/^[#0.,%\s]*$/.test(순수)) 날짜Fmt.add(id);
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
// cellXfs 의 순서가 곧 셀의 s= 값이다.
|
|
162
|
+
const 셀서식 = [];
|
|
163
|
+
let cellXfs = false;
|
|
164
|
+
for (const n of tags(stylesXml)) {
|
|
165
|
+
if (n.name === 'cellXfs') { cellXfs = !n.closing; continue; }
|
|
166
|
+
if (cellXfs && n.name === 'xf' && !n.closing) 셀서식.push(Number(n.attrs.numFmtId ?? 0));
|
|
167
|
+
}
|
|
168
|
+
return 셀서식.map((id) => 날짜Fmt.has(id));
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
// 엑셀의 날짜는 1899-12-30 부터 센 날수다. 1900 년 윤년 버그 때문에 30일이 기준이다.
|
|
172
|
+
const 기준 = Date.UTC(1899, 11, 30);
|
|
173
|
+
|
|
174
|
+
function 날짜로(n) {
|
|
175
|
+
if (!Number.isFinite(n) || n <= 0) return null;
|
|
176
|
+
const ms = 기준 + Math.round(n * 86400000);
|
|
177
|
+
const d = new Date(ms);
|
|
178
|
+
if (Number.isNaN(d.getTime())) return null;
|
|
179
|
+
const p = (x, w = 2) => String(x).padStart(w, '0');
|
|
180
|
+
const 날 = `${d.getUTCFullYear()}-${p(d.getUTCMonth() + 1)}-${p(d.getUTCDate())}`;
|
|
181
|
+
const 시 = n % 1 === 0 ? '' : ` ${p(d.getUTCHours())}:${p(d.getUTCMinutes())}:${p(d.getUTCSeconds())}`;
|
|
182
|
+
return 날 + 시;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
function 시트읽기(xml, { shared, 날짜여부 }) {
|
|
186
|
+
const rows = [];
|
|
187
|
+
let 지금줄 = null;
|
|
188
|
+
let 줄번호 = 0;
|
|
189
|
+
let 셀 = null;
|
|
190
|
+
let 안에 = null; // 'v' | 't' | null
|
|
191
|
+
let 모은글 = '';
|
|
192
|
+
let 최대칸 = 0;
|
|
193
|
+
|
|
194
|
+
const 셀마감 = () => {
|
|
195
|
+
if (!셀) return;
|
|
196
|
+
let 값 = 모은글;
|
|
197
|
+
if (셀.t === 's') {
|
|
198
|
+
const i = Number(값);
|
|
199
|
+
값 = Number.isInteger(i) && i >= 0 && i < shared.length ? shared[i] : '';
|
|
200
|
+
} else if (셀.t === 'b') {
|
|
201
|
+
값 = 값 === '1' ? 'TRUE' : 'FALSE';
|
|
202
|
+
} else if (셀.t === 'e') {
|
|
203
|
+
// #REF! 같은 오류값. 지우면 왜 비었는지 알 수 없다.
|
|
204
|
+
값 = 값 || '#오류';
|
|
205
|
+
} else if (셀.t === 'str' || 셀.t === 'inlineStr') {
|
|
206
|
+
// 그대로
|
|
207
|
+
} else if (값 !== '' && 날짜여부[셀.s] && /^-?\d+(\.\d+)?$/.test(값)) {
|
|
208
|
+
값 = 날짜로(Number(값)) ?? 값;
|
|
209
|
+
}
|
|
210
|
+
if (값 !== '') {
|
|
211
|
+
while (지금줄.length < 셀.col - 1) 지금줄.push('');
|
|
212
|
+
지금줄[셀.col - 1] = 값;
|
|
213
|
+
if (셀.col > 최대칸) 최대칸 = 셀.col;
|
|
214
|
+
}
|
|
215
|
+
셀 = null;
|
|
216
|
+
모은글 = '';
|
|
217
|
+
};
|
|
218
|
+
|
|
219
|
+
for (const n of tags(xml)) {
|
|
220
|
+
if (n.text !== undefined) { if (안에) 모은글 += unescapeXml(n.text); continue; }
|
|
221
|
+
if (n.name === 'row') {
|
|
222
|
+
if (n.closing) {
|
|
223
|
+
셀마감();
|
|
224
|
+
// 건너뛴 빈 줄도 자리를 지킨다. 안 그러면 행 번호가 밀린다.
|
|
225
|
+
while (rows.length < 줄번호 - 1) rows.push([]);
|
|
226
|
+
rows.push(지금줄 ?? []);
|
|
227
|
+
지금줄 = null;
|
|
228
|
+
} else if (!n.selfClosing) {
|
|
229
|
+
지금줄 = [];
|
|
230
|
+
줄번호 = Number(n.attrs.r) || rows.length + 1;
|
|
231
|
+
}
|
|
232
|
+
continue;
|
|
233
|
+
}
|
|
234
|
+
if (n.name === 'c') {
|
|
235
|
+
if (n.closing) { 셀마감(); continue; }
|
|
236
|
+
셀마감();
|
|
237
|
+
const { col } = n.attrs.r ? cellRef(n.attrs.r) : { col: (지금줄?.length ?? 0) + 1 };
|
|
238
|
+
셀 = { col: col || 1, t: n.attrs.t ?? null, s: Number(n.attrs.s ?? 0) };
|
|
239
|
+
모은글 = '';
|
|
240
|
+
if (n.selfClosing) { 셀 = null; } // <c r="A1"/> — 빈 칸
|
|
241
|
+
continue;
|
|
242
|
+
}
|
|
243
|
+
if (n.name === 'v' || n.name === 't') {
|
|
244
|
+
안에 = n.closing || n.selfClosing ? null : n.name;
|
|
245
|
+
continue;
|
|
246
|
+
}
|
|
247
|
+
// <f> 는 수식이다. 값은 <v> 에 따로 들어 있으므로 수식 자체는 안 읽는다.
|
|
248
|
+
if (n.name === 'f' && !n.closing) 안에 = null;
|
|
249
|
+
}
|
|
250
|
+
셀마감();
|
|
251
|
+
if (지금줄) rows.push(지금줄);
|
|
252
|
+
|
|
253
|
+
// 오른쪽 끝을 고른다
|
|
254
|
+
for (const r of rows) while (r.length < 최대칸) r.push('');
|
|
255
|
+
return rows;
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
// ── CSV ─────────────────────────────────────────────────────────────────
|
|
259
|
+
|
|
260
|
+
export function toCsv(rows) {
|
|
261
|
+
const 칸 = (v) => {
|
|
262
|
+
const s = String(v ?? '');
|
|
263
|
+
return /[",\n\r]/.test(s) ? `"${s.replace(/"/g, '""')}"` : s;
|
|
264
|
+
};
|
|
265
|
+
return rows.map((r) => r.map(칸).join(',')).join('\n');
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
// ── 바깥에서 쓰는 것 ────────────────────────────────────────────────────
|
|
269
|
+
|
|
270
|
+
/** 이 바이트들이 xlsx(암호 없는) 인가. */
|
|
271
|
+
export function looksXlsx(buf) {
|
|
272
|
+
return looksZip(buf);
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
/**
|
|
276
|
+
* 엑셀이 암호로 잠긴 파일인가.
|
|
277
|
+
*
|
|
278
|
+
* 암호가 걸리면 zip 이 아니라 OLE 복합문서로 감싸인다. 앞머리 여덟 바이트가
|
|
279
|
+
* 그 표식이다. .xls (옛 형식) 도 같은 표식이라, 여기서는 '풀어야 읽는 것' 으로
|
|
280
|
+
* 한데 묶는다. 어느 쪽이든 엑셀을 시켜야 읽을 수 있다.
|
|
281
|
+
*/
|
|
282
|
+
const OLE = Buffer.from([0xD0, 0xCF, 0x11, 0xE0, 0xA1, 0xB1, 0x1A, 0xE1]);
|
|
283
|
+
export function looksOle(buf) {
|
|
284
|
+
return buf.length >= 8 && buf.subarray(0, 8).equals(OLE);
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
/**
|
|
288
|
+
* xlsx 를 시트별 표로 읽는다.
|
|
289
|
+
* @returns {{ sheets: Array<{name:string, hidden:boolean, rows:string[][]}>, notes: string[] }}
|
|
290
|
+
*/
|
|
291
|
+
export function readXlsx(buf) {
|
|
292
|
+
const notes = [];
|
|
293
|
+
const { files, skipped } = readZip(buf, {
|
|
294
|
+
only: (n) => n.startsWith('xl/') || n === '[Content_Types].xml',
|
|
295
|
+
});
|
|
296
|
+
for (const s of skipped) notes.push(`${s.name} — ${s.why}`);
|
|
297
|
+
|
|
298
|
+
const 글 = (p) => {
|
|
299
|
+
const b = files.get(p);
|
|
300
|
+
return b ? b.toString('utf8') : null;
|
|
301
|
+
};
|
|
302
|
+
|
|
303
|
+
const wb = 글('xl/workbook.xml');
|
|
304
|
+
if (!wb) throw new Error('엑셀 파일이 아닙니다 — 안에 workbook.xml 이 없습니다');
|
|
305
|
+
|
|
306
|
+
const rels = 관계(글('xl/_rels/workbook.xml.rels') ?? '');
|
|
307
|
+
const shared = 공용문자열(글('xl/sharedStrings.xml') ?? '');
|
|
308
|
+
const 날짜여부 = 날짜스타일(글('xl/styles.xml') ?? '');
|
|
309
|
+
|
|
310
|
+
const 목록 = 시트목록(wb, rels);
|
|
311
|
+
const sheets = [];
|
|
312
|
+
for (const s of 목록) {
|
|
313
|
+
const xml = s.path ? 글(s.path) : null;
|
|
314
|
+
if (!xml) { notes.push(`시트 '${s.name}' 의 내용을 못 찾았습니다`); continue; }
|
|
315
|
+
sheets.push({ name: s.name, hidden: s.hidden, rows: 시트읽기(xml, { shared, 날짜여부 }) });
|
|
316
|
+
}
|
|
317
|
+
if (!sheets.length) throw new Error('읽을 수 있는 시트가 없습니다');
|
|
318
|
+
return { sheets, notes };
|
|
319
|
+
}
|
package/src/ui/ansi.js
CHANGED
|
@@ -1,22 +1,52 @@
|
|
|
1
|
-
// 화면 출력 기본기 — 색, 커서, 폭
|
|
1
|
+
// 화면 출력 기본기 — 색, 커서, 폭 계산, 상자. 외부 의존성 없음.
|
|
2
2
|
|
|
3
3
|
// 파이프로 넘길 때도 색을 보고 싶으면 FORCE_COLOR=1
|
|
4
4
|
const ON = (process.stdout.isTTY || process.env.FORCE_COLOR === '1') && process.env.NO_COLOR === undefined;
|
|
5
5
|
|
|
6
6
|
const E = (n) => (s) => (ON ? `\x1b[${n}m${s}\x1b[0m` : String(s));
|
|
7
7
|
|
|
8
|
+
// 흐린 글자의 밝기. 256색을 못 쓰는 옛 콘솔이면 90 으로 되돌린다.
|
|
9
|
+
function GRAY() {
|
|
10
|
+
const 뜻 = String(process.env.DEEL_CONTRAST ?? '').toLowerCase();
|
|
11
|
+
if (뜻 === 'low') return 90;
|
|
12
|
+
const 옛콘솔 = process.env.TERM === 'dumb';
|
|
13
|
+
if (옛콘솔) return 뜻 === 'high' ? 37 : 90;
|
|
14
|
+
return 뜻 === 'high' ? '38;5;252' : '38;5;245';
|
|
15
|
+
}
|
|
16
|
+
|
|
8
17
|
export const c = {
|
|
9
18
|
dim: E(2),
|
|
10
19
|
bold: E(1),
|
|
20
|
+
italic: E(3),
|
|
21
|
+
under: E(4),
|
|
11
22
|
red: E(31),
|
|
12
23
|
green: E(32),
|
|
13
24
|
yellow: E(33),
|
|
14
25
|
blue: E(34),
|
|
15
26
|
magenta: E(35),
|
|
16
27
|
cyan: E(36),
|
|
17
|
-
|
|
28
|
+
white: E(37),
|
|
29
|
+
// 흐린 글자.
|
|
30
|
+
//
|
|
31
|
+
// 예전에는 90(밝은 검정)이었는데, 배경이 어두우면 배경에 묻히고 밝으면
|
|
32
|
+
// 더 안 보인다. 실제로 "연한 글자가 잘 안 보인다" 는 말을 들었다.
|
|
33
|
+
// 그래서 어느 배경에서도 읽히는 중간 회색을 기본으로 쓴다.
|
|
34
|
+
//
|
|
35
|
+
// 눈에 맞게 바꿀 수 있다:
|
|
36
|
+
// DEEL_CONTRAST=high 더 밝게 (밝은 배경이나 눈이 피로할 때)
|
|
37
|
+
// DEEL_CONTRAST=low 예전처럼 흐리게
|
|
38
|
+
gray: E(GRAY()),
|
|
39
|
+
// 밝은 계열 — 어두운 배경에서 본문과 구분이 필요할 때
|
|
40
|
+
hred: E(91),
|
|
41
|
+
hgreen: E(92),
|
|
42
|
+
hyellow: E(93),
|
|
43
|
+
hblue: E(94),
|
|
44
|
+
hmagenta: E(95),
|
|
45
|
+
hcyan: E(96),
|
|
18
46
|
bgRed: E(41),
|
|
19
47
|
bgGreen: E(42),
|
|
48
|
+
bgBlue: E(44),
|
|
49
|
+
bgGray: E(100),
|
|
20
50
|
};
|
|
21
51
|
|
|
22
52
|
export const cursor = {
|
|
@@ -48,9 +78,32 @@ export function width(str) {
|
|
|
48
78
|
|
|
49
79
|
export function pad(str, target, align = 'left') {
|
|
50
80
|
const gap = Math.max(0, target - width(str));
|
|
51
|
-
|
|
81
|
+
if (align === 'right') return ' '.repeat(gap) + str;
|
|
82
|
+
if (align === 'center') {
|
|
83
|
+
const l = Math.floor(gap / 2);
|
|
84
|
+
return ' '.repeat(l) + str + ' '.repeat(gap - l);
|
|
85
|
+
}
|
|
86
|
+
return str + ' '.repeat(gap);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// 색 코드를 건드리지 않고 보이는 폭 기준으로 자른다.
|
|
90
|
+
export function clip(str, max, tail = '…') {
|
|
91
|
+
if (width(str) <= max) return str;
|
|
92
|
+
let out = '';
|
|
93
|
+
let w = 0;
|
|
94
|
+
const budget = max - width(tail);
|
|
95
|
+
for (const ch of String(str)) {
|
|
96
|
+
const cw = width(ch);
|
|
97
|
+
if (w + cw > budget) break;
|
|
98
|
+
out += ch;
|
|
99
|
+
w += cw;
|
|
100
|
+
}
|
|
101
|
+
return out + tail;
|
|
52
102
|
}
|
|
53
103
|
|
|
104
|
+
// 터미널 가로 폭. 파이프로 넘어가면 알 수 없으니 넉넉히 잡는다.
|
|
105
|
+
export const cols = () => process.stdout.columns || 100;
|
|
106
|
+
|
|
54
107
|
export const say = (s = '') => process.stdout.write(s + '\n');
|
|
55
108
|
|
|
56
109
|
export function rule(label = '', total = 64) {
|
|
@@ -59,11 +112,43 @@ export function rule(label = '', total = 64) {
|
|
|
59
112
|
say(c.gray(left + '─'.repeat(Math.max(0, total - width(left)))));
|
|
60
113
|
}
|
|
61
114
|
|
|
115
|
+
// 채움 막대. 반쪽 칸까지 써서 좁은 폭에서도 눈금이 보인다.
|
|
62
116
|
export function bar(used, total, cells = 32) {
|
|
63
117
|
const ratio = total > 0 ? Math.min(1, used / total) : 0;
|
|
64
|
-
const
|
|
118
|
+
const exact = ratio * cells;
|
|
119
|
+
const full = Math.floor(exact);
|
|
120
|
+
const half = exact - full >= 0.5 && full < cells;
|
|
65
121
|
const tone = ratio > 0.85 ? c.red : ratio > 0.6 ? c.yellow : c.green;
|
|
66
|
-
return tone('█'.repeat(
|
|
122
|
+
return tone('█'.repeat(full) + (half ? '▌' : '')) + c.gray('░'.repeat(cells - full - (half ? 1 : 0)));
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// 상태줄용 얇은 막대.
|
|
126
|
+
export function gauge(ratio, cells = 10) {
|
|
127
|
+
const r = Math.min(1, Math.max(0, ratio));
|
|
128
|
+
const filled = Math.round(r * cells);
|
|
129
|
+
const tone = r > 0.85 ? c.hred : r > 0.6 ? c.hyellow : c.hgreen;
|
|
130
|
+
return tone('▰'.repeat(filled)) + c.gray('▱'.repeat(cells - filled));
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
const BOX = { tl: '╭', tr: '╮', bl: '╰', br: '╯', h: '─', v: '│' };
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* 둥근 모서리 상자. 안쪽 폭은 가장 긴 줄에 맞춘다.
|
|
137
|
+
* @param {string[]} lines 색이 들어 있어도 폭 계산은 맞는다.
|
|
138
|
+
*/
|
|
139
|
+
export function box(lines, { title = '', pad: gap = 1, tone = c.gray, max = cols() - 4 } = {}) {
|
|
140
|
+
const body = lines.map((l) => clip(l, max - gap * 2 - 2));
|
|
141
|
+
const inner = Math.max(
|
|
142
|
+
width(title) + 2,
|
|
143
|
+
...body.map((l) => width(l)),
|
|
144
|
+
) + gap * 2;
|
|
145
|
+
const top = title
|
|
146
|
+
? BOX.tl + BOX.h + ' ' + title + ' ' + BOX.h.repeat(Math.max(0, inner - width(title) - 3)) + BOX.tr
|
|
147
|
+
: BOX.tl + BOX.h.repeat(inner) + BOX.tr;
|
|
148
|
+
const out = [tone(top)];
|
|
149
|
+
for (const l of body) out.push(tone(BOX.v) + ' '.repeat(gap) + pad(l, inner - gap * 2) + ' '.repeat(gap) + tone(BOX.v));
|
|
150
|
+
out.push(tone(BOX.bl + BOX.h.repeat(inner) + BOX.br));
|
|
151
|
+
return out;
|
|
67
152
|
}
|
|
68
153
|
|
|
69
154
|
export const mark = {
|
|
@@ -72,4 +157,11 @@ export const mark = {
|
|
|
72
157
|
warn: c.yellow('⚠'),
|
|
73
158
|
dot: c.cyan('⏺'),
|
|
74
159
|
arrow: c.gray('›'),
|
|
160
|
+
think: c.magenta('✻'),
|
|
161
|
+
run: c.hcyan('▶'),
|
|
162
|
+
bar: c.gray('▏'),
|
|
163
|
+
tree: c.gray('└'),
|
|
164
|
+
branch: c.gray('├'),
|
|
75
165
|
};
|
|
166
|
+
|
|
167
|
+
export const COLOR_ON = ON;
|
package/src/ui/level.js
ADDED
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
// 사용자 수준. 화면에 무엇을 내놓을지만 정한다.
|
|
2
|
+
//
|
|
3
|
+
// 무엇을 바꾸는가: 보이는 명령의 개수, 설명의 말투, 첫 실행에서 물어보는 것
|
|
4
|
+
// 무엇을 안 바꾸는가: 안전 장치. 하나도 안 바꾼다.
|
|
5
|
+
//
|
|
6
|
+
// 초보라고 승인을 덜 받거나 작업 범위를 넓히지 않는다. 그건 배려가 아니라
|
|
7
|
+
// 위험을 떠넘기는 것이다. 초보일수록 되돌릴 수 있어야 한다.
|
|
8
|
+
//
|
|
9
|
+
// 감추는 것도 '못 쓰게' 가 아니라 '안 보이게' 다. 초보 수준에서도 /think 를
|
|
10
|
+
// 치면 그대로 먹는다. 목록에 안 띄울 뿐이다 — 처음 켠 사람에게 명령 열여덟
|
|
11
|
+
// 개를 들이밀면 아무것도 못 고른다.
|
|
12
|
+
|
|
13
|
+
export const LEVELS = {
|
|
14
|
+
쉬움: {
|
|
15
|
+
id: '쉬움',
|
|
16
|
+
en: 'beginner',
|
|
17
|
+
name: '쉬움',
|
|
18
|
+
hint: '권장값으로 바로 시작',
|
|
19
|
+
// 목록에 띄울 것. 나머지는 쳐도 먹지만 안 보인다.
|
|
20
|
+
show: [
|
|
21
|
+
'help', 'work', 'auto', 'code', 'plan', 'ask',
|
|
22
|
+
// ctx 를 초보 목록에 넣는다. 컨텍스트가 작게 잡히면 "왜 파일을 조금만 읽지"
|
|
23
|
+
// 가 되는데, 초보일수록 그 원인을 못 찾는다. 명령 하나로 끝나는 문제다.
|
|
24
|
+
'model', 'ctx', 'scan', 'undo', 'clear', 'sessions', 'cost', 'level', 'exit',
|
|
25
|
+
],
|
|
26
|
+
// 첫 실행에서 훑어 추천까지 해 준다
|
|
27
|
+
autoScan: true,
|
|
28
|
+
// 오류를 무엇을 하라는 말로 바꿔 준다
|
|
29
|
+
plainErrors: true,
|
|
30
|
+
},
|
|
31
|
+
|
|
32
|
+
개발자: {
|
|
33
|
+
id: '개발자',
|
|
34
|
+
en: 'developer',
|
|
35
|
+
name: '개발자',
|
|
36
|
+
hint: '전부 직접 만짐',
|
|
37
|
+
show: null, // null = 전부
|
|
38
|
+
autoScan: false,
|
|
39
|
+
plainErrors: false,
|
|
40
|
+
},
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
export const DEFAULT = '쉬움';
|
|
44
|
+
export const ORDER = ['쉬움', '개발자'];
|
|
45
|
+
|
|
46
|
+
export function normalize(v) {
|
|
47
|
+
const s = String(v ?? '').trim().toLowerCase();
|
|
48
|
+
if (!s) return null;
|
|
49
|
+
const 별명 = {
|
|
50
|
+
'쉬움': '쉬움', '초보': '쉬움', '초보자': '쉬움', 'beginner': '쉬움', 'easy': '쉬움', 'b': '쉬움',
|
|
51
|
+
'개발자': '개발자', '고급': '개발자', 'developer': '개발자', 'dev': '개발자', 'advanced': '개발자', 'd': '개발자',
|
|
52
|
+
};
|
|
53
|
+
return 별명[s] ?? (LEVELS[v] ? v : null);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export function get(id) {
|
|
57
|
+
return LEVELS[normalize(id) ?? DEFAULT];
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** 이 수준에서 목록에 띄울 명령인가. 안 띄운다고 못 쓰는 것은 아니다. */
|
|
61
|
+
export function shows(levelId, cmd) {
|
|
62
|
+
const lv = get(levelId);
|
|
63
|
+
return lv.show === null || lv.show.includes(cmd);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* 오류를 초보에게 맞는 말로 바꾼다.
|
|
68
|
+
*
|
|
69
|
+
* 원인을 숨기지 않는다 — 무엇을 하면 되는지를 앞에 놓고, 원래 문구는 뒤에 남긴다.
|
|
70
|
+
* 원인을 지우면 물어볼 수도 없게 된다.
|
|
71
|
+
*/
|
|
72
|
+
const 풀이 = [
|
|
73
|
+
{
|
|
74
|
+
when: /ECONNREFUSED|연결할 수 없|connect|fetch failed/i,
|
|
75
|
+
say: '모델이 안 켜져 있는 것 같습니다.\n LM Studio 나 Ollama 를 켜고 모델을 하나 올린 다음 다시 해보세요.\n 어떤 것이 떠 있는지 보려면 /scan',
|
|
76
|
+
},
|
|
77
|
+
{
|
|
78
|
+
when: /허용되지 않은 주소/,
|
|
79
|
+
say: '지금 연결된 곳이 아닌 데로 나가려 했습니다. 막힌 게 정상입니다.\n 다른 모델을 쓰시려면 /model 로 고르세요.',
|
|
80
|
+
},
|
|
81
|
+
{
|
|
82
|
+
when: /작업 범위 밖/,
|
|
83
|
+
say: '시작한 폴더 바깥의 파일은 건드리지 않습니다.\n 그 파일이 꼭 필요하면, 그 폴더에서 deel 을 다시 켜세요.',
|
|
84
|
+
},
|
|
85
|
+
{
|
|
86
|
+
when: /먼저 Read 로 읽어야/,
|
|
87
|
+
say: '고치기 전에 파일을 먼저 읽게 되어 있습니다. 잠시 후 다시 시도합니다.',
|
|
88
|
+
},
|
|
89
|
+
{
|
|
90
|
+
when: /401|403|인증|unauthorized/i,
|
|
91
|
+
say: '열쇠(API 키)가 없거나 맞지 않습니다.\n deel setup 으로 다시 넣어 주세요.',
|
|
92
|
+
},
|
|
93
|
+
{
|
|
94
|
+
when: /timeout|시간 초과/i,
|
|
95
|
+
say: '모델이 제때 답하지 않았습니다.\n 큰 모델이면 원래 느립니다. 잠시 뒤 다시 하거나 더 작은 모델을 골라 보세요.',
|
|
96
|
+
},
|
|
97
|
+
];
|
|
98
|
+
|
|
99
|
+
export function explain(levelId, message) {
|
|
100
|
+
const lv = get(levelId);
|
|
101
|
+
const raw = String(message ?? '');
|
|
102
|
+
if (!lv.plainErrors) return { text: raw, plain: false };
|
|
103
|
+
const hit = 풀이.find((r) => r.when.test(raw));
|
|
104
|
+
if (!hit) return { text: raw, plain: false };
|
|
105
|
+
return { text: hit.say, detail: raw, plain: true };
|
|
106
|
+
}
|
package/src/ui/prompt.js
CHANGED
|
@@ -51,6 +51,40 @@ export function ask(label, { mask = false, def = '' } = {}) {
|
|
|
51
51
|
});
|
|
52
52
|
}
|
|
53
53
|
|
|
54
|
+
/**
|
|
55
|
+
* REPL 안에서 암호를 받는다. 화면에 안 찍히게.
|
|
56
|
+
*
|
|
57
|
+
* REPL 은 readline 이 stdin 을 쥐고 있어서 위의 ask 를 그대로 못 쓴다.
|
|
58
|
+
* readline 이 되비추는 자리를 잠깐 가로채서 ● 로 바꾼다. 못 가로채면
|
|
59
|
+
* 아예 아무것도 안 찍는다 — 화면에 암호가 보이느니 안 보이고 치는 편이 낫다.
|
|
60
|
+
*
|
|
61
|
+
* @param {import('node:readline').Interface} rl
|
|
62
|
+
* @param {string} label 물어볼 말
|
|
63
|
+
* @param {() => Promise<string|null>} nextLine 한 줄 받아오는 함수 (REPL 의 큐)
|
|
64
|
+
*/
|
|
65
|
+
export async function askHidden(rl, label, nextLine) {
|
|
66
|
+
process.stdout.write(` ${c.gray('›')} ${label} `);
|
|
67
|
+
|
|
68
|
+
const 원래 = typeof rl?._writeToOutput === 'function' ? rl._writeToOutput : null;
|
|
69
|
+
if (원래) {
|
|
70
|
+
rl._writeToOutput = function (s) {
|
|
71
|
+
// 줄바꿈·지우기 같은 제어는 그대로 두고, 글자만 가린다.
|
|
72
|
+
if (/^[\r\n]+$/.test(s) || s.startsWith('\x1b')) return 원래.call(this, s);
|
|
73
|
+
this.output.write(c.gray('●'.repeat([...s].length)));
|
|
74
|
+
};
|
|
75
|
+
} else {
|
|
76
|
+
process.stdout.write(c.gray('(입력해도 화면에 안 보입니다) '));
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
try {
|
|
80
|
+
const a = await nextLine();
|
|
81
|
+
return a === null ? null : a.trim();
|
|
82
|
+
} finally {
|
|
83
|
+
if (원래) rl._writeToOutput = 원래;
|
|
84
|
+
say('');
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
54
88
|
// 목록에서 번호로 고르기.
|
|
55
89
|
// REPL 안에서는 readline 이 stdin 을 쥐고 있으므로 ask 를 갈아끼워 쓴다.
|
|
56
90
|
export async function pick(label, items, { def = 0, ask: askFn = ask } = {}) {
|