deel-local-cli 1.5.8 → 1.6.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (59) hide show
  1. package/README.en.md +30 -14
  2. package/README.md +28 -12
  3. package/bin/deel.js +10 -0
  4. package/package.json +2 -2
  5. package/src/acp/map.js +143 -0
  6. package/src/acp/serve.js +166 -13
  7. package/src/agent/commit.js +511 -0
  8. package/src/agent/compact.js +269 -226
  9. package/src/agent/loop.js +196 -12
  10. package/src/agent/mention.js +56 -10
  11. package/src/agent/review.js +192 -0
  12. package/src/agent/session.js +22 -2
  13. package/src/agent/threads.js +1 -1
  14. package/src/backend/adapter.js +342 -280
  15. package/src/backend/azure.js +151 -0
  16. package/src/backend/ctxsize.js +19 -0
  17. package/src/backend/detect.js +106 -0
  18. package/src/backend/http.js +350 -30
  19. package/src/backend/probe.js +48 -1
  20. package/src/backend/proxy.js +151 -0
  21. package/src/backend/quota.js +133 -0
  22. package/src/backend/retry.js +132 -0
  23. package/src/backend/vision.js +185 -0
  24. package/src/commands.js +341 -8
  25. package/src/completion.js +264 -0
  26. package/src/config.js +117 -1
  27. package/src/i18n/en.js +13 -1
  28. package/src/i18n/index.js +22 -3
  29. package/src/i18n/ja.js +266 -0
  30. package/src/i18n/ko.js +12 -0
  31. package/src/i18n/zh.js +266 -0
  32. package/src/oneshot.js +28 -3
  33. package/src/pack/sbom.js +25 -0
  34. package/src/pack/selfpack.js +9 -3
  35. package/src/plugins/manage.js +34 -7
  36. package/src/repl.js +92 -6
  37. package/src/report.js +26 -2
  38. package/src/safety/guard.js +74 -2
  39. package/src/safety/keystore.js +237 -0
  40. package/src/safety/network.js +76 -14
  41. package/src/safety/policy.js +209 -0
  42. package/src/safety/secrets.js +32 -0
  43. package/src/setup.js +40 -7
  44. package/src/tools/clipboard.js +178 -0
  45. package/src/tools/convert.js +287 -0
  46. package/src/tools/docs.js +72 -1
  47. package/src/tools/fastgrep.js +217 -0
  48. package/src/tools/fsutil.js +55 -12
  49. package/src/tools/ignore.js +192 -0
  50. package/src/tools/index.js +391 -41
  51. package/src/tools/jobs.js +4 -12
  52. package/src/tools/outline.js +3 -1
  53. package/src/tools/pdf.js +1368 -0
  54. package/src/tools/shell.js +123 -0
  55. package/src/tools/verify.js +20 -4
  56. package/src/tools/webfetch.js +18 -5
  57. package/src/ui/inputbox.js +2 -2
  58. package/src/ui/screen.js +4 -1
  59. package/src/ui/status.js +22 -0
package/src/setup.js CHANGED
@@ -1,11 +1,14 @@
1
1
  // 첫 실행 마법사 + 진단 실행.
2
2
  import { c, say, rule, mark } from './ui/ansi.js';
3
+ import { 주소가리기 } from './safety/secrets.js';
3
4
  import { ask, pick } from './ui/prompt.js';
4
5
  import { spin } from './ui/spinner.js';
5
6
  import { detect } from './backend/detect.js';
6
7
  import { probe } from './backend/probe.js';
7
8
  import { renderHeader, renderLine, verdict, renderVerdict, plainReport } from './report.js';
8
9
  import { load, save, upsert, slug, resolveKey, activeProfile, configPath } from './config.js';
10
+ import { 보관방식 } from './safety/keystore.js';
11
+ import { 애저풀기, 애저base } from './backend/azure.js';
9
12
  import { allowEndpoint } from './safety/network.js';
10
13
  import { writeFileSync } from 'node:fs';
11
14
 
