deel-local-cli 1.13.1 → 1.15.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.
@@ -112,6 +112,27 @@ export function 내부살림(abs) {
112
112
  }
113
113
  if (i >= 0) {
114
114
  const 안 = 조각.slice(i + 1);
115
+ /*
116
+ * ── 폴더 **자체**를 가리키면 그것도 막는다 ──────────────────────────
117
+ *
118
+ * 여태 이 자는 「그 안의 파일」 만 봤다. `안` 이 비면(경로가 살림 뿌리
119
+ * 자체면) 아무 갈래에도 안 걸리고 그대로 null 로 떨어졌다. 그래서
120
+ * `rm -rf .deel/history` 는 막히는데 `rm -rf .deel` 은 통과했다 —
121
+ * 같은 파일을 지우는 두 명령이 서로 다른 답을 받았다.
122
+ *
123
+ * 이 폴더에는 되돌리기 이력과 감사 기록이 산다. 되돌리기는 이 프로그램이
124
+ * 승인 창을 안 띄우는 **근거**다(집안 규칙 3). 그 그물을 걷어 내는
125
+ * 명령을 그냥 통과시키면 근거가 통째로 없어지고, 그 사실은 되돌리려고
126
+ * 할 때에야 드러난다.
127
+ *
128
+ * 「작업 폴더 정리해 줘」 한 마디에 `rm -rf build dist .deel` 이 나오는
129
+ * 것은 드문 일이 아니다.
130
+ */
131
+ if (안.length === 0) {
132
+ return 'deel 자신의 살림 폴더입니다. 되돌리기 이력과 감사 기록이 들어 있어'
133
+ + ' 통째로 건드리지 않습니다 — 지우면 /undo 가 되돌릴 것이 없어집니다.'
134
+ + ' 안의 파일 하나가 필요하면 사용자에게 물어보세요.';
135
+ }
115
136
  if (안[0] === 'config.json') {
116
137
  return 'deel 자신의 설정 파일입니다. 게이트웨이 열쇠가 들어 있어 읽지 않습니다.'
117
138
  + ' 연결 상태가 궁금하면 사용자에게 /status 를 쳐 보라고 하세요.';
@@ -219,16 +240,44 @@ export function 훑기상한(env = process.env) {
219
240
  /**
220
241
  * 폴더를 훑는다.
221
242
  *
243
+ /*
244
+ * ── 몇 개마다 한 번 숨을 쉴까 ───────────────────────────────────────────
245
+ *
246
+ * 숨 한 번은 setImmediate 한 번이고, 그 사이에 이벤트 루프가 한 바퀴 돈다.
247
+ * 그 한 바퀴가 이 파일에서 제일 중요한 줄이다 — 그동안에만 키보드가 배달되고,
248
+ * 그동안에만 signal.aborted 가 참으로 바뀔 수 있다.
249
+ *
250
+ * 자주 쉬면 훑기 자체가 느려지고, 드물게 쉬면 그만큼 귀를 닫는다. 200개면
251
+ * 사내망 드라이브에서도 한 숨 사이가 10ms 언저리다.
252
+ */
253
+ export const 숨쉴간격 = 200;
254
+ const 한숨 = () => new Promise((풀기) => setImmediate(풀기));
255
+
256
+ /**
257
+ * 폴더를 훑는다. **비동기다** — 부르는 쪽은 반드시 await 한다.
258
+ *
222
259
  * @param {AbortSignal|null} signal 멈추라면 훑다 말고 나온다.
223
260
  * 사내망 드라이브에서는 파일 20,000개 훑기가 몇 초다. 그 사이에
224
261
  * ESC 를 눌렀는데 끝까지 다 훑고 나서 멈추면, 사람 눈에는 멈추지
225
262
  * 않는 것으로 보인다. 나온 것에 `끊김` 을 달아 부르는 쪽이 알게 한다 —
226
263
  * 조용히 적게 돌려주면 「그런 파일이 없다」가 되어 버린다.
264
+ *
265
+ * ── 왜 비동기여야 하나 ───────────────────────────────────────────────
266
+ *
267
+ * 여기 `signal?.aborted` 를 보는 줄은 오래 있었다. 그런데 훑기가 동기면
268
+ * 그 줄은 **부르기 전에 이미 켜져 있던 깃발**만 볼 수 있다. 깃발을 켜는
269
+ * 것은 이벤트 루프에 걸린 콜백인데, 동기 반복문이 도는 동안 이벤트 루프는
270
+ * 한 칸도 안 돈다.
271
+ *
272
+ * 그래서 「누르고 나서 시킨」 것은 멈추고 「시켜 놓고 누른」 것은 안 멈췄다.
273
+ * 사람이 실제로 하는 것은 뒤쪽인데. 지키는 것처럼 생겼는데 아무것도 안
274
+ * 지키는 줄이었다 (검사: test/abort-tools.test.js 의 4-2).
227
275
  */
228
- export function walk(root, { limit = 훑기상한(), skipDirs = SKIP_DIRS, ignore = true, signal = null } = {}) {
276
+ export async function walk(root, { limit = 훑기상한(), skipDirs = SKIP_DIRS, ignore = true, signal = null } = {}) {
229
277
  const out = [];
230
278
  const 건너뜀 = { 폴더: 0, 파일: 0 };
231
279
  let 끊김 = false;
280
+ let 본것 = 0;
232
281
  const stack = [{ dir: root, rel: '', 규칙: ignore ? 뿌리규칙읽기(root) : [] }];
233
282
  while (stack.length && out.length < limit) {
234
283
  if (signal?.aborted) { 끊김 = true; break; }
@@ -255,7 +304,14 @@ export function walk(root, { limit = 훑기상한(), skipDirs = SKIP_DIRS, ignor
255
304
  out.push({ path: full, rel: erel, mtime: st.mtimeMs, size: st.size });
256
305
  if (out.length >= limit) break;
257
306
  }
307
+ // 본 것을 다 센다 — 걸러낸 것도 readdirSync·걸리나 값을 이미 치렀다.
308
+ 본것 += 1;
309
+ if (본것 % 숨쉴간격 === 0) {
310
+ await 한숨();
311
+ if (signal?.aborted) { 끊김 = true; break; }
312
+ }
258
313
  }
314
+ if (끊김) break;
259
315
  }
260
316
  Object.defineProperty(out, '건너뜀', { value: 건너뜀, enumerable: false });
261
317
  // 상한까지 찼으면 "여기서 멈췄다" 고 표시한다. 딱 맞아떨어져 끝난 경우까지
@@ -1,12 +1,13 @@
1
1
  // 도구. 이름과 인자를 Claude Code 와 같게 맞춘다 —
2
2
  // 그래야 그 관례로 쓰인 스킬·명령이 그대로 먹는다.
3
- import { writeFileSync, appendFileSync, readFileSync, existsSync, mkdirSync, statSync, renameSync, cpSync, rmSync } from 'node:fs';
3
+ import { writeFileSync, appendFileSync, readFileSync, existsSync, mkdirSync, statSync, renameSync, cpSync, rmSync,
4
+ openSync, readSync, closeSync } from 'node:fs';
4
5
  import { dirname, extname, join, relative, sep } from 'node:path';
5
6
  import { 무리로돌리기 } from './spawn.js';
6
7
  import { globToRegex, walk, readText, readTextFull, 내부살림 } from './fsutil.js';
7
8
  import { 건너뜀말 } from './ignore.js';
8
9
  import { encode, label as encLabel, decode as decodeBytes, consoleCodepage, looksBinary } from './encoding.js';
9
- import { checkCommand, checkPaths, isMutating } from '../safety/guard.js';
10
+ import { checkCommand, checkPaths, isMutating, 셸이파일에쓰나 } from '../safety/guard.js';
10
11
  import { 띄우기, 나무끊기, 무리끊기, JOBS_TOOL } from './jobs.js';
11
12
  import { 셸명령 } from './shell.js';
12
13
  import { findMatch, applySpans, reindent, TIER_LABELS } from './edit-match.js';
@@ -226,10 +227,37 @@ function 가린표되돌리나(보인이름, 새내용) {
226
227
  return 몇 ? 표막는말(보인이름, 몇) : null;
227
228
  }
228
229
 
230
+ /**
231
+ * 파일 앞머리만 읽는다.
232
+ *
233
+ * 「글이 아닌 파일인가」 를 가리는 데 30MB 를 다 읽을 이유가 없다. 실제로
234
+ * looksBinary() 자체가 **앞 8000바이트만** 본다(encoding.js) — 그 뒤를 읽는
235
+ * 것은 답에 아무 영향이 없는 순수한 낭비다.
236
+ */
237
+ function 앞머리(abs, 만큼) {
238
+ let fd = null;
239
+ try {
240
+ fd = openSync(abs, 'r');
241
+ const buf = Buffer.allocUnsafe(만큼);
242
+ const 읽은 = readSync(fd, buf, 0, 만큼, 0);
243
+ return buf.subarray(0, 읽은);
244
+ } catch { return null; } finally {
245
+ if (fd != null) { try { closeSync(fd); } catch { /* 이미 닫혔다 */ } }
246
+ }
247
+ }
248
+
249
+ // looksBinary 가 보는 만큼. 두 수가 어긋나면 답이 달라지므로 같이 움직여야 한다.
250
+ const 냄새맡을바이트 = 8000;
251
+
252
+ /** 파일이 몇 바이트인가. 못 재면 -1 — 그러면 들고 있던 수와 절대 안 맞는다. */
253
+ function 파일크기(abs) {
254
+ try { return statSync(abs).size; } catch { return -1; }
255
+ }
256
+
229
257
  function 바이너리인가(abs) {
230
258
  if (!existsSync(abs)) return null;
231
- let buf;
232
- try { buf = readFileSync(abs); } catch { return null; }
259
+ const buf = 앞머리(abs, 냄새맡을바이트);
260
+ if (!buf) return null;
233
261
  if (!looksBinary(buf)) return null;
234
262
  return `글이 아닌 파일입니다 — 덮어쓰면 되살릴 수 없습니다.\n`
235
263
  + ' 그림·hwp·pdf·압축파일 같은 것을 글로 덮어쓰면 그 파일은 그대로 끝납니다.\n'
@@ -304,7 +332,25 @@ function 뜰만한낱말(cmd) {
304
332
  * @returns {string[]} 떠 둔 파일들의 보인 이름
305
333
  */
306
334
  function 바꾸기전스냅샷(cmd, ctx) {
307
- if (!isMutating(cmd)) return [];
335
+ /*
336
+ * ── 쓰는 꼴도 뜬다 ────────────────────────────────────────────────────
337
+ *
338
+ * 여태 `isMutating(cmd)` 하나로 갈랐다. 그건 `mv`·`cp`·`rm` 같은 **첫
339
+ * 낱말**만 보는 자다. 그런데 모델이 셸에서 파일을 고치는 흔한 방법은
340
+ * `echo x > f` · `sed -i` · `tee` · `prettier --write` 이고, 그 전부가
341
+ * 여기서 빠져나갔다 — 열한 가지 중 `rm` 하나만 걸렸다.
342
+ *
343
+ * 그냥 못 뜨는 것으로 끝나지 않았다. 스냅샷이 없으면 그 턴은 기록을 한 줄도
344
+ * 안 남기고, 그러면 History.turns() 가 그 턴을 못 본다. /undo 는 그 턴을
345
+ * **뛰어넘어 앞의 무관한 턴**을 되돌리고 초록색 성공 줄을 찍었다. 사용자는
346
+ * 지키고 싶던 것을 잃고 지우고 싶던 것은 그대로 두게 된다.
347
+ *
348
+ * MUTATING 을 넓히면 안 된다 — 승인 창과 이중실행 거부가 같이 물고 있어서
349
+ * `echo` 마다 물어보게 된다. 그래서 셸이파일에쓰나() 를 따로 둔다
350
+ * (safety/guard.js).
351
+ */
352
+ const 셸쓰기 = 셸이파일에쓰나(cmd);
353
+ if (!isMutating(cmd) && !셸쓰기) return [];
308
354
  const 뜬것 = [];
309
355
  for (const t of 뜰만한낱말(cmd)) {
310
356
  if (뜬것.length >= 스냅샷상한) break;
@@ -313,7 +359,19 @@ function 바꾸기전스냅샷(cmd, ctx) {
313
359
  // 뜨는 데 실패했다고 명령 자체를 막으면 안 되는 명령까지 막힌다.
314
360
  try { abs = ctx.scope.resolve(t); } catch { continue; }
315
361
  try {
316
- if (!existsSync(abs) || statSync(abs).isDirectory()) continue;
362
+ const 있나 = existsSync(abs);
363
+ if (있나 && statSync(abs).isDirectory()) continue;
364
+ /*
365
+ * 쓰는 꼴이면 **없는 파일도** 떠 둔다.
366
+ *
367
+ * `echo x > 새파일.js` 는 파일을 만든다. 「없었다」 는 사실 자체가
368
+ * 되돌릴 거리다 — Write 도구가 이미 그렇게 한다(before: null 이면
369
+ * /undo 가 지운다). 여기만 안 그러면 셸로 만든 파일은 영영 안 지워진다.
370
+ *
371
+ * 옮기기·지우기(isMutating)는 반대다. 없는 파일을 떠 두면 `rm *.tmp`
372
+ * 한 번에 이력이 쓰레기로 찬다. 그래서 이 갈래는 쓰는 꼴에만 연다.
373
+ */
374
+ if (!있나 && !셸쓰기) continue;
317
375
  ctx.history.snapshot(abs, 'Bash');
318
376
  뜬것.push(ctx.scope.show(abs));
319
377
  } catch { /* 못 뜨면 그냥 넘어간다. 명령은 돌아야 한다 */ }
@@ -322,13 +380,44 @@ function 바꾸기전스냅샷(cmd, ctx) {
322
380
  }
323
381
 
324
382
  /** 지금 파일이 몇 줄인가. 붙인 뒤 '얼마나 찼는지' 를 사실로 말해 주려고 센다. */
325
- function 줄수(abs, 인코딩) {
383
+ function 줄수(abs, 인코딩) { return 줄재기(abs, 인코딩).줄; }
384
+
385
+ /**
386
+ * 줄 수와 「끝이 줄바꿈인가」 를 한 번에.
387
+ *
388
+ * 둘을 따로 재면 파일을 두 번 읽는다. 그리고 이어 붙일 때는 둘 다 필요하다 —
389
+ * 끝이 줄바꿈이 아니면 붙이는 첫 줄이 **마지막 줄에 이어지므로** 한 줄 덜 는다.
390
+ */
391
+ function 줄재기(abs, 인코딩) {
326
392
  try {
327
393
  const buf = readFileSync(abs);
328
- if (looksBinary(buf)) return 0;
394
+ if (looksBinary(buf)) return { 줄: 0, 끝줄바꿈: true };
329
395
  const t = 인코딩 && 인코딩 !== 'utf-8' ? decodeBytes(buf).text : buf.toString('utf8');
330
- return t.split('\n').length - (t.endsWith('\n') ? 1 : 0);
331
- } catch { return 0; }
396
+ const 끝줄바꿈 = t.length === 0 || t.endsWith('\n');
397
+ return { 줄: t.split('\n').length - (t.endsWith('\n') ? 1 : 0), 끝줄바꿈 };
398
+ } catch { return { 줄: 0, 끝줄바꿈: true }; }
399
+ }
400
+
401
+ /*
402
+ * ── 이어 붙일 때 줄 수를 다시 안 세려고 들고 있는 것 ─────────────────────
403
+ *
404
+ * Append 는 **한 파일에 여러 번** 불리는 것이 정상이다 — 도구 설명문이 그렇게
405
+ * 시킨다. 그런데 부를 때마다 「지금 전체 몇 줄」 을 대려고 파일을 통째로 읽고
406
+ * 해독하고 split 했다. 30MB 로그에 다섯 줄 붙이는 데 그 짓을 다섯 번 한다.
407
+ *
408
+ * 붙인 줄 수는 우리가 안다. 그러니 앞의 수에 더하면 된다.
409
+ *
410
+ * 그 수가 아직 참인지는 **바이트 수**로 가린다. 우리가 붙인 만큼만 커졌으면
411
+ * 우리가 아는 그 파일이다. 사람이 편집기로 갈아엎었거나 다른 도구가 건드렸으면
412
+ * 크기가 안 맞고, 그때는 두말없이 다시 센다. 틀린 수를 빠르게 대는 것보다
413
+ * 맞는 수를 느리게 대는 편이 낫다.
414
+ */
415
+ const 줄기억 = new Map();
416
+ // 파일이 수천 개씩 쌓이지는 않지만, 오래 도는 대화에서 무한정 크지도 않게 한다.
417
+ const 줄기억최대 = 512;
418
+ function 줄기억넣기(abs, 것) {
419
+ if (줄기억.size >= 줄기억최대) 줄기억.delete(줄기억.keys().next().value);
420
+ 줄기억.set(abs, 것);
332
421
  }
333
422
 
334
423
  /**
@@ -555,7 +644,7 @@ export function 복사해옮기기(앞, 뒤, { 복사 = cpSync, 지우기 = rmSy
555
644
  return {};
556
645
  }
557
646
 
558
- function 한개옮기기({ from, to, overwrite = false }, ctx) {
647
+ async function 한개옮기기({ from, to, overwrite = false }, ctx) {
559
648
  if (typeof from !== 'string' || !from) return { error: 'from 이 없습니다' };
560
649
  if (typeof to !== 'string' || !to) return { error: 'to 가 없습니다' };
561
650
 
@@ -596,7 +685,7 @@ function 한개옮기기({ from, to, overwrite = false }, ctx) {
596
685
  * 뜨는 쪽이 훨씬 나쁘다.
597
686
  */
598
687
  // 옮길 때는 .gitignore 를 안 본다 — 옮겨지는 것은 전부이고, 되돌리기도 전부를 떠야 한다.
599
- const 훑은것 = 폴더인가 ? walk(앞, { ignore: false }) : null;
688
+ const 훑은것 = 폴더인가 ? await walk(앞, { ignore: false }) : null;
600
689
  const 짝들 = 폴더인가
601
690
  ? 훑은것.map((f) => [f.path, join(뒤, relative(앞, f.path))])
602
691
  : [[앞, 뒤]];
@@ -658,7 +747,7 @@ function 한개옮기기({ from, to, overwrite = false }, ctx) {
658
747
  }
659
748
 
660
749
  /** 여러 개를 한 번에. 하나가 막혀도 나머지는 옮기고, 무엇이 막혔는지 다 알린다. */
661
- function 여러개옮기기(목록, ctx) {
750
+ async function 여러개옮기기(목록, ctx) {
662
751
  const 된것 = [];
663
752
  const 안된것 = [];
664
753
  /*
@@ -674,7 +763,7 @@ function 여러개옮기기(목록, ctx) {
674
763
  */
675
764
  const 바뀐것들 = [];
676
765
  for (const 하나 of 목록) {
677
- const r = 한개옮기기(하나, ctx);
766
+ const r = await 한개옮기기(하나, ctx);
678
767
  if (r.error) { 안된것.push(`${하나.from ?? '?'} → ${하나.to ?? '?'}: ${r.error}`); continue; }
679
768
  된것.push(r.content);
680
769
  // 폴더를 옮겼으면 그 안의 파일 하나하나가, 아니면 옮겨 간 자리가 답이다.
@@ -725,13 +814,38 @@ function 한파일쓰기(args, ctx) {
725
814
  const existed = existsSync(abs);
726
815
  // 덮어쓰기 전 내용. 바뀐 자리를 보여주려면 지금 떠 놔야 한다.
727
816
  // 읽다 터지는 파일(바이너리 등)이면 그냥 없던 셈 친다 — 쓰는 것 자체는 막지 않는다.
728
- let 이전 = null;
729
- if (existed) { try { 이전 = readTextFull(abs).text; } catch { 이전 = null; } }
817
+ /*
818
+ * 읽은 것을 **통째로** 들고 있는다 글만 뽑고 버리지 않는다.
819
+ *
820
+ * 여기가 `.text` 만 꺼내 쓰고 `.encoding` 을 버리고 있었다. 그래서 바로
821
+ * 아래에서 인코딩을 다시 정할 때 볼 것이 캐시밖에 없었고, 캐시는 이
822
+ * 파일을 **Read 로 한 번이라도 연 적이 있을 때만** 차 있다.
823
+ *
824
+ * 「이 파일 전체를 다시 써 줘」 → 모델은 Read 없이 Write 로 간다
825
+ * → 캐시가 비어 있다 → utf-8 로 때운다
826
+ * → 사내 CP949 문서가 조용히 UTF-8 이 된다
827
+ *
828
+ * 화면에는 `덮어씀: 사내문서.txt (4줄)` 만 뜬다. 인코딩 표기는 「원래가
829
+ * utf-8 이 아닐 때」 붙는데 그 원래가 이미 틀렸으니 한 글자도 안 뜬다.
830
+ * 사내 뷰어에서 한글이 깨지고 .bat 이 안 도는데, 사람은 「한 줄 고쳐
831
+ * 달라」 고 했을 뿐이라 원인을 인코딩으로 이을 길이 없다.
832
+ */
833
+ let 읽음 = null;
834
+ if (existed) { try { 읽음 = readTextFull(abs); } catch { 읽음 = null; } }
835
+ // 덮어쓰기 전 내용. 바뀐 자리를 보여주려면 지금 떠 놔야 한다.
836
+ const 이전 = 읽음?.text ?? null;
730
837
  mkdirSync(dirname(abs), { recursive: true });
731
838
 
732
- // 원래 있던 파일이면 그 파일이 쓰던 인코딩으로 되돌려 쓴다.
733
- // 파일이면 UTF-8 이다 요즘 만드는 파일까지 옛 인코딩으로 이유가 없다.
734
- const 원래 = existed ? (ctx.enc?.get(abs) ?? 'utf-8') : 'utf-8';
839
+ /*
840
+ * 원래 있던 파일이면 파일이 **지금** 쓰는 인코딩으로 되돌려 쓴다.
841
+ * 파일이면 UTF-8 이다 요즘 만드는 파일까지 옛 인코딩으로 둘 이유가 없다.
842
+ *
843
+ * 캐시(ctx.enc)는 여기서 안 본다. 방금 잰 값이 있는데 캐시를 보면 두 가지가
844
+ * 어긋난다 — 캐시가 비면 UTF-8 로 때우고(위), 캐시가 낡으면 남이 이미
845
+ * UTF-8 로 바꿔 둔 파일을 CP949 라고 우기며 이모지를 거절한다. 재는 값이
846
+ * 있는 자리에서는 잰 값이 이긴다. Edit 도 이미 그렇게 한다.
847
+ */
848
+ const 원래 = 읽음?.encoding ?? 'utf-8';
735
849
  const 만든것 = encode(args.content, 원래);
736
850
  if (만든것.lost.length) {
737
851
  return {
@@ -1212,6 +1326,9 @@ export const TOOLS = {
1212
1326
  // 이어 붙이는 조각에는 앞머리 표식(BOM)이 들어가면 안 된다 — 파일 한가운데에
1213
1327
  // BOM 이 박히면 그 자리가 이상한 글자로 보인다. 그래서 표식 없는 이름으로 바꾼다.
1214
1328
  const 원래 = existed ? (ctx.enc?.get(abs) ?? 재는인코딩(abs)) : 'utf-8';
1329
+ // 잰 것은 적어 둔다 — Read 가 하는 것과 같은 자리다. 안 적어 두면 이 파일에
1330
+ // 붙일 때마다 인코딩을 다시 재느라 파일을 통째로 또 읽는다.
1331
+ ctx.enc?.set?.(abs, 원래);
1215
1332
  const 조각인코딩 = 원래 === 'utf-8-bom' ? 'utf-8' : 원래;
1216
1333
  const 만든것 = encode(args.content, 조각인코딩);
1217
1334
  if (만든것.lost.length) {
@@ -1222,13 +1339,30 @@ export const TOOLS = {
1222
1339
  };
1223
1340
  }
1224
1341
 
1342
+ /*
1343
+ * 붙이기 **전에** 앞의 줄 수를 잡아 둔다.
1344
+ *
1345
+ * 들고 있던 것이 있고 크기가 그대로면 그걸 쓴다. 아니면 여기서 한 번
1346
+ * 세고, 다음 번부터는 더하기만 한다. 없던 파일이면 앞이 0줄이다.
1347
+ */
1348
+ const 앞것 = !existed
1349
+ ? { 줄: 0, 끝줄바꿈: true }
1350
+ : (() => {
1351
+ const 크기 = 파일크기(abs);
1352
+ const 든것 = 줄기억.get(abs);
1353
+ if (든것 && 든것.바이트 === 크기) return 든것;
1354
+ const 잰것 = 줄재기(abs, 원래);
1355
+ return { 바이트: 크기, ...잰것 };
1356
+ })();
1357
+
1225
1358
  mkdirSync(dirname(abs), { recursive: true });
1226
1359
  if (existed) appendFileSync(abs, 만든것.buf);
1227
1360
  else writeFileSync(abs, 만든것.buf);
1228
1361
  ctx.seen.add(abs);
1229
1362
 
1230
1363
  const 붙인줄 = args.content.split('\n').length - (args.content.endsWith('\n') ? 1 : 0);
1231
- const 전체줄 = 줄수(abs, 원래);
1364
+ const 전체줄 = 앞것 ? 앞것.줄 + 붙인줄 - (앞것.끝줄바꿈 ? 0 : 1) : 줄수(abs, 원래);
1365
+ 줄기억넣기(abs, { 바이트: 파일크기(abs), 줄: 전체줄, 끝줄바꿈: args.content.endsWith('\n') });
1232
1366
  const 표기 = 원래 !== 'utf-8' ? ` · ${encLabel(원래)}` : '';
1233
1367
  return {
1234
1368
  content: `${existed ? '이어 붙임' : '새로 만듦'}: ${ctx.scope.show(abs)}`
@@ -1336,15 +1470,15 @@ export const TOOLS = {
1336
1470
  required: [],
1337
1471
  },
1338
1472
  },
1339
- run(args, ctx) {
1473
+ async run(args, ctx) {
1340
1474
  const 목록 = Array.isArray(args.moves) ? args.moves.filter((x) => x && typeof x === 'object') : [];
1341
1475
  if (목록.length) {
1342
- return 여러개옮기기(목록.map((m) => ({ ...m, overwrite: m.overwrite ?? args.overwrite })), ctx);
1476
+ return await 여러개옮기기(목록.map((m) => ({ ...m, overwrite: m.overwrite ?? args.overwrite })), ctx);
1343
1477
  }
1344
1478
  if (typeof args.from !== 'string' || !args.from) {
1345
1479
  return { error: 'from 이 없습니다. 한 개면 from·to 를, 여러 개면 moves 배열을 주세요.' };
1346
1480
  }
1347
- return 한개옮기기(args, ctx);
1481
+ return await 한개옮기기(args, ctx);
1348
1482
  },
1349
1483
  },
1350
1484
 
@@ -1361,10 +1495,10 @@ export const TOOLS = {
1361
1495
  required: ['pattern'],
1362
1496
  },
1363
1497
  },
1364
- run(args, ctx) {
1498
+ async run(args, ctx) {
1365
1499
  const root = args.path ? ctx.scope.resolve(args.path) : ctx.scope.root;
1366
1500
  const re = globToRegex(args.pattern);
1367
- const 전부 = walk(root, { signal: ctx.signal });
1501
+ const 전부 = await walk(root, { signal: ctx.signal });
1368
1502
  // 훑다 말고 나왔으면 그렇다고 말한다. 조용히 적게 주면 「그런 파일이 없다」가 된다.
1369
1503
  if (전부.끊김) return { error: '중단했습니다. 폴더를 끝까지 안 훑었습니다.', 끝났다: true, 중단됨: true };
1370
1504
  // .gitignore 로 건너뛴 것은 수를 말한다 — 조용히 빼면 '그 파일이 없다' 로 읽힌다 (tools/ignore.js).
@@ -1496,7 +1630,7 @@ export const TOOLS = {
1496
1630
  */
1497
1631
  let files = isFile
1498
1632
  ? [{ path: root, rel: ctx.scope.show(root) }]
1499
- : walk(root, { signal: ctx.signal });
1633
+ : await walk(root, { signal: ctx.signal });
1500
1634
  if (files.끊김) return { error: '중단했습니다. 폴더를 끝까지 안 훑었습니다.', 끝났다: true, 중단됨: true };
1501
1635
  const 안본것 = isFile ? '' : 건너뜀말(files.건너뜀, files.잘림, files.상한).trim(); // .gitignore 로 건너뛴 수 — 꼬리에 적는다
1502
1636
  // 훑기 상한에 걸렸으면 "일치 없음" 이라고 잘라 말하면 안 된다. 안 본 것이다.
@@ -2448,8 +2582,8 @@ async function 고친뒤진단(name, r, ctx) {
2448
2582
  }
2449
2583
 
2450
2584
  /** 이 폴더에서 언어 서버를 쓸 수 있나. repl 이 켤 때 한 번 물어본다. */
2451
- export function 언어서버있나(뿌리) {
2452
- try { return !!프로젝트갈래(뿌리); } catch { return false; }
2585
+ export async function 언어서버있나(뿌리) {
2586
+ try { return !!(await 프로젝트갈래(뿌리)); } catch { return false; }
2453
2587
  }
2454
2588
 
2455
2589
  export async function runTool(name, args, ctx) {
package/src/tools/lsp.js CHANGED
@@ -179,7 +179,7 @@ async function 채비(args, ctx) {
179
179
  * 간다 — 모델은 `handleClick 어디 있어` 처럼 이름만 알고 부르는 것이 보통이라,
180
180
  * 매번 파일을 요구하면 이 도구를 쓰는 값이 사라진다.
181
181
  */
182
- const 볼것 = 파일 ?? 프로젝트갈래(ctx.scope.root)?.대표파일 ?? null;
182
+ const 볼것 = 파일 ?? (await 프로젝트갈래(ctx.scope.root))?.대표파일 ?? null;
183
183
  if (!볼것) return { 오류: '이 폴더에서 쓸 수 있는 언어 서버가 없습니다. Grep · Outline 을 쓰세요.' };
184
184
  if (!갈래(볼것)) return { 오류: `언어 서버가 없는 갈래입니다: ${볼것}` };
185
185
 
@@ -229,14 +229,14 @@ export const OUTLINE_TOOL = {
229
229
  },
230
230
  },
231
231
 
232
- run(args, ctx) {
232
+ async run(args, ctx) {
233
233
  const 시작 = args.path ? ctx.scope.resolve(args.path) : ctx.scope.root;
234
234
  if (!existsSync(시작)) return { error: `없는 경로입니다: ${args.path ?? '.'}` };
235
235
 
236
236
  const 하나인가 = statSync(시작).isFile();
237
237
  let 파일들 = 하나인가
238
238
  ? [{ path: 시작, rel: ctx.scope.show(시작), mtime: statSync(시작).mtimeMs }]
239
- : walk(시작, { skipDirs: SKIP_DIRS, signal: ctx.signal });
239
+ : await walk(시작, { skipDirs: SKIP_DIRS, signal: ctx.signal });
240
240
  if (파일들.끊김) return { error: '중단했습니다. 폴더를 끝까지 안 훑었습니다.', 끝났다: true, 중단됨: true };
241
241
  const 건너뜀 = 하나인가 ? '' : 건너뜀말(파일들.건너뜀, 파일들.잘림, 파일들.상한); // .gitignore 로 건너뛴 수 (tools/ignore.js)
242
242
 
@@ -194,13 +194,13 @@ export const VERIFY_TOOL = {
194
194
  try { abs = ctx.scope.resolve(p); } catch (e) { 볼것.push({ 없음: p, 왜: e.message }); continue; }
195
195
  if (!existsSync(abs)) { 볼것.push({ 없음: p, 왜: '파일이 없습니다' }); continue; }
196
196
  if (statSync(abs).isDirectory()) {
197
- const 안것 = walk(abs, { skipDirs: SKIP_DIRS });
197
+ const 안것 = await walk(abs, { skipDirs: SKIP_DIRS });
198
198
  셈더하기(안것);
199
199
  for (const f of 안것) 볼것.push({ path: f.path });
200
200
  } else 볼것.push({ path: abs }); // 짚어 준 파일은 규칙과 상관없이 본다
201
201
  }
202
202
  } else {
203
- const 전부 = walk(뿌리, { skipDirs: SKIP_DIRS });
203
+ const 전부 = await walk(뿌리, { skipDirs: SKIP_DIRS });
204
204
  셈더하기(전부);
205
205
  const 볼만한것 = 전부
206
206
  .filter((f) => ['.html', '.htm', '.css', '.json', '.js', '.mjs', '.cjs', '.py'].includes(extname(f.path).toLowerCase()));