deel-local-cli 0.5.0 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/repl.js CHANGED
@@ -1,7 +1,10 @@
1
1
  // 대화 화면. 루프가 보내는 이벤트를 Claude Code 풍으로 그린다.
2
- import { createInterface } from 'node:readline';
3
- import { c, say, mark, cursor, width } from './ui/ansi.js';
4
- import { handle, COMMANDS } from './commands.js';
2
+ import { createInterface, emitKeypressEvents } from 'node:readline';
3
+ import { c, say, mark, cursor, box, clip, cols } from './ui/ansi.js';
4
+ import { statusLine, headerLines, contextWarning } from './ui/status.js';
5
+ import { STAGES } from './agent/effort.js';
6
+ import { handle } from './commands.js';
7
+ import { next as nextWork, get as getWork, canWrite } from './agent/modes.js';
5
8
  import { run } from './agent/loop.js';
6
9
  import { Session } from './agent/session.js';
7
10
  import { makeScope } from './safety/guard.js';
@@ -9,21 +12,41 @@ import { History } from './safety/undo.js';
9
12
  import { Audit } from './safety/audit.js';
10
13
  import { activeProfile, load, resolveKey } from './config.js';
11
14
  import { discover } from './skills/discover.js';
15
+ import { allowEndpoint, setOffline, isOffline, isLocalHost } from './safety/network.js';
16
+ import { Store, latest, prune } from './agent/store.js';
17
+ import { askHidden } from './ui/prompt.js';
18
+ import { explain } from './ui/level.js';
19
+
20
+ // 도구마다 눈에 띄는 글자를 다르게 준다. 훑을 때 종류가 먼저 보인다.
21
+ const TOOL_GLYPH = {
22
+ Read: c.blue('◧'),
23
+ Write: c.green('◆'),
24
+ Edit: c.yellow('◈'),
25
+ Glob: c.magenta('❋'),
26
+ Grep: c.magenta('❊'),
27
+ Bash: c.hcyan('▶'),
28
+ Skill: c.hmagenta('✦'),
29
+ WebFetch: c.hblue('◍'),
30
+ TodoWrite: c.hyellow('☰'),
31
+ };
12
32
 
13
33
  // 도구 호출을 한 줄로 요약 — Read(src/a.js) 처럼.
14
34
  function toolLabel(name, args) {
15
35
  const a = args ?? {};
16
36
  const first =
17
- a.file_path ?? a.pattern ?? a.path ??
18
- (a.command ? String(a.command).slice(0, 48) : '') ?? '';
19
- return `${name}(${c.gray(String(first))})`;
37
+ a.file_path ?? a.pattern ?? a.path ?? a.url ?? a.name ??
38
+ (a.command ? String(a.command).replace(/\s+/g, ' ').slice(0, 52) : null) ??
39
+ // 할 일 목록은 보여줄 경로가 없다. 빈 괄호를 띄우느니 개수를 적는다.
40
+ (Array.isArray(a.todos) ? `${a.todos.length}건` : null) ?? '';
41
+ const g = TOOL_GLYPH[name] ?? c.cyan('⏺');
42
+ const 안 = clip(String(first ?? ''), 56);
43
+ return `${g} ${c.bold(name)}${안 ? `${c.gray('(')}${c.gray(안)}${c.gray(')')}` : ''}`;
20
44
  }
21
45
 
22
46
  function toolResultLine(result, ms) {
23
- if (result?.error) return `${c.red('└')} ${c.red(String(result.error).split('\n')[0])}`;
24
- const s = result?.summary ?? '완료';
25
- const t = ms > 1000 ? c.gray(` ${(ms / 1000).toFixed(1)}초`) : '';
26
- return `${c.gray('└')} ${c.gray(s)}${t}`;
47
+ const t = ms > 700 ? c.gray(` ${(ms / 1000).toFixed(1)}초`) : '';
48
+ if (result?.error) return `${c.red('')} ${c.red(clip(String(result.error).split('\n')[0], 80))}${t}`;
49
+ return `${c.gray('└')} ${c.gray(clip(result?.summary ?? '완료', 80))}${t}`;
27
50
  }