@@ -18,14 +21,22 @@ export function banner() {
18
21
  // 주소와 키를 받아 연결을 찾아낸다. 실패하면 null.
19
22
  async function connect(url, key) {
20
23
  const s = spin(`${url} 확인 중...`);
21
- // 사용자가 방금 적어 넣은 주소다. 확인하는 동안만 문을 연다.
22
- allowEndpoint(/^https?:\/\//i.test(url) ? url : 'http://' + url);
24
+ /*
25
+ * 사용자가 방금 적어 넣은 주소다. 확인하는 동안만 문을 연다.
26
+ *
27
+ * 스킴을 안 적었으면 **두 가지 다 열어 둔다.** 자리마다 기본으로 붙이는
28
+ * 스킴이 다르기 때문이다 — 로컬 서버를 찾는 쪽은 `http://` 를, Azure 쪽은
29
+ * `https://` 를 붙인다. 한쪽만 열어 두면 우리가 만든 주소를 우리 자물쇠가
30
+ * 막고, 화면에는 "허용되지 않은 주소" 만 뜬다. 여는 것은 사람이 적어 넣은
31
+ * 그 호스트 하나뿐이라 넓어지는 것이 아니다.
32
+ */
33
+ allowEndpoint(/^https?:\/\//i.test(url) ? url : [`http://${url}`, `https://${url}`]);
23
34
  const found = await detect(url, key);
24
35
  if (!found.kind) {
25
36
  s.stop(` ${mark.no} ${c.red('연결 실패')}`);
26
37
  say('');
27
38
  say(` 시도한 주소:`);
28
- for (const t of found.tried) say(` ${c.gray(t + '/models')}`);
39
+ for (const t of found.tried) say(` ${c.gray(주소가리기(t.includes('?') ? t : `${t}/models`))}`);
29
40
  say('');
30
41
  say(` ${c.gray('확인할 것 — 주소·포트가 맞는지, 프록시가 필요한지,')}`);
31
42
  say(` ${c.gray('사내 인증서라면 NODE_EXTRA_CA_CERTS 환경변수가 필요합니다.')}`);
@@ -34,7 +45,8 @@ async function connect(url, key) {
34
45
  }
35
46
  const kindName = found.kind === 'ollama' ? `Ollama ${found.version ?? ''}`.trim() : 'OpenAI 호환';
36
47
  s.stop(` ${mark.ok} ${c.green('연결됨')} ${c.gray(`${kindName} · ${found.ms}ms`)}`);
37
- say(` ${c.gray('주소')} ${found.base}`);
48
+ // 물음표 뒤에 열쇠를 싣는 앞단이 있다 (safety/secrets.js 의 주소가리기).
49
+ say(` ${c.gray('주소')} ${주소가리기(found.base)}`);
38
50
  say(` ${c.gray('인증')} ${found.auth === 'none' ? '없음' : found.auth}`);
39
51
  say(` ${c.gray('모델')} ${found.models.length}개 발견`);
40
52
  if (found.warn) say(` ${mark.warn} ${c.yellow(found.warn)}`);
@@ -70,6 +82,10 @@ export async function runProbe(conn, { out = null } = {}) {
70
82
 
71
83
  export async function runSetup() {
72
84
  banner();
85
+ // 설정에 적어 둔 api-version 을 **붙기 전에** 읽는다. load() 가 애저정하기()를
86
+ // 부른다. 이걸 뒤에서 하면 사내에서 판을 고정해 둔 곳이 확인만 GA판으로 하고,
87
+ // 문서에 적어 둔 대로 안 도는 셈이 된다.
88
+ load();
73
89
  say(` ${c.gray('모델 연결을 설정합니다. 주소와 키만 있으면 됩니다.')}`);
74
90
  say('');
75
91
 
@@ -85,6 +101,18 @@ export async function runSetup() {
85
101
  const model = await chooseModel(found);
86
102
  if (!model) { say(` ${mark.no} 모델이 비었습니다.`); return 1; }
87
103
 
104
+ /*
105
+ * Azure 는 모델 이름이 **주소 안에** 있다.
106
+ *
107
+ * 목록에서 다른 배포를 골랐으면 주소도 그 배포로 바꿔야 한다. 안 바꾸면
108
+ * 화면에는 고른 이름이 보이는데 요청은 처음 주소의 배포로 나간다 —
109
+ * 사람이 알아챌 방법이 없는 어긋남이다.
110
+ */
111
+ if (found.azure) {
112
+ const 푼것 = 애저풀기(found.base);
113
+ if (푼것) found.base = 애저base(푼것.origin, model, 푼것.판);
114
+ }
115
+
88
116
  const conn = { kind: found.kind, base: found.base, auth: found.auth, key, model };
89
117
  const { facts } = await runProbe(conn);
90
118
 
@@ -102,14 +130,18 @@ export async function runSetup() {
102
130
  tools: facts.tools ?? false,
103
131
  json: facts.json ?? false,
104
132
  think: facts.think ?? false,
133
+ vision: facts.vision ?? false,
105
134
  });
106
135
  cfg.active = id;
107
136
  const p = save(cfg);
108
137
 
109
138
  say(` ${mark.ok} 저장됨 ${c.cyan(p)}`);
110
139
  if (key) {
111
- say(` ${c.gray('키가 파일에 들어 있습니다. 남기고 싶지 않으면 파일에서 지우고')}`);
112
- say(` ${c.gray('환경변수 DEEL_API_KEY 넣으세요 그쪽이 우선합니다.')}`);
140
+ // 열쇠가 파일에 '어떤 꼴로' 들어갔는지를 그대로 적는다. 잠겼는지 아닌지를
141
+ // 사람이 짐작하게 두면, 잠긴 파일을 잠긴 줄 알고 아무 데나 둔다.
142
+ const 저장한것 = activeProfile(load())?.apiKey ?? null;
143
+ say(` ${c.gray(`열쇠 보관 — ${보관방식(저장한것)}`)}`);
144
+ say(` ${c.gray('파일에 아예 안 남기려면 환경변수 DEEL_API_KEY 로 넣으세요 — 그쪽이 우선합니다.')}`);
113
145
  }
114
146
  say('');
115
147
  return 0;
@@ -121,6 +153,7 @@ export async function runDiagnose(flags) {
121
153
  let conn;
122
154
 
123
155
  if (flags.url) {
156
+ load(); // 설정의 api-version 을 먼저 읽는다 (runSetup 과 같은 이유)
124
157
  const key = flags.key ?? process.env.DEEL_API_KEY ?? '';
125
158
  const found = await connect(flags.url, key);
126
159
  if (!found) return 1;
@@ -167,7 +200,7 @@ export async function showStatus() {
167
200
  p.think ? c.green('추론') : c.gray('추론'),
168
201
  ].join(c.gray('·'));
169
202
  say(` ${c.bold(p.name)} ${c.gray(p.model)} ${caps}${here}`);
170
- say(` ${c.gray(p.baseUrl)}`);
203
+ say(` ${c.gray(주소가리기(p.baseUrl))}`);
171
204
  }
172
205
  say('');
173
206
  say(` ${c.gray('설정 파일')} ${configPath()}`);
@@ -0,0 +1,178 @@
1
+ /**
2
+ * 클립보드에 든 그림 꺼내기 — 화면 캡처 → 바로 붙이기.
3
+ *
4
+ * ── 왜 필요한가 ─────────────────────────────────────────────────────────
5
+ *
6
+ * 눈이 달린 모델에게 화면을 보여 줄 수 있게 됐는데(backend/vision.js),
7
+ * 보여 주려면 먼저 **파일로 저장해야** 했다. 오류 화면 하나를 물어보려고
8
+ * 캡처 → 그림판 → 저장 → 경로 찾기를 거친다. 그 네 걸음이 「그냥 말로
9
+ * 설명하자」로 사람을 되돌린다.
10
+ *
11
+ * 캡처는 이미 클립보드에 있다. 꺼내 오기만 하면 된다.
12
+ *
13
+ * ── 왜 의존성 없이 되나 ─────────────────────────────────────────────────
14
+ *
15
+ * 세 운영체제 모두 **이미 깔려 있는 것**으로 꺼낸다. 새로 깔라고 하지 않는다.
16
+ *
17
+ * 윈도우 파워셸의 .NET Clipboard (윈도우에 늘 있다)
18
+ * 맥 osascript (맥에 늘 있다)
19
+ * 리눅스 wl-paste 나 xclip — 배포판에 따라 없을 수 있다
20
+ *
21
+ * ── 없으면 없다고 한다 ──────────────────────────────────────────────────
22
+ *
23
+ * 리눅스에서 둘 다 없으면 **어떻게 하면 되는지**를 같이 준다. 「안 됩니다」
24
+ * 로 끝내면 사람은 이 기능이 고장 난 줄 알지, 도구 하나 깔면 되는 줄 모른다.
25
+ * 클립보드에 글만 있을 때도 「그림이 없다」와 「못 꺼냈다」를 갈라 말한다 —
26
+ * 앞의 것은 사람이 캡처를 다시 하면 되고, 뒤의 것은 우리 탓이다.
27
+ */
28
+ import { spawnSync } from 'node:child_process';
29
+ import { mkdtempSync, mkdirSync, readFileSync, writeFileSync, existsSync, rmSync, statSync } from 'node:fs';
30
+ import { tmpdir } from 'node:os';
31
+ import { join } from 'node:path';
32
+
33
+ /** 이보다 큰 그림은 안 받는다. 화면 캡처 한 장은 보통 1MB 안쪽이다. */
34
+ export const 그림한도 = 12 * 1024 * 1024;
35
+
36
+ const 있나 = (이름, 인자) => {
37
+ const r = spawnSync(이름, 인자, { encoding: 'utf8', timeout: 5000, windowsHide: true });
38
+ return !r.error && r.status === 0;
39
+ };
40
+
41
+ /*
42
+ * 윈도우.
43
+ *
44
+ * 파워셸을 **파일로 적어 두고** 부른다. `-Command` 뒤에 길게 붙이면 따옴표가
45
+ * 셸을 두 번 거치면서 깨지고, 그 깨짐이 PC 마다 다르게 나타난다. 스크립트는
46
+ * ASCII 로만 쓴다 — Windows PowerShell 5.1 은 BOM 없는 .ps1 을 그 PC 의 옛
47
+ * 코드페이지로 읽어서, 한글이 섞이면 문법이 무너진다(src/completion.js 참고).
48
+ */
49
+ function 윈도우에서(방) {
50
+ const 낼곳 = join(방, 'clip.png');
51
+ const 스크립트 = join(방, 'grab.ps1');
52
+ const 글 = [
53
+ '$ErrorActionPreference = "Stop"',
54
+ 'Add-Type -AssemblyName System.Windows.Forms',
55
+ 'Add-Type -AssemblyName System.Drawing',
56
+ '$img = [System.Windows.Forms.Clipboard]::GetImage()',
57
+ 'if ($img -eq $null) { Write-Output "NOIMAGE"; exit 0 }',
58
+ `$img.Save(${JSON.stringify(낼곳)}, [System.Drawing.Imaging.ImageFormat]::Png)`,
59
+ 'Write-Output "OK"',
60
+ ].join('\n');
61
+ // Buffer 로 적어서 인코딩이 끼어들 자리를 없앤다.
62
+ writeFileSync(스크립트, Buffer.from(글, 'latin1'));
63
+
64
+ const r = spawnSync('powershell', [
65
+ '-NoProfile', '-NonInteractive', '-STA', '-ExecutionPolicy', 'Bypass', '-File', 스크립트,
66
+ ], { encoding: 'utf8', timeout: 20000, windowsHide: true });
67
+
68
+ if (r.error) return { ok: false, 왜: `파워셸을 못 불렀습니다: ${r.error.message}` };
69
+ const 낸말 = String(r.stdout ?? '').trim();
70
+ if (/NOIMAGE/.test(낸말)) return { ok: false, 없음: true };
71
+ if (!existsSync(낼곳)) {
72
+ const 탈 = String(r.stderr ?? '').split('\n').find((l) => l.trim()) ?? '';
73
+ return { ok: false, 왜: 탈 ? `클립보드를 못 읽었습니다: ${탈.trim()}` : '클립보드를 못 읽었습니다' };
74
+ }
75
+ return { ok: true, buf: readFileSync(낼곳), mime: 'image/png' };
76
+ }
77
+
78
+ /*
79
+ * 맥.
80
+ *
81
+ * osascript 로 PNG 를 꺼내면 «data PNGf89504e47…» 꼴의 16진수 글이 나온다.
82
+ * 그림이 없으면 오류를 내므로, 그것으로 「없음」을 가른다.
83
+ */
84
+ function 맥에서() {
85
+ const r = spawnSync('osascript', ['-e', 'the clipboard as «class PNGf»'], {
86
+ encoding: 'utf8', timeout: 20000,
87
+ });
88
+ if (r.error) return { ok: false, 왜: `osascript 를 못 불렀습니다: ${r.error.message}` };
89
+ const 낸말 = String(r.stdout ?? '');
90
+ const m = /«data PNGf([0-9A-Fa-f]+)»/.exec(낸말);
91
+ if (!m) {
92
+ // 그림이 아니면 osascript 가 오류를 낸다. 그건 「없음」이지 고장이 아니다.
93
+ if (r.status !== 0) return { ok: false, 없음: true };
94
+ return { ok: false, 없음: true };
95
+ }
96
+ return { ok: true, buf: Buffer.from(m[1], 'hex'), mime: 'image/png' };
97
+ }
98
+
99
+ /*
100
+ * 리눅스.
101
+ *
102
+ * 웨이랜드면 wl-paste, X11 이면 xclip. 배포판이 기본으로 안 깔아 주는 것이라
103
+ * 없을 수 있고, 그때는 **무엇을 깔면 되는지** 알려 준다.
104
+ */
105
+ function 리눅스에서() {
106
+ const 후보 = [
107
+ { 이름: 'wl-paste', 볼것: ['--list-types'], 꺼내기: ['--type', 'image/png'] },
108
+ { 이름: 'xclip', 볼것: ['-selection', 'clipboard', '-t', 'TARGETS', '-o'], 꺼내기: ['-selection', 'clipboard', '-t', 'image/png', '-o'] },
109
+ ];
110
+ const 있는것 = 후보.filter((x) => 있나(x.이름, ['--version']) || 있나(x.이름, ['-version']));
111
+ if (!있는것.length) {
112
+ return {
113
+ ok: false,
114
+ 왜: '클립보드에서 그림을 꺼낼 도구가 없습니다.\n'
115
+ + ' 웨이랜드면 sudo apt install wl-clipboard\n'
116
+ + ' X11 이면 sudo apt install xclip\n'
117
+ + ' 둘 다 안 되면 캡처를 파일로 저장한 뒤 @경로 로 붙이세요.',
118
+ };
119
+ }
120
+ for (const x of 있는것) {
121
+ const 종류 = spawnSync(x.이름, x.볼것, { encoding: 'utf8', timeout: 10000 });
122
+ if (!/image\/png/i.test(String(종류.stdout ?? ''))) continue;
123
+ const r = spawnSync(x.이름, x.꺼내기, { timeout: 20000, maxBuffer: 64 * 1024 * 1024 });
124
+ if (r.error || !r.stdout?.length) continue;
125
+ return { ok: true, buf: Buffer.from(r.stdout), mime: 'image/png' };
126
+ }
127
+ return { ok: false, 없음: true };
128
+ }
129
+
130
+ /**
131
+ * 클립보드에 그림이 있으면 꺼낸다.
132
+ *
133
+ * @returns {{ok:true, buf:Buffer, mime:string}
134
+ * | {ok:false, 없음:true} 그림이 없다 (사람이 다시 캡처하면 된다)
135
+ * | {ok:false, 왜:string}} 못 꺼냈다 (까닭과 길을 같이 준다)
136
+ */
137
+ export function 클립보드그림({ platform = process.platform } = {}) {
138
+ let 방 = null;
139
+ try {
140
+ if (platform === 'win32') {
141
+ 방 = mkdtempSync(join(tmpdir(), 'deel-clip-'));
142
+ const r = 윈도우에서(방);
143
+ return 잰다(r);
144
+ }
145
+ if (platform === 'darwin') return 잰다(맥에서());
146
+ if (platform === 'linux') return 잰다(리눅스에서());
147
+ return { ok: false, 왜: `${platform} 에서는 아직 클립보드 그림을 못 꺼냅니다 — 파일로 저장한 뒤 @경로 로 붙이세요.` };
148
+ } catch (err) {
149
+ return { ok: false, 왜: `클립보드를 읽다 막혔습니다: ${err.message}` };
150
+ } finally {
151
+ if (방) { try { rmSync(방, { recursive: true, force: true }); } catch { /* 치우다 실패는 넘어간다 */ } }
152
+ }
153
+ }
154
+
155
+ /** 크기를 여기서 한 번만 잰다 — 운영체제마다 따로 재면 한 군데를 빠뜨린다. */
156
+ function 잰다(r) {
157
+ if (!r?.ok) return r;
158
+ if (!r.buf?.length) return { ok: false, 없음: true };
159
+ if (r.buf.length > 그림한도) {
160
+ return { ok: false, 왜: `그림이 너무 큽니다 (${(r.buf.length / 1048576).toFixed(1)}MB · 한도 ${그림한도 / 1048576}MB)` };
161
+ }
162
+ return r;
163
+ }
164
+
165
+ /**
166
+ * 꺼낸 그림을 파일로 앉힌다.
167
+ *
168
+ * 살림 폴더(.deel/붙인그림/) 안에 둔다. 작업 폴더에 흩뿌리면 사람이 지우기
169
+ * 전에 커밋에 딸려 들어가고, 임시 폴더에 두면 대화를 다시 열었을 때 사라진다.
170
+ */
171
+ export function 그림앉히기(buf, 살림, { 이제 = new Date() } = {}) {
172
+ const 방 = join(살림, '붙인그림');
173
+ mkdirSync(방, { recursive: true });
174
+ const 이름 = `${이제.toISOString().replace(/[:.]/g, '-').replace('T', '_').slice(0, 19)}.png`;
175
+ const 자리 = join(방, 이름);
176
+ writeFileSync(자리, buf);
177
+ return { 자리, 이름, 바이트: statSync(자리).size };
178
+ }
@@ -0,0 +1,287 @@
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 {
29
+ existsSync, mkdirSync, readdirSync, readFileSync, rmSync, statSync,
30
+ } from 'node:fs';
31
+ import { basename, extname, join } from 'node:path';
32
+
33
+ /** 변환해서 읽어 볼 만한 확장자. 여기 없는 것은 손대지 않는다. */
34
+ const 바꿔볼확장자 = new Set([
35
+ '.ppt', '.doc', '.xls', // 옛 Office (OLE 복합문서)
36
+ '.rtf', '.odt', '.odp', '.ods', '.wpd',
37
+ '.pptx', '.docx', '.xlsx', // 겉만 그 이름이고 속이 다른 것
38
+ '.hwp', // 구형 한글 — soffice 가 읽는 판이 있다
39
+ ]);
40
+
41
+ /**
42
+ * 우리가 **영영 직접은 못 읽는** 갈래. 바꿔볼확장자 의 부분집합이다.
43
+ *
44
+ * 둘을 가르는 이유가 있다. `.pptx` 가 안 읽히는 것은 「깨졌거나 속이 다르다」는
45
+ * 뜻이라 docs.js 가 이미 정체를 짚어 준다 — 그 말이 우리 말보다 낫다. 반면
46
+ * `.ppt` 는 정체가 분명하고 우리에게 길이 없다. 그때만 「없다」고 못 박는다.
47
+ */
48
+ const 직접못읽는확장자 = new Set([
49
+ '.ppt', '.doc', '.xls', '.xlt', '.rtf', '.odt', '.odp', '.ods', '.wpd', '.hwp',
50
+ ]);
51
+
52
+ /** 이 파일을 변환해서 읽어 볼까. */
53
+ export function 바꿔볼까(경로) {
54
+ return 바꿔볼확장자.has(extname(String(경로 ?? '')).toLowerCase());
55
+ }
56
+
57
+ /** 바꾸는 것 말고는 길이 없는 갈래인가. */
58
+ export function 직접못읽나(경로) {
59
+ return 직접못읽는확장자.has(extname(String(경로 ?? '')).toLowerCase());
60
+ }
61
+
62
+ /*
63
+ * 이 PC 에 깔린 soffice 를 찾는다.
64
+ *
65
+ * PATH 에 없는 자리가 흔하다 — 맥은 앱 꾸러미 안에, 윈도우는 Program Files
66
+ * 밑에 있고 둘 다 PATH 에 안 걸린다. 사람이 쓰는 그 프로그램이 눈앞에 있는데
67
+ * "없습니다" 라고 말하는 것이 제일 아깝다.
68
+ */
69
+ const soffice자리 = [
70
+ '/Applications/LibreOffice.app/Contents/MacOS/soffice',
71
+ 'C:\\Program Files\\LibreOffice\\program\\soffice.exe',
72
+ 'C:\\Program Files (x86)\\LibreOffice\\program\\soffice.exe',
73
+ '/usr/bin/soffice', '/usr/local/bin/soffice', '/opt/homebrew/bin/soffice',
74
+ '/snap/bin/libreoffice',
75
+ ];
76
+
77
+ /*
78
+ * ── 윈도우에서 `.cmd` 는 그냥 못 띄운다 ─────────────────────────────────
79
+ *
80
+ * scoop·choco 로 깐 것은 `soffice.cmd` 같은 껍데기다. 그걸 spawnSync 로 그냥
81
+ * 부르면 **EINVAL** 이 난다(Node 20 부터 막혔다). 그러면 우리는 "이 PC 에
82
+ * 변환기가 없습니다" 라고 말하게 되는데 — 사람은 눈앞에서 그 프로그램을 쓰고
83
+ * 있다. 있다고 해 놓고 안 되는 것 다음으로 나쁜 것이 이것이다.
84
+ *
85
+ * `shell: true` 로 넘기면 되긴 하지만 node 가 DEP0190 경고를 내고, 그 지적이
86
+ * 맞다 — 경로에 빈칸이 있으면 그대로 깨진다. 사내 PC 의 `C:\Program Files\…`
87
+ * 가 정확히 그 꼴이다.
88
+ *
89
+ * 그래서 언어 서버 쪽에서 쓰는 것과 **같은 방법**을 쓴다 (lsp/client.js).
90
+ * `cmd /s /c` 는 뒤엣것이 따옴표로 시작해 따옴표로 끝나면 바깥 한 쌍을 떼어
91
+ * 내므로, 한 겹 더 둘러서 넘긴다.
92
+ */
93
+ function 부르기(cmd, 인자, opts = {}) {
94
+ const 셸필요 = process.platform === 'win32' && /\.(cmd|bat)$/i.test(String(cmd));
95
+ if (!셸필요) return spawnSync(cmd, 인자, { encoding: 'utf8', windowsHide: true, ...opts });
96
+ const 몰아쓰기 = `""${cmd}" ${인자.map((a) => `"${a}"`).join(' ')}"`;
97
+ return spawnSync(process.env.ComSpec || 'cmd.exe', ['/d', '/s', '/c', 몰아쓰기], {
98
+ encoding: 'utf8', windowsHide: true, windowsVerbatimArguments: true, ...opts,
99
+ });
100
+ }
101
+
102
+ function 돌아가나(cmd, 인자) {
103
+ const r = 부르기(cmd, 인자, { timeout: 15000 });
104
+ return !r.error && r.status === 0;
105
+ }
106
+
107
+ let 본것 = null;
108
+
109
+ /**
110
+ * 쓸 수 있는 변환기. 한 번만 찾는다.
111
+ *
112
+ * @returns {{soffice: string|null, textutil: boolean, 왜: string|null}}
113
+ */
114
+ export function 변환기찾기({ 다시 = false, env = process.env, platform = process.platform } = {}) {
115
+ if (본것 && !다시) return 본것;
116
+ if (env.DEEL_CONVERT === 'off') {
117
+ 본것 = { soffice: null, textutil: false, 왜: 'DEEL_CONVERT=off 로 꺼 두었습니다' };
118
+ return 본것;
119
+ }
120
+ let soffice = null;
121
+ if (돌아가나('soffice', ['--version'])) soffice = 'soffice';
122
+ else {
123
+ for (const p of soffice자리) {
124
+ if (existsSync(p) && 돌아가나(p, ['--version'])) { soffice = p; break; }
125
+ }
126
+ }
127
+ // textutil 은 맥에만 있고 .doc·.rtf 를 아주 빨리 바꾼다. soffice 보다 먼저 쓴다.
128
+ const textutil = platform === 'darwin' && 돌아가나('textutil', ['-info', '/dev/null']);
129
+ 본것 = { soffice, textutil, 왜: null };
130
+ return 본것;
131
+ }
132
+
133
+ /** 검사가 원래대로 돌려놓을 때. */
134
+ export function 변환기잊기() { 본것 = null; return null; }
135
+
136
+ /** 사람에게 보여줄 한 줄. */
137
+ export function 변환기말(찾은것 = 변환기찾기()) {
138
+ if (찾은것.왜) return 찾은것.왜;
139
+ const 것들 = [];
140
+ if (찾은것.soffice) 것들.push('soffice');
141
+ if (찾은것.textutil) 것들.push('textutil');
142
+ return 것들.length ? `${것들.join(' · ')} 를 빌려 씁니다` : '이 PC 에 쓸 수 있는 변환기가 없습니다';
143
+ }
144
+
145
+ /*
146
+ * 못 바꿨을 때 하는 말.
147
+ *
148
+ * 여기가 「안 된다고 확실히 말하는」 자리다. 여태 이 자리에서 나간 말은
149
+ * `바이너리 파일입니다 — 텍스트로 읽을 수 없습니다` 한 줄이었다. 그 말에는
150
+ * 까닭도 길도 없어서, 모델은 같은 파일을 Read 로 또 열거나 Bash 로 우회로를
151
+ * 찾다 울타리에 막히고, 그 왕복으로 컨텍스트만 탔다.
152
+ *
153
+ * 그래서 세 가지를 한꺼번에 말한다 — 무엇이라서 못 읽는지, 이 PC 에 무엇이
154
+ * 없어서 못 바꾸는지, 사람이 무엇을 하면 되는지. 그리고 **다시 열지 말라**고
155
+ * 못 박는다. 다시 열어도 결과가 같은 것은 우리가 아는 사실이다.
156
+ */
157
+ export function 못바꿈말(보인이름, 확장자, 찾은것 = 변환기찾기()) {
158
+ const 갈래 = String(확장자 ?? '').replace(/^\./, '').toLowerCase();
159
+ const 없다 = !찾은것.soffice && !찾은것.textutil;
160
+ const 길 = 없다
161
+ ? '이 PC 에 LibreOffice(soffice)가 없어서 바꿔 읽을 수도 없습니다.'
162
+ : '이 PC 의 변환기로 바꿔 봤지만 글이 안 나왔습니다.';
163
+ return `${보인이름} 은 deel 이 직접 못 읽는 형식입니다 (.${갈래}).\n`
164
+ + `${길}\n`
165
+ + `해결: 원래 프로그램에서 ${새이름(갈래)} 로 저장한 뒤 다시 주세요.`
166
+ + (없다 ? ' 또는 LibreOffice 를 설치하면 deel 이 빌려 씁니다.' : '')
167
+ + '\n**같은 파일을 다시 Read 하지 마세요. 결과는 같습니다.**';
168
+ }
169
+
170
+ /** 그 갈래를 무엇으로 저장하면 읽히는지. */
171
+ function 새이름(갈래) {
172
+ if (갈래 === 'ppt') return 'pptx';
173
+ if (갈래 === 'doc' || 갈래 === 'rtf' || 갈래 === 'odt' || 갈래 === 'wpd') return 'docx';
174
+ if (갈래 === 'xls' || 갈래 === 'ods') return 'xlsx';
175
+ if (갈래 === 'odp') return 'pptx';
176
+ if (갈래 === 'hwp') return 'hwpx';
177
+ return 'pdf 나 txt';
178
+ }
179
+
180
+ /** 떨굴 자리. 작업 폴더 안이라야 한다. */
181
+ export function 임시자리(root) {
182
+ return join(root, '.deel', 'tmp');
183
+ }
184
+
185
+ /**
186
+ * 문서를 글로 바꿔서 읽는다.
187
+ *
188
+ * @param {string} abs 읽을 파일 (절대경로)
189
+ * @param {string} root 작업 폴더 — 바꾼 것을 여기 안에 떨군다
190
+ * @returns {{ok:true, text:string, 쓴것:string} | {ok:false, 왜:string, 없음?:boolean}}
191
+ */
192
+ export function 글로바꾸기(abs, root, { timeout = 90000, 찾은것 = null } = {}) {
193
+ const 있는것 = 찾은것 ?? 변환기찾기();
194
+ if (!있는것.soffice && !있는것.textutil) {
195
+ return { ok: false, 없음: true, 왜: 있는것.왜 ?? '이 PC 에 변환기가 없습니다' };
196
+ }
197
+ if (!existsSync(abs)) return { ok: false, 왜: `없는 파일입니다: ${abs}` };
198
+
199
+ const 받을곳 = 임시자리(root);
200
+ try { mkdirSync(받을곳, { recursive: true }); } catch (err) {
201
+ return { ok: false, 왜: `바꿔 놓을 자리를 못 만들었습니다: ${err.message}` };
202
+ }
203
+
204
+ /*
205
+ * textutil 을 먼저 본다 (맥).
206
+ *
207
+ * soffice 는 처음 뜰 때 몇십 초가 걸린다 — 실제로 20초를 재 봤다. textutil 은
208
+ * OS 에 붙어 있는 것이라 곧바로 답한다. 둘 다 되는 자리면 빠른 쪽이 맞다.
209
+ */
210
+ const 확장자 = extname(abs).toLowerCase();
211
+ if (있는것.textutil && ['.doc', '.rtf', '.odt', '.docx'].includes(확장자)) {
212
+ const 나온것 = join(받을곳, `${basename(abs, 확장자)}.txt`);
213
+ const r = 부르기('textutil', ['-convert', 'txt', '-output', 나온것, abs], { timeout });
214
+ if (!r.error && r.status === 0 && existsSync(나온것)) {
215
+ const text = readFileSync(나온것, 'utf8');
216
+ // soffice 쪽과 같은 규칙 — 읽었으면 사본을 남기지 않는다.
217
+ try { rmSync(나온것, { force: true }); } catch { /* 임시치우기가 거둔다 */ }
218
+ return { ok: true, text, 쓴것: 'textutil', 파일: 나온것 };
219
+ }
220
+ }
221
+
222
+ if (!있는것.soffice) return { ok: false, 없음: true, 왜: '이 파일을 바꿀 변환기가 없습니다' };
223
+
224
+ /*
225
+ * soffice 는 결과 파일 이름을 제가 정한다.
226
+ *
227
+ * 그래서 넣기 전에 폴더에 무엇이 있었는지 적어 두고, 나온 뒤에 늘어난 것을
228
+ * 찾는다. 이름을 미리 짐작하면 확장자가 겹치거나 다른 판에서 어긋난다.
229
+ *
230
+ * -env:UserInstallation 을 따로 주는 까닭: soffice 는 이미 떠 있는 제 인스턴스가
231
+ * 있으면 새 부탁을 그쪽에 넘기고 **곧바로 끝나 버린다.** 사람이 LibreOffice 를
232
+ * 열어 둔 PC 에서 변환이 조용히 아무것도 안 하는 것이 그 모습이다. 우리 몫의
233
+ * 프로필을 따로 주면 그 일이 안 생긴다.
234
+ */
235
+ const 프로필 = join(받을곳, '.soffice-profile');
236
+ const 전 = new Set(existsSync(받을곳) ? readdirSync(받을곳) : []);
237
+ const r = 부르기(있는것.soffice, [
238
+ `-env:UserInstallation=file:///${프로필.replace(/\\/g, '/').replace(/^\/+/, '')}`,
239
+ '--headless', '--norestore',
240
+ '--convert-to', 'txt:Text',
241
+ '--outdir', 받을곳,
242
+ abs,
243
+ ], { timeout });
244
+
245
+ if (r.error) {
246
+ return { ok: false, 왜: `변환기를 못 돌렸습니다: ${r.error.message}` };
247
+ }
248
+ const 새로생긴것 = (existsSync(받을곳) ? readdirSync(받을곳) : [])
249
+ .filter((f) => !전.has(f) && f.toLowerCase().endsWith('.txt'));
250
+ if (!새로생긴것.length) {
251
+ const 끄트머리 = String(r.stderr || r.stdout || '').trim().split('\n').slice(-2).join(' ').slice(0, 200);
252
+ return { ok: false, 왜: `변환기가 글을 못 뽑았습니다${끄트머리 ? ` (${끄트머리})` : ''}` };
253
+ }
254
+ const 나온것 = join(받을곳, 새로생긴것[0]);
255
+ let text = '';
256
+ try { text = readFileSync(나온것, 'utf8'); } catch (err) {
257
+ return { ok: false, 왜: `바꾼 글을 못 읽었습니다: ${err.message}` };
258
+ } finally {
259
+ /*
260
+ * 읽었으면 곧바로 지운다.
261
+ *
262
+ * 글은 이미 손에 있다. 남겨 두면 **사람 문서의 알맹이가 사본으로 작업
263
+ * 폴더에 쌓인다** — 그대로 커밋되거나 압축되어 나갈 수 있는 자리다.
264
+ * 나중에 거두겠다는 약속은 세션이 죽으면 안 지켜진다. 지금 지운다.
265
+ */
266
+ try { rmSync(나온것, { force: true }); } catch { /* 못 지우면 임시치우기가 거둔다 */ }
267
+ }
268
+ return { ok: true, text, 쓴것: 'soffice', 파일: 나온것 };
269
+ }
270
+
271
+ /** 떨궈 둔 것을 거둔다. 검사와 세션 끝에서 부른다. */
272
+ export function 임시치우기(root) {
273
+ const 자리 = 임시자리(root);
274
+ if (!existsSync(자리)) return 0;
275
+ let 몇개 = 0;
276
+ for (const f of readdirSync(자리)) {
277
+ const p = join(자리, f);
278
+ try {
279
+ // 사람이 넣어 둔 것은 안 건드린다. 우리가 만든 것만 거둔다.
280
+ if (f === '.soffice-profile' || (statSync(p).isFile() && f.toLowerCase().endsWith('.txt'))) {
281
+ rmSync(p, { recursive: true, force: true });
282
+ 몇개++;
283
+ }
284
+ } catch { /* 못 지워도 다음에 지운다 */ }
285
+ }
286
+ return 몇개;
287
+ }
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 꾸러미;