deel-local-cli 1.6.0 → 1.6.2

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.
@@ -194,6 +194,8 @@ export function checkCommand(cmd) {
194
194
  export function checkPaths(cmd, scope) {
195
195
  if (!scope) return true;
196
196
  for (const t of 경로낱말(String(cmd))) {
197
+ // 자료가 아닌 자리는 울타리를 안 묻는다 (봐주는자리 머리말).
198
+ if (봐주는자리(t)) continue;
197
199
  let abs;
198
200
  try { abs = scope.resolve(t); }
199
201
  catch (e) {
@@ -282,6 +284,65 @@ export function 코드조각인가(낱말) {
282
284
  return false;
283
285
  }
284
286
 
287
+ /*
288
+ * ── 울타리 밖이지만 막을 까닭이 없는 자리 ───────────────────────────────
289
+ *
290
+ * 울타리는 **사람의 자료**를 지키려고 있다. 그런데 두 가지가 자료가 아닌데도
291
+ * 같이 막혀서, 모델이 할 수 있는 일을 못 하게 만들고 있었다.
292
+ *
293
+ * ▶ Bash(soffice --convert-to txt 보고서.pptx > /dev/null 2>&1)
294
+ * └ 막힘 — 작업 범위 밖입니다: /dev/null
295
+ * ▶ Bash(ls -l /usr/bin/strings)
296
+ * └ 막힘 — 작업 범위 밖입니다: /usr/bin/strings
297
+ *
298
+ * 앞엣것은 **아무것도 저장되지 않는 자리**다. `2>/dev/null` 은 셸 명령 절반이
299
+ * 달고 다니는데, 그 한 조각 때문에 명령 전체가 거절됐다. 뒤엣것은 **이 PC 에
300
+ * 무엇이 깔렸는지 보는 것**이다. 그걸 못 보면 모델은 우회로를 못 찾는다 —
301
+ * 못 읽는 문서를 만났을 때 변환기가 있는지조차 확인할 수 없다.
302
+ *
303
+ * 실제로 그래서 헛돌았다. 문서를 못 읽었고, 변환하려니 막혔고, 남은 길이
304
+ * 없으니 같은 문을 계속 두드렸다. 1.5.8 에서 한 번 겪은 것과 같은 모양인데
305
+ * 그때는 걸린 명령 하나만 풀고 같은 갈래를 안 훑었다.
306
+ *
307
+ * **넓히는 것은 이 둘뿐이다.** 남의 홈 · /etc · /var · /tmp 는 그대로 막는다.
308
+ * 프로그램이 든 폴더는 자료가 아니고, 거기 쓰는 것은 어차피 OS 가 막는다.
309
+ */
310
+ const 버리는자리 = new Set([
311
+ '/dev/null', '/dev/stdout', '/dev/stderr', '/dev/tty',
312
+ 'nul', 'nul:', '\\\\.\\nul', // 윈도우
313
+ ]);
314
+
315
+ /**
316
+ * 프로그램이 사는 자리. 자료가 아니라 실행파일이다.
317
+ *
318
+ * **전부 소문자로 적는다** — 견줄 때 소문자로 낮춰서 보기 때문이다.
319
+ * `/Applications` 처럼 대문자를 섞어 적으면 그 줄만 조용히 안 걸린다.
320
+ */
321
+ const 프로그램자리 = [
322
+ '/bin', '/sbin', '/usr/bin', '/usr/sbin', '/usr/libexec',
323
+ '/usr/local/bin', '/usr/local/sbin',
324
+ '/opt/homebrew/bin', '/opt/homebrew/sbin', '/opt/local/bin',
325
+ '/snap/bin', '/applications', // 맥은 앱 꾸러미 안에 실행파일이 있다
326
+ 'c:/windows', 'c:/program files', 'c:/program files (x86)',
327
+ ];
328
+
329
+ /**
330
+ * 이 경로는 울타리를 안 물어도 되는 자리인가.
331
+ *
332
+ * @returns {string|null} 봐 주는 까닭. 아니면 null
333
+ */
334
+ export function 봐주는자리(경로) {
335
+ const p = String(경로 ?? '').replace(/\\/g, '/');
336
+ const 낮춤 = p.toLowerCase().replace(/\/+$/, '');
337
+ if (버리는자리.has(낮춤)) return '아무것도 저장되지 않는 자리';
338
+ // 한 칸 아래까지만 본다. `/usr/bin/strings` 는 되고 `/usr/bin/../../Users/남` 은
339
+ // 여기 오기 전에 resolve 로 펴져서 이 목록에 안 걸린다.
340
+ for (const 자리 of 프로그램자리) {
341
+ if (낮춤 === 자리 || 낮춤.startsWith(`${자리}/`)) return '프로그램이 있는 자리';
342
+ }
343
+ return null;
344
+ }
345
+
285
346
  /** 명령줄에서 경로처럼 보이는 낱말만 골라낸다. */
286
347
  export function 경로낱말(cmd) {
287
348
  const out = [];
@@ -0,0 +1,315 @@
1
+ // 이미 깔려 있는 변환기를 빌려 쓴다. 아무것도 설치하지 않는다.
2
+ //
3
+ // ── 왜 이걸 만드나 ──────────────────────────────────────────────────────
4
+ //
5
+ // 사내 자료는 형식이 제각각이다. 이름은 .pptx 인데 속은 옛 .ppt 이고, .doc 이고,
6
+ // .rtf 다. deel 은 hwpx·docx·pptx·xlsx·pdf 를 직접 읽지만 그 밖은 못 읽는다.
7
+ // 그때 여태 이렇게 끝났다 —
8
+ //
9
+ // ◧ Read(보고서.pptx)
10
+ // └ pptx 모양이 아닙니다 — 깨졌거나 다른 형식입니다.
11
+ //
12
+ // 길이 없으니 모델은 같은 파일을 몇 번씩 다시 열었다. 그런데 정작 그 PC 에는
13
+ // LibreOffice 가 깔려 있었다. 사람이 그 파일을 열어 보는 바로 그 프로그램이다.
14
+ //
15
+ // ── 빌려 쓰기의 규칙 ────────────────────────────────────────────────────
16
+ //
17
+ // 1.6.0 에서 `rg` 를 빌려 쓴 것과 **같은 원칙**이다 (tools/fastgrep.js).
18
+ //
19
+ // · 아무것도 설치하지 않는다. 없으면 없다고 말하고 끝낸다.
20
+ // · 있는지 한 번만 본다. 매번 물으면 없는 PC 에서 그 실패가 계속 쌓인다.
21
+ // · 끌 수 있다 (`DEEL_CONVERT=off`). 결과가 의심될 때 견줄 자리가 있어야 한다.
22
+ // · 셸을 안 거친다. 파일 이름에 빈칸·따옴표·한글이 들어와도 그대로 넘긴다.
23
+ // · 결과는 **작업 폴더 안**(.deel/tmp/)에 떨군다. 밖에 쓰면 울타리를 우리
24
+ // 손으로 넘는 셈이고, 남긴 것을 거둘 자리도 없어진다.
25
+ //
26
+ // 바뀐 글만 쓰고 원본은 한 글자도 안 건드린다. 변환은 읽기의 곁길이다.
27
+ import { spawnSync } from 'node:child_process';
28
+ import { 돌려보기 } from './spawn.js';
29
+ import {
30
+ existsSync, mkdirSync, readdirSync, readFileSync, rmSync, statSync,
31
+ } from 'node:fs';
32
+ import { basename, extname, join } from 'node:path';
33
+
34
+ /** 변환해서 읽어 볼 만한 확장자. 여기 없는 것은 손대지 않는다. */
35
+ const 바꿔볼확장자 = new Set([
36
+ '.ppt', '.doc', '.xls', // 옛 Office (OLE 복합문서)
37
+ '.rtf', '.odt', '.odp', '.ods', '.wpd',
38
+ '.pptx', '.docx', '.xlsx', // 겉만 그 이름이고 속이 다른 것
39
+ '.hwp', // 구형 한글 — soffice 가 읽는 판이 있다
40
+ ]);
41
+
42
+ /**
43
+ * 우리가 **영영 직접은 못 읽는** 갈래. 바꿔볼확장자 의 부분집합이다.
44
+ *
45
+ * 둘을 가르는 이유가 있다. `.pptx` 가 안 읽히는 것은 「깨졌거나 속이 다르다」는
46
+ * 뜻이라 docs.js 가 이미 정체를 짚어 준다 — 그 말이 우리 말보다 낫다. 반면
47
+ * `.ppt` 는 정체가 분명하고 우리에게 길이 없다. 그때만 「없다」고 못 박는다.
48
+ */
49
+ const 직접못읽는확장자 = new Set([
50
+ '.ppt', '.doc', '.xls', '.xlt', '.rtf', '.odt', '.odp', '.ods', '.wpd', '.hwp',
51
+ ]);
52
+
53
+ /** 이 파일을 변환해서 읽어 볼까. */
54
+ export function 바꿔볼까(경로) {
55
+ return 바꿔볼확장자.has(extname(String(경로 ?? '')).toLowerCase());
56
+ }
57
+
58
+ /** 바꾸는 것 말고는 길이 없는 갈래인가. */
59
+ export function 직접못읽나(경로) {
60
+ return 직접못읽는확장자.has(extname(String(경로 ?? '')).toLowerCase());
61
+ }
62
+
63
+ /*
64
+ * 이 PC 에 깔린 soffice 를 찾는다.
65
+ *
66
+ * PATH 에 없는 자리가 흔하다 — 맥은 앱 꾸러미 안에, 윈도우는 Program Files
67
+ * 밑에 있고 둘 다 PATH 에 안 걸린다. 사람이 쓰는 그 프로그램이 눈앞에 있는데
68
+ * "없습니다" 라고 말하는 것이 제일 아깝다.
69
+ */
70
+ const soffice자리 = [
71
+ '/Applications/LibreOffice.app/Contents/MacOS/soffice',
72
+ 'C:\\Program Files\\LibreOffice\\program\\soffice.exe',
73
+ 'C:\\Program Files (x86)\\LibreOffice\\program\\soffice.exe',
74
+ '/usr/bin/soffice', '/usr/local/bin/soffice', '/opt/homebrew/bin/soffice',
75
+ '/snap/bin/libreoffice',
76
+ ];
77
+
78
+ /*
79
+ * ── 윈도우에서 `.cmd` 는 그냥 못 띄운다 ─────────────────────────────────
80
+ *
81
+ * scoop·choco 로 깐 것은 `soffice.cmd` 같은 껍데기다. 그걸 spawnSync 로 그냥
82
+ * 부르면 **EINVAL** 이 난다(Node 20 부터 막혔다). 그러면 우리는 "이 PC 에
83
+ * 변환기가 없습니다" 라고 말하게 되는데 — 사람은 눈앞에서 그 프로그램을 쓰고
84
+ * 있다. 있다고 해 놓고 안 되는 것 다음으로 나쁜 것이 이것이다.
85
+ *
86
+ * `shell: true` 로 넘기면 되긴 하지만 node 가 DEP0190 경고를 내고, 그 지적이
87
+ * 맞다 — 경로에 빈칸이 있으면 그대로 깨진다. 사내 PC 의 `C:\Program Files\…`
88
+ * 가 정확히 그 꼴이다.
89
+ *
90
+ * 그래서 언어 서버 쪽에서 쓰는 것과 **같은 방법**을 쓴다 (lsp/client.js).
91
+ * `cmd /s /c` 는 뒤엣것이 따옴표로 시작해 따옴표로 끝나면 바깥 한 쌍을 떼어
92
+ * 내므로, 한 겹 더 둘러서 넘긴다.
93
+ */
94
+ function 명령짓기(cmd, 인자) {
95
+ const 셸필요 = process.platform === 'win32' && /\.(cmd|bat)$/i.test(String(cmd));
96
+ if (!셸필요) return { 실행: cmd, 인자, 덤: {} };
97
+ const 몰아쓰기 = `""${cmd}" ${인자.map((a) => `"${a}"`).join(' ')}"`;
98
+ return {
99
+ 실행: process.env.ComSpec || 'cmd.exe',
100
+ 인자: ['/d', '/s', '/c', 몰아쓰기],
101
+ 덤: { windowsVerbatimArguments: true },
102
+ };
103
+ }
104
+
105
+ /*
106
+ * 변환은 **비동기로** 부른다.
107
+ *
108
+ * soffice 는 처음 뜰 때 20초, 큰 문서면 그 이상 걸린다. 전에는 이 자리가
109
+ * spawnSync 였고, 그동안 Node 는 이벤트 루프를 통째로 멈췄다 — 그 90초 내내
110
+ * ESC 가 배달되지 않았다. 화면에는 도는 그림이 보이는데 키가 안 먹으니
111
+ * 사람 눈에는 프로그램이 고장 난 것처럼 보인다.
112
+ *
113
+ * 자식을 띄우고 기다리는 일은 tools/spawn.js 한 벌만 안다. 찾기(fastgrep)와
114
+ * 여기가 같은 것을 쓴다 — 따로 적어 두면 언젠가 한쪽만 고쳐진다.
115
+ */
116
+ function 부르기(cmd, 인자, opts = {}) {
117
+ const c = 명령짓기(cmd, 인자);
118
+ return 돌려보기(c.실행, c.인자, { ...opts, 덤: c.덤 });
119
+ }
120
+
121
+ /*
122
+ * 있는지 본다. 여기는 **동기로 둔다.**
123
+ *
124
+ * `--version` 은 곧바로 답하고, 한 세션에 한 번만 부른다(변환기찾기가 기억한다).
125
+ * 이걸 비동기로 만들면 변환기찾기·변환기말·못바꿈말이 줄줄이 비동기가 되고,
126
+ * 그 함수들은 안내문을 만드는 자리라 부르는 데가 많다. 90초를 없애려다
127
+ * 열 군데를 흔드는 것은 값이 안 맞는다.
128
+ */
129
+ function 돌아가나(cmd, 인자) {
130
+ const c = 명령짓기(cmd, 인자);
131
+ const r = spawnSync(c.실행, c.인자, { encoding: 'utf8', timeout: 15000, windowsHide: true, ...c.덤 });
132
+ return !r.error && r.status === 0;
133
+ }
134
+
135
+ let 본것 = null;
136
+
137
+ /**
138
+ * 쓸 수 있는 변환기. 한 번만 찾는다.
139
+ *
140
+ * @returns {{soffice: string|null, textutil: boolean, 왜: string|null}}
141
+ */
142
+ export function 변환기찾기({ 다시 = false, env = process.env, platform = process.platform } = {}) {
143
+ if (본것 && !다시) return 본것;
144
+ if (env.DEEL_CONVERT === 'off') {
145
+ 본것 = { soffice: null, textutil: false, 왜: 'DEEL_CONVERT=off 로 꺼 두었습니다' };
146
+ return 본것;
147
+ }
148
+ let soffice = null;
149
+ if (돌아가나('soffice', ['--version'])) soffice = 'soffice';
150
+ else {
151
+ for (const p of soffice자리) {
152
+ if (existsSync(p) && 돌아가나(p, ['--version'])) { soffice = p; break; }
153
+ }
154
+ }
155
+ // textutil 은 맥에만 있고 .doc·.rtf 를 아주 빨리 바꾼다. soffice 보다 먼저 쓴다.
156
+ const textutil = platform === 'darwin' && 돌아가나('textutil', ['-info', '/dev/null']);
157
+ 본것 = { soffice, textutil, 왜: null };
158
+ return 본것;
159
+ }
160
+
161
+ /** 검사가 원래대로 돌려놓을 때. */
162
+ export function 변환기잊기() { 본것 = null; return null; }
163
+
164
+ /** 사람에게 보여줄 한 줄. */
165
+ export function 변환기말(찾은것 = 변환기찾기()) {
166
+ if (찾은것.왜) return 찾은것.왜;
167
+ const 것들 = [];
168
+ if (찾은것.soffice) 것들.push('soffice');
169
+ if (찾은것.textutil) 것들.push('textutil');
170
+ return 것들.length ? `${것들.join(' · ')} 를 빌려 씁니다` : '이 PC 에 쓸 수 있는 변환기가 없습니다';
171
+ }
172
+
173
+ /*
174
+ * 못 바꿨을 때 하는 말.
175
+ *
176
+ * 여기가 「안 된다고 확실히 말하는」 자리다. 여태 이 자리에서 나간 말은
177
+ * `바이너리 파일입니다 — 텍스트로 읽을 수 없습니다` 한 줄이었다. 그 말에는
178
+ * 까닭도 길도 없어서, 모델은 같은 파일을 Read 로 또 열거나 Bash 로 우회로를
179
+ * 찾다 울타리에 막히고, 그 왕복으로 컨텍스트만 탔다.
180
+ *
181
+ * 그래서 세 가지를 한꺼번에 말한다 — 무엇이라서 못 읽는지, 이 PC 에 무엇이
182
+ * 없어서 못 바꾸는지, 사람이 무엇을 하면 되는지. 그리고 **다시 열지 말라**고
183
+ * 못 박는다. 다시 열어도 결과가 같은 것은 우리가 아는 사실이다.
184
+ */
185
+ export function 못바꿈말(보인이름, 확장자, 찾은것 = 변환기찾기()) {
186
+ const 갈래 = String(확장자 ?? '').replace(/^\./, '').toLowerCase();
187
+ const 없다 = !찾은것.soffice && !찾은것.textutil;
188
+ const 길 = 없다
189
+ ? '이 PC 에 LibreOffice(soffice)가 없어서 바꿔 읽을 수도 없습니다.'
190
+ : '이 PC 의 변환기로 바꿔 봤지만 글이 안 나왔습니다.';
191
+ return `${보인이름} 은 deel 이 직접 못 읽는 형식입니다 (.${갈래}).\n`
192
+ + `${길}\n`
193
+ + `해결: 원래 프로그램에서 ${새이름(갈래)} 로 저장한 뒤 다시 주세요.`
194
+ + (없다 ? ' 또는 LibreOffice 를 설치하면 deel 이 빌려 씁니다.' : '')
195
+ + '\n**같은 파일을 다시 Read 하지 마세요. 결과는 같습니다.**';
196
+ }
197
+
198
+ /** 그 갈래를 무엇으로 저장하면 읽히는지. */
199
+ function 새이름(갈래) {
200
+ if (갈래 === 'ppt') return 'pptx';
201
+ if (갈래 === 'doc' || 갈래 === 'rtf' || 갈래 === 'odt' || 갈래 === 'wpd') return 'docx';
202
+ if (갈래 === 'xls' || 갈래 === 'ods') return 'xlsx';
203
+ if (갈래 === 'odp') return 'pptx';
204
+ if (갈래 === 'hwp') return 'hwpx';
205
+ return 'pdf 나 txt';
206
+ }
207
+
208
+ /** 떨굴 자리. 작업 폴더 안이라야 한다. */
209
+ export function 임시자리(root) {
210
+ return join(root, '.deel', 'tmp');
211
+ }
212
+
213
+ /**
214
+ * 문서를 글로 바꿔서 읽는다.
215
+ *
216
+ * @param {string} abs 읽을 파일 (절대경로)
217
+ * @param {string} root 작업 폴더 — 바꾼 것을 여기 안에 떨군다
218
+ * @returns {{ok:true, text:string, 쓴것:string} | {ok:false, 왜:string, 없음?:boolean}}
219
+ */
220
+ export async function 글로바꾸기(abs, root, { timeout = 90000, 찾은것 = null, signal = null } = {}) {
221
+ const 있는것 = 찾은것 ?? 변환기찾기();
222
+ if (!있는것.soffice && !있는것.textutil) {
223
+ return { ok: false, 없음: true, 왜: 있는것.왜 ?? '이 PC 에 변환기가 없습니다' };
224
+ }
225
+ if (!existsSync(abs)) return { ok: false, 왜: `없는 파일입니다: ${abs}` };
226
+
227
+ const 받을곳 = 임시자리(root);
228
+ try { mkdirSync(받을곳, { recursive: true }); } catch (err) {
229
+ return { ok: false, 왜: `바꿔 놓을 자리를 못 만들었습니다: ${err.message}` };
230
+ }
231
+
232
+ /*
233
+ * textutil 을 먼저 본다 (맥).
234
+ *
235
+ * soffice 는 처음 뜰 때 몇십 초가 걸린다 — 실제로 20초를 재 봤다. textutil 은
236
+ * OS 에 붙어 있는 것이라 곧바로 답한다. 둘 다 되는 자리면 빠른 쪽이 맞다.
237
+ */
238
+ const 확장자 = extname(abs).toLowerCase();
239
+ if (있는것.textutil && ['.doc', '.rtf', '.odt', '.docx'].includes(확장자)) {
240
+ const 나온것 = join(받을곳, `${basename(abs, 확장자)}.txt`);
241
+ const r = await 부르기('textutil', ['-convert', 'txt', '-output', 나온것, abs], { timeout, signal });
242
+ if (!r.error && r.status === 0 && existsSync(나온것)) {
243
+ const text = readFileSync(나온것, 'utf8');
244
+ // soffice 쪽과 같은 규칙 — 읽었으면 사본을 남기지 않는다.
245
+ try { rmSync(나온것, { force: true }); } catch { /* 임시치우기가 거둔다 */ }
246
+ return { ok: true, text, 쓴것: 'textutil', 파일: 나온것 };
247
+ }
248
+ }
249
+
250
+ if (!있는것.soffice) return { ok: false, 없음: true, 왜: '이 파일을 바꿀 변환기가 없습니다' };
251
+
252
+ /*
253
+ * soffice 는 결과 파일 이름을 제가 정한다.
254
+ *
255
+ * 그래서 넣기 전에 폴더에 무엇이 있었는지 적어 두고, 나온 뒤에 늘어난 것을
256
+ * 찾는다. 이름을 미리 짐작하면 확장자가 겹치거나 다른 판에서 어긋난다.
257
+ *
258
+ * -env:UserInstallation 을 따로 주는 까닭: soffice 는 이미 떠 있는 제 인스턴스가
259
+ * 있으면 새 부탁을 그쪽에 넘기고 **곧바로 끝나 버린다.** 사람이 LibreOffice 를
260
+ * 열어 둔 PC 에서 변환이 조용히 아무것도 안 하는 것이 그 모습이다. 우리 몫의
261
+ * 프로필을 따로 주면 그 일이 안 생긴다.
262
+ */
263
+ const 프로필 = join(받을곳, '.soffice-profile');
264
+ const 전 = new Set(existsSync(받을곳) ? readdirSync(받을곳) : []);
265
+ const r = await 부르기(있는것.soffice, [
266
+ `-env:UserInstallation=file:///${프로필.replace(/\\/g, '/').replace(/^\/+/, '')}`,
267
+ '--headless', '--norestore',
268
+ '--convert-to', 'txt:Text',
269
+ '--outdir', 받을곳,
270
+ abs,
271
+ ], { timeout, signal });
272
+
273
+ if (r.error) {
274
+ return { ok: false, 왜: `변환기를 못 돌렸습니다: ${r.error.message}` };
275
+ }
276
+ const 새로생긴것 = (existsSync(받을곳) ? readdirSync(받을곳) : [])
277
+ .filter((f) => !전.has(f) && f.toLowerCase().endsWith('.txt'));
278
+ if (!새로생긴것.length) {
279
+ const 끄트머리 = String(r.stderr || r.stdout || '').trim().split('\n').slice(-2).join(' ').slice(0, 200);
280
+ return { ok: false, 왜: `변환기가 글을 못 뽑았습니다${끄트머리 ? ` (${끄트머리})` : ''}` };
281
+ }
282
+ const 나온것 = join(받을곳, 새로생긴것[0]);
283
+ let text = '';
284
+ try { text = readFileSync(나온것, 'utf8'); } catch (err) {
285
+ return { ok: false, 왜: `바꾼 글을 못 읽었습니다: ${err.message}` };
286
+ } finally {
287
+ /*
288
+ * 읽었으면 곧바로 지운다.
289
+ *
290
+ * 글은 이미 손에 있다. 남겨 두면 **사람 문서의 알맹이가 사본으로 작업
291
+ * 폴더에 쌓인다** — 그대로 커밋되거나 압축되어 나갈 수 있는 자리다.
292
+ * 나중에 거두겠다는 약속은 세션이 죽으면 안 지켜진다. 지금 지운다.
293
+ */
294
+ try { rmSync(나온것, { force: true }); } catch { /* 못 지우면 임시치우기가 거둔다 */ }
295
+ }
296
+ return { ok: true, text, 쓴것: 'soffice', 파일: 나온것 };
297
+ }
298
+
299
+ /** 떨궈 둔 것을 거둔다. 검사와 세션 끝에서 부른다. */
300
+ export function 임시치우기(root) {
301
+ const 자리 = 임시자리(root);
302
+ if (!existsSync(자리)) return 0;
303
+ let 몇개 = 0;
304
+ for (const f of readdirSync(자리)) {
305
+ const p = join(자리, f);
306
+ try {
307
+ // 사람이 넣어 둔 것은 안 건드린다. 우리가 만든 것만 거둔다.
308
+ if (f === '.soffice-profile' || (statSync(p).isFile() && f.toLowerCase().endsWith('.txt'))) {
309
+ rmSync(p, { recursive: true, force: true });
310
+ 몇개++;
311
+ }
312
+ } catch { /* 못 지워도 다음에 지운다 */ }
313
+ }
314
+ return 몇개;
315
+ }
@@ -134,12 +134,16 @@ export const 도구설명EN = {
134
134
  },
135
135
  },
136
136
  Ask: {
137
- desc: 'Ask the person one question at a genuine fork. Never write "let me know" and stop;'
138
- + ' asking in prose ends the turn and throws away everything you have looked at.'
139
- + ' Give 2-4 options and they answer with a single number.'
137
+ desc: 'Ask the person one question at a genuine fork. **First fill in `이해` with what you'
138
+ + ' understood this request to be**, and only ask if something is genuinely left to decide.'
139
+ + ' Never write "let me know" and stop; asking in prose ends the turn and throws away'
140
+ + ' everything you have looked at. Give 2-4 options and they answer with a single number.'
140
141
  + ' **Never ask back something the user already said.** "Tidy up the files" is the answer —'
141
142
  + ' go and do it. Deciding how is your job, not theirs.',
142
143
  params: {
144
+ 이해: 'one line: what you understood this request to be. The person reads this line to judge'
145
+ + ' whether you actually read them. "I understand your request" is not an understanding —'
146
+ + ' name the actual work',
143
147
  question: 'one sentence. Make it clear what has to be decided',
144
148
  options: '2-4 choices, one line each, so the difference is visible',
145
149
  },
package/src/tools/docs.js CHANGED
@@ -56,6 +56,77 @@ export function looksOldHwp(경로, buf) {
56
56
  return buf.length >= 8 && buf.readUInt32LE(0) === 0xe011cfd0 && buf.readUInt32LE(4) === 0xe11ab1a1;
57
57
  }
58
58
 
59
+ /*
60
+ * ── 겉과 속이 다를 때, 속이 무엇인지 말한다 ─────────────────────────────
61
+ *
62
+ * 여태는 이랬다 —
63
+ *
64
+ * ◧ Read(보고서.pptx)
65
+ * └ pptx 모양이 아닙니다 — 겉은 pptx 인데 속이 zip 꾸러미가 아닙니다.
66
+ * 깨졌거나 다른 형식입니다.
67
+ *
68
+ * 「깨졌거나 다른 형식」 은 **아무것도 안 알려 준다.** 깨진 것이면 할 일이
69
+ * 없고, 다른 형식이면 바꾸면 되는데 둘을 안 갈라 줬다. 그래서 모델은 같은
70
+ * 파일을 몇 번씩 다시 열어 봤다 — 답이 매번 같은데도.
71
+ *
72
+ * 앞 몇 바이트만 보면 무엇인지 거의 다 안다. 알면 길도 같이 줄 수 있다.
73
+ * 옛 hwp 안내(아래)가 이미 그렇게 하고 있었는데, 이쪽만 안 하고 있었다.
74
+ */
75
+ const 서명들 = [
76
+ { 이름: '옛 Office 파일 (.ppt · .doc · .xls)', 바이트: [0xd0, 0xcf, 0x11, 0xe0, 0xa1, 0xb1, 0x1a, 0xe1] },
77
+ { 이름: 'PDF', 바이트: [0x25, 0x50, 0x44, 0x46] }, // %PDF
78
+ { 이름: 'RTF 문서', 바이트: [0x7b, 0x5c, 0x72, 0x74, 0x66] }, // {\rtf
79
+ { 이름: 'PNG 그림', 바이트: [0x89, 0x50, 0x4e, 0x47] },
80
+ { 이름: 'JPEG 그림', 바이트: [0xff, 0xd8, 0xff] },
81
+ { 이름: 'gzip 압축', 바이트: [0x1f, 0x8b] },
82
+ { 이름: '7z 압축', 바이트: [0x37, 0x7a, 0xbc, 0xaf] },
83
+ { 이름: 'RAR 압축', 바이트: [0x52, 0x61, 0x72, 0x21] },
84
+ ];
85
+
86
+ /** 앞 몇 바이트로 정체를 짚는다. 모르면 null. */
87
+ export function 속내용(buf) {
88
+ if (!Buffer.isBuffer(buf) || buf.length < 2) return null;
89
+ for (const s of 서명들) {
90
+ if (s.바이트.every((b, i) => buf[i] === b)) return s.이름;
91
+ }
92
+ const 앞 = buf.subarray(0, 200).toString('latin1').trim().toLowerCase();
93
+ if (/^<(!doctype html|html|\?xml)/.test(앞)) return 앞.startsWith('<?xml') ? 'XML 글' : 'HTML 글';
94
+ // 남은 것이 전부 읽을 수 있는 글자면 그냥 글 파일이다.
95
+ const 이상한바이트 = buf.subarray(0, 512).filter((b) => b < 9 || (b > 13 && b < 32)).length;
96
+ if (!이상한바이트) return '그냥 글 파일';
97
+ return null;
98
+ }
99
+
100
+ /**
101
+ * 겉과 속이 다를 때 하는 말. **길을 같이 준다.**
102
+ *
103
+ * @param {string} 갈래 겉으로 본 갈래 (pptx · docx · hwpx)
104
+ * @param {string|null} 속 속내용() 이 짚은 것
105
+ */
106
+ export function 겉속다름말(갈래, 속) {
107
+ const 머리 = `겉은 ${갈래} 인데 속이 다릅니다`;
108
+ if (!속) {
109
+ return `${머리} — 무엇인지 알아보지 못했습니다 (zip 꾸러미가 아닙니다).\n`
110
+ + ' 파일이 깨졌을 수 있습니다. 이 파일로는 더 해 볼 것이 없으니 사용자에게 알리세요.';
111
+ }
112
+ if (속.startsWith('옛 Office')) {
113
+ return `${머리} — 실제로는 ${속} 입니다.\n`
114
+ + ` 이름만 ${갈래} 로 바뀐 옛 형식이라 이 도구로는 못 읽습니다.\n`
115
+ + ` PowerPoint·Word 에서 열어 ${갈래} 로 다시 저장하거나,\n`
116
+ + ` soffice 가 깔려 있으면 soffice --headless --convert-to ${갈래} <파일> 로 바꾸세요.`;
117
+ }
118
+ if (속 === 'PDF') {
119
+ return `${머리} — 실제로는 PDF 입니다.\n`
120
+ + ` 확장자만 ${갈래} 입니다. 파일 이름을 .pdf 로 바꿔서 Read 하면 그대로 읽힙니다.`;
121
+ }
122
+ if (속 === 'HTML 글' || 속 === 'XML 글' || 속 === '그냥 글 파일') {
123
+ return `${머리} — 실제로는 ${속} 입니다.\n`
124
+ + ' 글 파일이라 확장자를 .txt 로 바꾸거나 그대로 Bash 로 읽을 수 있습니다.';
125
+ }
126
+ return `${머리} — 실제로는 ${속} 입니다.\n`
127
+ + ` ${갈래} 로 읽을 수 있는 파일이 아닙니다. 이 파일로는 더 해 볼 것이 없으니 사용자에게 알리세요.`;
128
+ }
129
+
59
130
  /** 구형 hwp 를 만났을 때 하는 말. 길을 같이 준다. */
60
131
  export function 옛hwp안내(보인이름) {
61
132
  return `구형 hwp 형식이라 읽지 못합니다: ${보인이름}\n`
@@ -176,7 +247,7 @@ export function readDoc(경로또는버퍼) {
176
247
  }
177
248
  if (!갈래) return { ok: false, error: '어떤 문서인지 모르는 경로입니다' };
178
249
  if (!looksZip(buf)) {
179
- return { ok: false, error: `${갈래} 모양이 아닙니다 겉은 ${갈래} 인데 속이 zip 꾸러미가 아닙니다. 깨졌거나 다른 형식입니다.` };
250
+ return { ok: false, error: 겉속다름말(갈래, 속내용(buf)), 끝났다: true };
180
251
  }
181
252
 
182
253
  let 꾸러미;
@@ -24,6 +24,7 @@
24
24
  // 결과가 없는 것이 아니라 우리가 못 물어본 것이다. 그때 '일치 없음' 을
25
25
  // 돌려주면 사람은 없는 줄 안다. 그래서 실패는 실패로 두고 JS 로 다시 찾는다.
26
26
  import { spawnSync } from 'node:child_process';
27
+ import { 돌려보기 } from './spawn.js';
27
28
  import { join } from 'node:path';
28
29
 
29
30
  /*
@@ -99,7 +100,7 @@ export function 저장소인가(폴더) {
99
100
  *
100
101
  * @returns {{ok:true, 줄들:string[], 잘림:boolean} | {ok:false, 왜:string}}
101
102
  */
102
- export function rg로찾기({ 무늬, 자리, glob = null, 대소문자무시 = false, 무시파일 = null, 최대 = 5000, timeout = 20000 }) {
103
+ export async function rg로찾기({ 무늬, 자리, glob = null, 대소문자무시 = false, 무시파일 = null, 최대 = 5000, timeout = 20000, signal = null }) {
103
104
  const 인자 = [
104
105
  '--line-number',
105
106
  '--no-heading',
@@ -132,9 +133,7 @@ export function rg로찾기({ 무늬, 자리, glob = null, 대소문자무시 =
132
133
  // `--` 뒤로 넘겨서 무늬가 옵션으로 안 읽히게 한다. `-foo` 같은 무늬가 실제로 있다.
133
134
  인자.push('--regexp', 무늬, '--', 자리);
134
135
 
135
- const r = spawnSync('rg', 인자, {
136
- encoding: 'utf8', timeout, windowsHide: true, maxBuffer: 32 * 1024 * 1024,
137
- });
136
+ const r = await 돌려보기('rg', 인자, { timeout, signal });
138
137
  // rg 는 못 찾으면 1, 진짜 탈이 나면 2 를 준다. 1 은 성공(빈 결과)이다.
139
138
  if (r.error) return { ok: false, 왜: r.error.message };
140
139
  if (r.status === 2) return { ok: false, 왜: (r.stderr ?? '').split('\n')[0] || 'rg 가 무늬를 못 읽었습니다' };
@@ -143,14 +142,12 @@ export function rg로찾기({ 무늬, 자리, glob = null, 대소문자무시 =
143
142
  }
144
143
 
145
144
  /** `git grep` 으로 찾는다. rg 가 없고 여기가 저장소일 때. */
146
- export function git로찾기({ 무늬, 자리, 대소문자무시 = false, 최대 = 5000, timeout = 20000 }) {
145
+ export async function git로찾기({ 무늬, 자리, 대소문자무시 = false, 최대 = 5000, timeout = 20000, signal = null }) {
147
146
  const 인자 = ['-C', 자리, '--no-pager', 'grep', '--line-number', '--no-color', '-I', '-E'];
148
147
  if (대소문자무시) 인자.push('-i');
149
148
  // 여기도 같은 목록으로 뺀다. git 은 경로무늬 앞머리에 마법을 붙여 빼낸다.
150
149
  인자.push('-e', 무늬, '--', '.', ...안볼확장자.map((x) => `:(exclude,icase)*.${x}`));
151
- const r = spawnSync('git', 인자, {
152
- encoding: 'utf8', timeout, windowsHide: true, maxBuffer: 32 * 1024 * 1024,
153
- });
150
+ const r = await 돌려보기('git', 인자, { timeout, signal });
154
151
  if (r.error) return { ok: false, 왜: r.error.message };
155
152
  if (r.status !== 0 && r.status !== 1) {
156
153
  return { ok: false, 왜: (r.stderr ?? '').split('\n')[0] || 'git grep 이 무늬를 못 읽었습니다' };
@@ -191,10 +188,10 @@ export function 줄가르기(줄) {
191
188
  *
192
189
  * @returns {{엔진:string, 줄들:Array<{파일,줄,내용}>, 잘림:boolean} | null}
193
190
  */
194
- export function 빠르게찾기({ 무늬, 자리, glob = null, 대소문자무시 = false, 무시파일 = null, 최대 = 5000 }) {
191
+ export async function 빠르게찾기({ 무늬, 자리, glob = null, 대소문자무시 = false, 무시파일 = null, 최대 = 5000, signal = null }) {
195
192
  const 것 = 엔진찾기();
196
193
  if (것.rg) {
197
- const r = rg로찾기({ 무늬, 자리, glob, 대소문자무시, 무시파일, 최대 });
194
+ const r = await rg로찾기({ 무늬, 자리, glob, 대소문자무시, 무시파일, 최대, signal });
198
195
  if (r.ok) return { 엔진: 'rg', 줄들: r.줄들.map(줄가르기).filter(Boolean), 잘림: r.잘림 };
199
196
  // rg 가 무늬를 못 읽은 것일 수 있다(Rust regex 에는 되돌아보기가 없다).
200
197
  // 그건 '없다' 가 아니라 '못 물어봤다' 이므로 예전 길로 내려간다.
@@ -203,7 +200,7 @@ export function 빠르게찾기({ 무늬, 자리, glob = null, 대소문자무
203
200
  // git grep 은 .deelignore 를 시킬 방법이 없다. 그 파일이 있으면 예전 길로 간다 —
204
201
  // 사람이 "deel 은 여기 보지 마라" 고 적어 둔 것을 못 지키면 빠른 것이 뜻이 없다.
205
202
  if (것.gitgrep && !무시파일 && 저장소인가(자리)) {
206
- const r = git로찾기({ 무늬, 자리, 대소문자무시, 최대 });
203
+ const r = await git로찾기({ 무늬, 자리, 대소문자무시, 최대, signal });
207
204
  if (r.ok) return { 엔진: 'git grep', 줄들: r.줄들.map(줄가르기).filter(Boolean), 잘림: r.잘림 };
208
205
  return null;
209
206
  }
@@ -175,11 +175,22 @@ export function 훑기상한(env = process.env) {
175
175
  return Math.floor(n);
176
176
  }
177
177
 
178
- export function walk(root, { limit = 훑기상한(), skipDirs = SKIP_DIRS, ignore = true } = {}) {
178
+ /**
179
+ * 폴더를 훑는다.
180
+ *
181
+ * @param {AbortSignal|null} signal 멈추라면 훑다 말고 나온다.
182
+ * 사내망 드라이브에서는 파일 20,000개 훑기가 몇 초다. 그 사이에
183
+ * ESC 를 눌렀는데 끝까지 다 훑고 나서 멈추면, 사람 눈에는 멈추지
184
+ * 않는 것으로 보인다. 나온 것에 `끊김` 을 달아 부르는 쪽이 알게 한다 —
185
+ * 조용히 적게 돌려주면 「그런 파일이 없다」가 되어 버린다.
186
+ */
187
+ export function walk(root, { limit = 훑기상한(), skipDirs = SKIP_DIRS, ignore = true, signal = null } = {}) {
179
188
  const out = [];
180
189
  const 건너뜀 = { 폴더: 0, 파일: 0 };
190
+ let 끊김 = false;
181
191
  const stack = [{ dir: root, rel: '', 규칙: ignore ? 뿌리규칙읽기(root) : [] }];
182
192
  while (stack.length && out.length < limit) {
193
+ if (signal?.aborted) { 끊김 = true; break; }
183
194
  const { dir, rel, 규칙 } = stack.pop();
184
195
  let entries;
185
196
  try { entries = readdirSync(dir, { withFileTypes: true }); } catch { continue; }
@@ -211,6 +222,7 @@ export function walk(root, { limit = 훑기상한(), skipDirs = SKIP_DIRS, ignor
211
222
  // 사람을 한 번 더 보게 만들 뿐이고, 다 봤다고 말하는 것은 못 보게 만든다.
212
223
  Object.defineProperty(out, '잘림', { value: out.length >= limit, enumerable: false });
213
224
  Object.defineProperty(out, '상한', { value: limit, enumerable: false });
225
+ Object.defineProperty(out, '끊김', { value: 끊김, enumerable: false });
214
226
  return out;
215
227
  }
216
228