28
51
 
29
52
  export async function chatLoop(opts = {}) {
@@ -44,13 +67,42 @@ export async function chatLoop(opts = {}) {
44
67
  tools: prof.tools ?? false, json: prof.json ?? false, think: prof.think ?? false,
45
68
  };
46
69
 
70
+ // 이 자리 하나만 연다. 다른 어디로도 나가지 못한다.
71
+ allowEndpoint(conn.base);
72
+ if (opts.offline ?? prof.offline) setOffline(true);
73
+
47
74
  const session = new Session(conn, {
48
75
  root,
49
76
  mode: opts.mode ?? 'auto',
77
+ // 수준은 설정에 남는다. 한 번 고르면 다음에 켤 때도 그대로다.
78
+ level: opts.level ?? cfg.level ?? null,
50
79
  think: opts.think ?? 'medium',
80
+ effort: opts.effort ?? 'save',
51
81
  maxSteps: opts.maxSteps ?? 24,
52
82
  });
53
83
 
84
+ // ── 대화 이어하기 ─────────────────────────────────────────────────────
85
+ // 껐다 켜도 이어지도록, 메시지가 오갈 때마다 .deel/sessions/ 에 바로 적는다.
86
+ let store = null;
87
+ if (opts.sessionId || opts.continue) {
88
+ const target = opts.sessionId ?? latest(root)?.id;
89
+ if (!target) {
90
+ say('');
91
+ say(` ${c.gray('이어할 대화가 없습니다. 새로 시작합니다.')}`);
92
+ } else {
93
+ store = new Store(root, target);
94
+ const { messages } = store.load();
95
+ if (messages.length) {
96
+ session.messages = messages;
97
+ say('');
98
+ say(` ${mark.ok} ${c.bold(target)} ${c.gray(`— 메시지 ${messages.length}개를 이어 받았습니다.`)}`);
99
+ }
100
+ }
101
+ }
102
+ if (!store) store = new Store(root);
103
+ store.begin({ model: conn.model, base: conn.base, root });
104
+ try { prune(root); } catch {}
105
+
54
106
  // 이 PC 에 있는 스킬·명령·플러그인을 찾아 붙인다. 품고 다니지 않는다.
55
107
  const found = discover(root);
56
108
  session.skills = found.skills;
@@ -66,7 +118,7 @@ export async function chatLoop(opts = {}) {
66
118
  const echo = !process.stdin.isTTY; // 파이프·기록용일 때는 입력을 되비춘다
67
119
 
68
120
  rl.on('line', (l) => {
69
- if (echo) say(l);
121
+ if (echo) say(c.gray(l));
70
122
  if (waiter) { const w = waiter; waiter = null; w(l); }
71
123
  else queue.push(l);
72
124
  });
@@ -75,6 +127,23 @@ export async function chatLoop(opts = {}) {
75
127
  if (waiter) { const w = waiter; waiter = null; w(null); }
76
128
  });
77
129
 
130
+ // Shift+Tab 으로 작업 모드를 차례로 돌린다.
131
+ //
132
+ // 터미널일 때만 한다. 파이프로 넣을 때 키를 가로채면 입력이 깨진다 —
133
+ // 검사와 데모가 그렇게 돌아간다.
134
+ if (process.stdin.isTTY) {
135
+ emitKeypressEvents(process.stdin, rl);
136
+ process.stdin.on('keypress', (_ch, key) => {
137
+ if (!key || key.name !== 'tab' || !key.shift) return;
138
+ session.work = nextWork(session.work);
139
+ const w = getWork(session.work);
140
+ cursor.clearLine();
141
+ say(` ${c.hcyan(w.glyph)} ${c.bold(w.name)} ${c.gray('(' + w.en + ')')} ${c.gray(w.hint)}`
142
+ + (canWrite(session.work) ? '' : ` ${c.green('· 파일을 못 바꿉니다')}`));
143
+ prompt();
144
+ });
145
+ }
146
+
78
147
  const nextLine = () => {
79
148
  if (queue.length) return Promise.resolve(queue.shift());
80
149
  if (closed) return Promise.resolve(null);
@@ -88,6 +157,22 @@ export async function chatLoop(opts = {}) {
88
157
  return a.trim() || o.def || '';
89
158
  };
90
159
 
160
+ /**
161
+ * 오류를 이 사람 수준에 맞게 보여준다.
162
+ *
163
+ * 쉬움 수준에서는 무엇을 하면 되는지를 앞에 놓고, 원래 문구는 회색으로 뒤에 남긴다.
164
+ * 원인을 지우지 않는 것이 중요하다 — 지우면 물어볼 수도 없게 된다.
165
+ * 개발자 수준에서는 원래 문구 그대로다.
166
+ */
167
+ const 오류보이기 = (message) => {
168
+ const r = explain(session.level, message);
169
+ if (!r.plain) { say(` ${c.red('✗')} ${String(message)}`); return; }
170
+ const [머리, ...나머지] = r.text.split('\n');
171
+ say(` ${c.red('✗')} ${머리}`);
172
+ for (const l of 나머지) say(` ${l}`);
173
+ if (r.detail) say(` ${c.gray(`(원래 문구: ${clip(String(r.detail).split('\n')[0], 90)})`)}`);
174
+ };
175
+
91
176
  const ctx = {
92
177
  scope: makeScope(root),
93
178
  history: new History(root),
@@ -96,6 +181,13 @@ export async function chatLoop(opts = {}) {
96
181
  skills: found.skills,
97
182
  loadedSkills: new Set(),
98
183
  ask,
184
+ // 암호는 여기서만 받는다. 받은 값은 도구가 쓰고 버린다 —
185
+ // 설정에도, 세션 기록에도, 감사기록에도, 명령줄에도 안 남는다.
186
+ askPassword: async (label) => {
187
+ if (closed) return null;
188
+ const pw = await askHidden(rl, label, nextLine);
189
+ return pw === null || pw === '' ? null : pw;
190
+ },
99
191
  confirm: async (name, args) => {
100
192
  say('');
101
193
  say(` ${c.yellow('?')} ${toolLabel(name, args)}`);
@@ -104,30 +196,44 @@ export async function chatLoop(opts = {}) {
104
196
  },
105
197
  };
106
198
 
107
- // 머리말
199
+ // ── 머리말 ────────────────────────────────────────────────────────────
108
200
  say('');
109
- say(` ${c.cyan('deel')} ${c.gray(prof.model)} ${c.gray('·')} ${c.gray(ctx.scope.root)}`);
201
+ for (const l of box(headerLines(session, found), { tone: c.gray })) say(' ' + l);
110
202
  const warn = [];
111
- if (!conn.tools) warn.push('도구 호출 미확인');
112
- if (!conn.streaming) warn.push('스트리밍 없음');
113
- if (warn.length) say(` ${c.yellow('⚠')} ${c.gray(warn.join(' · '))}`);
114
- if (found.skills.length || found.commands.length) {
115
- say(` ${c.gray(`스킬 ${found.skills.length}개 · 명령 ${found.commands.length}개 찾음`)}${found.plugins.length ? c.gray(` (플러그인 ${found.plugins.length}개)`) : ''}`);
116
- }
117
- say(` ${c.gray('/help 명령 목록. Ctrl+C 끝냅니다.')}`);
118
- say('');
203
+ if (!conn.tools) warn.push('도구 호출이 확인되지 않았습니다 — deel diagnose 로 점검하세요');
204
+ if (!conn.streaming) warn.push('스트리밍이 없어 응답이 한 번에 나옵니다');
205
+ for (const w of warn) say(` ${mark.warn} ${c.gray(w)}`);
206
+ say(` ${c.gray('/help 명령 목록')} ${c.gray('/think 추론 강도')} ${c.gray('Ctrl+C 중단·끝내기')}`);
207
+
208
+ // 입력 자리. 위에 상태줄을 한 줄 깔고 그 아래에 커서를 둔다.
209
+ const prompt = () => {
210
+ say('');
211
+ say(statusLine(session));
212
+ const w = contextWarning(session);
213
+ if (w) say(` ${w}`);
214
+ process.stdout.write(` ${c.hcyan('❯')} `);
215
+ };
119
216
 
217
+ // Ctrl+C 는 상황에 따라 뜻이 다르다.
218
+ // 모델이 답하는 중 → 그 답을 끊는다 (프로그램은 살아 있다)
219
+ // 입력을 기다리는 중 → 한 번은 경고, 두 번이면 끝낸다
220
+ // 느린 로컬 모델이 엉뚱한 답을 길게 뽑기 시작했을 때 끝까지 기다리지 않아도 된다.
120
221
  let interrupted = false;
222
+ let turn = null; // 지금 도는 턴의 AbortController
121
223
  rl.on('SIGINT', () => {
224
+ if (turn && !turn.signal.aborted) {
225
+ turn.abort();
226
+ return; // 화면 정리는 루프 쪽 'aborted' 이벤트가 한다
227
+ }
122
228
  if (interrupted) { rl.close(); return; }
123
229
  interrupted = true;
124
230
  say('');
125
231
  say(` ${c.gray('한 번 더 Ctrl+C 를 누르면 끝냅니다.')}`);
126
- process.stdout.write(`\n${c.cyan('›')} `);
232
+ prompt();
127
233
  });
128
234
 
129
235
  for (;;) {
130
- process.stdout.write(`\n${c.cyan('›')} `);
236
+ prompt();
131
237
  const line = await nextLine();
132
238
  if (line === null) break; // 입력이 끝났다 (파이프 종료 / Ctrl+D)
133
239
  interrupted = false;
@@ -141,82 +247,168 @@ export async function chatLoop(opts = {}) {
141
247
 
142
248
  say('');
143
249
  const started = Date.now();
250
+ const before = { in: session.usage.in, out: session.usage.out };
251
+
252
+ // 어디까지 적었는지. 도중에 죽어도 여기까지는 남아 있게 자주 흘려 보낸다.
253
+ let saved = session.messages.length;
254
+ const flush = () => {
255
+ for (const m of session.messages.slice(saved)) store.append(m);
256
+ saved = session.messages.length;
257
+ };
144
258
  let tools = 0;
145
259
  let thinkChars = 0;
146
260
  let streamed = false;
147
261
  let thinkingShown = false;
262
+ let stage = null;
148
263
 
264
+ const clearThinking = () => { if (thinkingShown) { cursor.clearLine(); thinkingShown = false; } };
265
+
266
+ turn = new AbortController();
149
267
  try {
150
- for await (const ev of run(session, ctx, toSend)) {
268
+ for await (const ev of run(session, ctx, toSend, { signal: turn.signal })) {
151
269
  switch (ev.type) {
270
+ // 어느 단계를 어떤 강도로 도는지 — 추론 강도 조절이 실제로 먹는지 눈으로 보인다.
271
+ case 'stage':
272
+ stage = ev;
273
+ thinkChars = 0;
274
+ break;
275
+
276
+ case 'retry':
277
+ clearThinking();
278
+ say(` ${c.yellow('↻')} ${c.gray(`${ev.why} — 상한을 ${ev.from} → ${ev.to} 로 올려 다시 부릅니다`)}`);
279
+ break;
280
+
152
281
  case 'waiting':
153
- process.stdout.write(` ${c.gray('생각 중…')}\r`);
282
+ process.stdout.write(` ${c.gray(stageTag(stage) + ' 생각 중…')}\r`);
154
283
  break;
155
284
 
156
285
  case 'thinking':
157
286
  thinkChars += ev.text.length;
158
287
  if (process.stdout.isTTY) {
159
288
  cursor.clearLine();
160
- process.stdout.write(` ${c.magenta('✻')} ${c.gray(`생각 중… ${thinkChars}자`)}`);
289
+ process.stdout.write(` ${mark.think} ${c.gray(stageTag(stage))} ${c.gray(`생각 중… ${thinkChars.toLocaleString()}자`)}`);
161
290
  thinkingShown = true;
162
291
  }
163
292
  break;
164
293
 
165
294
  case 'content':
166
- if (thinkingShown) { cursor.clearLine(); thinkingShown = false; }
295
+ clearThinking();
167
296
  if (!streamed) { streamed = true; process.stdout.write(' '); }
168
297
  process.stdout.write(ev.text.replace(/\n/g, '\n '));
169
298
  break;
170
299
 
171
300
  case 'tool_start':
172
- if (thinkingShown) { cursor.clearLine(); thinkingShown = false; }
301
+ clearThinking();
302
+ if (streamed) { say(''); streamed = false; }
303
+ say('');
304
+ say(` ${toolLabel(ev.name, ev.args)}`);
305
+ break;
306
+
307
+ // 여럿을 같이 돌린다 — 한 줄로 알리고, 이름은 결과와 붙여서 그린다.
308
+ case 'tools_start':
309
+ clearThinking();
173
310
  if (streamed) { say(''); streamed = false; }
174
311
  say('');
175
- say(` ${c.cyan('')} ${toolLabel(ev.name, ev.args)}`);
312
+ say(` ${c.gray(`${ev.count}개를 함께 돌립니다`)} ${c.gray('·')} ${c.gray(ev.names.join(' '))}`);
176
313
  break;
177
314
 
178
315
  case 'tool':
179
316
  tools++;
180
- say(` ${toolResultLine(ev.result, ev.ms ?? 0)}`);
317
+ // 같이 것은 이름을 다시 적어 준다. 안 그러면 어느 결과인지 모른다.
318
+ if (ev.parallel) say(` ${toolLabel(ev.name, ev.args)}`);
319
+ if (ev.name === 'TodoWrite' && ev.result?.todos) {
320
+ for (const t of ev.result.todos) {
321
+ const 표 = t.state === 'done' ? c.green('☑') : t.state === 'doing' ? c.hyellow('▶') : c.gray('☐');
322
+ const 글 = t.state === 'done' ? c.gray(t.text) : t.state === 'doing' ? c.white(t.text) : c.gray(t.text);
323
+ say(` ${표} ${clip(글, 74)}`);
324
+ }
325
+ } else {
326
+ say(` ${toolResultLine(ev.result, ev.ms ?? 0)}`);
327
+ }
328
+ flush(); // 도구가 하나 끝날 때마다 적어 둔다
181
329
  break;
182
330
 
183
331
  case 'trimmed':
184
332
  say(` ${c.gray(`(컨텍스트가 차서 오래된 대화 ${ev.dropped}개를 줄였습니다)`)}`);
185
333
  break;
186
334
 
335
+ case 'compacting':
336
+ clearThinking();
337
+ process.stdout.write(` ${c.gray('컨텍스트가 찼습니다 — 앞선 대화를 요약해 접는 중…')}\r`);
338
+ break;
339
+
340
+ case 'compacted': {
341
+ if (process.stdout.isTTY) cursor.clearLine();
342
+ const 줄인 = ev.before - ev.after;
343
+ say(` ${c.cyan('◱')} ${c.gray(`대화 ${ev.folded}개를 요약으로 접었습니다 — `)}` +
344
+ `${c.gray(ev.before.toLocaleString())} ${c.gray('→')} ${c.white(ev.after.toLocaleString())} ${c.gray('토큰')} ` +
345
+ `${c.green(`(${Math.round((줄인 / Math.max(1, ev.before)) * 100)}% 줄어듦)`)}`);
346
+ if (ev.fallback) say(` ${c.yellow('요약을 못 받아 그냥 줄였습니다.')}`);
347
+ // 접히면 이력이 통째로 바뀐다. 덧붙이기로는 못 맞추니 새로 적는다.
348
+ store.replace(session.messages, `압축 — ${ev.folded}개를 요약으로`);
349
+ saved = session.messages.length;
350
+ break;
351
+ }
352
+
353
+ case 'compact_failed':
354
+ if (process.stdout.isTTY) cursor.clearLine();
355
+ say(` ${c.gray(`(접지 못했습니다: ${ev.why})`)}`);
356
+ break;
357
+
187
358
  case 'limit':
188
359
  say('');
189
- say(` ${c.yellow('⚠')} 도구 호출 ${ev.steps}회에서 멈췄습니다. ${c.gray('이어서 하려면 다시 말씀하세요.')}`);
360
+ say(` ${mark.warn} 도구 호출 ${ev.steps}회에서 멈췄습니다. ${c.gray('이어서 하려면 다시 말씀하세요.')}`);
361
+ break;
362
+
363
+ case 'aborted':
364
+ clearThinking();
365
+ if (streamed) { say(''); streamed = false; }
366
+ say('');
367
+ say(` ${c.yellow('⊘')} ${c.gray('중단했습니다. 여기까지는 대화에 남아 있으니 이어서 말씀하세요.')}`);
190
368
  break;
191
369
 
192
370
  case 'error':
193
- if (thinkingShown) cursor.clearLine();
371
+ clearThinking();
194
372
  say('');
195
- say(` ${c.red('✗')} ${ev.text}`);
373
+ 오류보이기(ev.text);
196
374
  break;
197
375
 
198
376
  case 'done':
377
+ clearThinking();
199
378
  if (streamed) say('');
200
379
  break;
201
380
  }
202
381
  }
203
382
  } catch (err) {
383
+ clearThinking();
204
384
  say('');
205
- say(` ${c.red('✗')} ${err.message}`);
385
+ 오류보이기(err.message);
206
386
  }
387
+ turn = null;
388
+ interrupted = false; // 중단은 '끝내기' 의사가 아니다. 종료 카운트를 되돌린다.
389
+ flush(); // 오류로 끝났어도 여기까지는 남긴다
207
390
 
208
- // 꼬리말 — 이번 요약
391
+ // ── 꼬리말 — 이번 턴만의 숫자 ────────────────────────────────────────
209
392
  const secs = ((Date.now() - started) / 1000).toFixed(1);
210
393
  const bits = [`${secs}초`];
211
394
  if (tools) bits.push(`도구 ${tools}회`);
212
- if (session.usage.out) bits.push(`${session.usage.out.toLocaleString()}토큰`);
395
+ const dIn = session.usage.in - before.in;
396
+ const dOut = session.usage.out - before.out;
397
+ if (dIn || dOut) bits.push(`↑${dIn.toLocaleString()} ↓${dOut.toLocaleString()}`);
213
398
  say('');
214
- say(` ${c.gray('─ ' + bits.join(' · '))}`);
399
+ say(` ${c.gray('─'.repeat(2))} ${c.gray(bits.join(c.gray(' · ')))}`);
215
400
  }
216
401
 
217
402
  rl.close();
218
403
  say('');
219
- say(` ${c.gray('끝냅니다.')} ${c.gray(`도구 시간 ${(session.usage.ms / 1000).toFixed(1)}초 · 모델 호출 ${session.usage.calls}회`)}`);
404
+ say(` ${c.gray('끝냅니다.')} ${c.gray(`모델 호출 ${session.usage.calls}회 · 도구 시간 ${(session.usage.ms / 1000).toFixed(1)}초 · ↑${session.usage.in.toLocaleString()} ↓${session.usage.out.toLocaleString()}`)}`);
220
405
  say('');
221
406
  return 0;
222
407
  }
408
+
409
+ // "첫 판단·high" 처럼 지금 도는 단계를 짧게.
410
+ function stageTag(ev) {
411
+ if (!ev) return '';
412
+ const label = STAGES[ev.stage]?.label ?? ev.stage;
413
+ return `${label}·${ev.level}`;
414
+ }
@@ -0,0 +1,95 @@
1
+ // 어디로 말을 걸 수 있는지 한 곳에서 정한다.
2
+ //
3
+ // 왜 필요한가:
4
+ // 코딩 에이전트는 소스 코드를 통째로 모델에 보낸다. 그 주소가 어디인지가 전부다.
5
+ // "설정한 곳으로만 갑니다" 를 말로 하면 언젠가 거짓이 된다. 코드가 막아야 한다.
6
+ //
7
+ // 규칙:
8
+ // 1) 기본은 전부 거절. 허용 목록에 오른 자리만 통과.
9
+ // 2) 모델 호출은 setup 에서 정한 그 주소 하나만.
10
+ // 3) 플러그인 받기(github)는 사용자가 그 명령을 칠 때만 잠깐 열린다.
11
+ // 4) offline 이면 이 컴퓨터 밖은 전부 거절 — 3번도 막힌다.
12
+ //
13
+ // 여기를 지나지 않는 요청은 없다. http.js 의 req() 가 매번 물어본다.
14
+
15
+ export class NetBlocked extends Error {
16
+ constructor(url, why) {
17
+ super(`허용되지 않은 주소입니다: ${url}\n ${why}`);
18
+ this.name = 'NetBlocked';
19
+ this.url = url;
20
+ }
21
+ }
22
+
23
+ const LOCAL = new Set(['localhost', '127.0.0.1', '::1', '0.0.0.0', '[::1]']);
24
+
25
+ export const isLocalHost = (h) => LOCAL.has(String(h).toLowerCase()) ||
26
+ /^127\./.test(h) ||
27
+ /^10\./.test(h) ||
28
+ /^192\.168\./.test(h) ||
29
+ /^172\.(1[6-9]|2\d|3[01])\./.test(h);
30
+
31
+ function originOf(url) {
32
+ const u = new URL(url);
33
+ return `${u.protocol}//${u.host}`;
34
+ }
35
+
36
+ // 하나뿐인 문지기. 모듈 하나에 상태를 두는 것은 일부러다 —
37
+ // 여기저기서 각자 예외를 두면 자물쇠가 아니게 된다.
38
+ const gate = {
39
+ allow: new Set(), // 통과시킬 origin 들
40
+ offline: false, // true 면 이 컴퓨터 밖은 전부 거절
41
+ log: [], // 실제로 나간 곳 (사람이 확인용)
42
+ enforced: true,
43
+ };
44
+
45
+ /** 모델 연결 주소를 허용 목록에 올린다. 이전에 올린 것은 지운다. */
46
+ export function allowEndpoint(baseUrl) {
47
+ gate.allow.clear();
48
+ if (baseUrl) gate.allow.add(originOf(baseUrl));
49
+ return [...gate.allow];
50
+ }
51
+
52
+ /** 잠깐 한 곳을 더 연다. 되돌리는 함수를 준다 — 반드시 finally 에서 부른다. */
53
+ export function allowTemporarily(url) {
54
+ const o = originOf(url);
55
+ const had = gate.allow.has(o);
56
+ gate.allow.add(o);
57
+ return () => { if (!had) gate.allow.delete(o); };
58
+ }
59
+
60
+ export function setOffline(on) { gate.offline = !!on; return gate.offline; }
61
+ export function isOffline() { return gate.offline; }
62
+ export function allowed() { return [...gate.allow]; }
63
+ export function contacted() { return gate.log.slice(); }
64
+ export function resetNet() { gate.allow.clear(); gate.log.length = 0; gate.offline = false; }
65
+
66
+ /**
67
+ * 이 주소로 나가도 되는가. 안 되면 던진다.
68
+ * 통과한 것은 기록에 남는다 — "무엇이 어디로 갔나" 를 나중에 보여 주기 위해서다.
69
+ */
70
+ export function checkUrl(url) {
71
+ let u;
72
+ try { u = new URL(url); } catch { throw new NetBlocked(url, '주소 형식이 아닙니다.'); }
73
+
74
+ if (u.protocol !== 'http:' && u.protocol !== 'https:') {
75
+ throw new NetBlocked(url, `${u.protocol} 는 쓰지 않습니다.`);
76
+ }
77
+
78
+ const local = isLocalHost(u.hostname);
79
+ if (gate.offline && !local) {
80
+ throw new NetBlocked(url, '오프라인 모드입니다 — 이 컴퓨터 밖으로는 나가지 않습니다.');
81
+ }
82
+
83
+ const origin = `${u.protocol}//${u.host}`;
84
+ if (!gate.allow.has(origin)) {
85
+ throw new NetBlocked(url,
86
+ gate.allow.size
87
+ ? `지금 허용된 곳: ${[...gate.allow].join(', ')}`
88
+ : '연결이 정해지지 않았습니다. deel setup 을 먼저 하세요.');
89
+ }
90
+
91
+ const seen = gate.log.find((x) => x.origin === origin);
92
+ if (seen) seen.n++;
93
+ else gate.log.push({ origin, n: 1, local });
94
+ return true;
95
+ }
@@ -1,7 +1,11 @@
1
1
  // 되돌리기. 승인 프롬프트를 안 쓰는 대신 이게 안전망이다.
2
2
  // 파일을 고치기 전에 항상 이전 내용을 떠 놓고, /undo 로 턴 단위로 되돌린다.
3
3
  import { join } from 'node:path';
4
- import { readFileSync, writeFileSync, existsSync, mkdirSync, rmSync, appendFileSync, readdirSync } from 'node:fs';
4
+ import { readFileSync, writeFileSync, existsSync, mkdirSync, rmSync, appendFileSync, readdirSync, statSync } from 'node:fs';
5
+
6
+ // 되돌리기 이력은 파일 내용을 통째로 담는다. 이만큼 커지면 오래된 턴을 버린다.
7
+ const MAX_BYTES = 32 * 1024 * 1024;
8
+ const KEEP_TURNS = 50;
5
9
 
6
10
  export class History {
7
11
  constructor(root) {
@@ -22,9 +26,50 @@ export class History {
22
26
  const before = existsSync(absPath) ? safeRead(absPath) : null;
23
27
  const rec = { turn: this.turn, at: new Date().toISOString(), path: absPath, before, label };
24
28
  appendFileSync(this.file, JSON.stringify(rec) + '\n', 'utf8');
29
+ this.#maybePrune();
25
30
  return rec;
26
31
  }
27
32
 
33
+ /**
34
+ * 이력이 끝없이 자라는 것을 막는다.
35
+ *
36
+ * 스냅샷은 파일 내용을 통째로 담는다. 큰 파일을 여러 번 고치면 금방 수십 MB 가 된다.
37
+ * 그런데 아무 때나 자르면 안 된다 — 방금 한 일을 못 되돌리게 되면 안전망이 아니다.
38
+ * 그래서 최근 KEEP_TURNS 개 턴은 무조건 남기고, 그보다 오래된 것만 버린다.
39
+ *
40
+ * 매번 확인하면 파일을 계속 다시 읽게 되므로, 커졌을 때만 본다.
41
+ */
42
+ #maybePrune() {
43
+ this.#writes = (this.#writes ?? 0) + 1;
44
+ if (this.#writes % 20 !== 0) return;
45
+ try {
46
+ if (statSync(this.file).size < MAX_BYTES) return;
47
+ } catch { return; }
48
+ this.prune();
49
+ }
50
+
51
+ #writes = 0;
52
+
53
+ /** 최근 keep 개 턴만 남기고 자른다. 버린 줄 수를 돌려준다. */
54
+ prune({ keep = KEEP_TURNS } = {}) {
55
+ const recs = this.all();
56
+ const turns = this.turns();
57
+ if (turns.length <= keep) return 0;
58
+ const 남길턴 = new Set(turns.slice(-keep));
59
+ const 남길것 = recs.filter((r) => 남길턴.has(r.turn));
60
+ const 버린수 = recs.length - 남길것.length;
61
+ if (!버린수) return 0;
62
+ try {
63
+ writeFileSync(this.file, 남길것.map((r) => JSON.stringify(r)).join('\n') + (남길것.length ? '\n' : ''), 'utf8');
64
+ } catch { return 0; }
65
+ return 버린수;
66
+ }
67
+
68
+ /** 지금 이력이 얼마나 되나. /status 에서 보여 준다. */
69
+ size() {
70
+ try { return statSync(this.file).size; } catch { return 0; }
71
+ }
72
+
28
73
  all() {
29
74
  if (!existsSync(this.file)) return [];
30
75
  return readFileSync(this.file, 'utf8')
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);