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.
- package/README.ko.md +32 -26
- package/README.md +32 -26
- package/bin/deel.js +6 -1
- package/package.json +3 -2
- package/src/acp/serve.js +71 -8
- package/src/agent/compact.js +25 -2
- package/src/agent/filemem.js +17 -0
- package/src/agent/loop.js +28 -1
- package/src/agent/session.js +36 -1
- package/src/agent/threads.js +33 -0
- package/src/cmdnames.js +68 -0
- package/src/commands.js +49 -71
- package/src/lsp/servers.js +9 -3
- package/src/oneshot.js +61 -1
- package/src/pack/selfpack.js +15 -2
- package/src/pack/sheet.en.js +16 -2
- package/src/repl.js +1 -1
- package/src/safety/guard.js +74 -1
- package/src/safety/undo.js +37 -2
- package/src/tools/fsutil.js +57 -1
- package/src/tools/index.js +162 -28
- package/src/tools/lsp.js +1 -1
- package/src/tools/outline.js +2 -2
- package/src/tools/verify.js +2 -2
package/src/tools/fsutil.js
CHANGED
|
@@ -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
|
// 상한까지 찼으면 "여기서 멈췄다" 고 표시한다. 딱 맞아떨어져 끝난 경우까지
|
package/src/tools/index.js
CHANGED
|
@@ -1,12 +1,13 @@
|
|
|
1
1
|
// 도구. 이름과 인자를 Claude Code 와 같게 맞춘다 —
|
|
2
2
|
// 그래야 그 관례로 쓰인 스킬·명령이 그대로 먹는다.
|
|
3
|
-
import { writeFileSync, appendFileSync, readFileSync, existsSync, mkdirSync, statSync, renameSync, cpSync, rmSync
|
|
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
|
-
|
|
232
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
331
|
-
|
|
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
|
-
|
|
729
|
-
|
|
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
|
-
|
|
734
|
-
|
|
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
|
|
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
|
|
package/src/tools/outline.js
CHANGED
|
@@ -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
|
|
package/src/tools/verify.js
CHANGED
|
@@ -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()));
|