deel-local-cli 2.1.1 → 2.1.3
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 +6 -4
- package/README.md +6 -4
- package/bin/deel.js +64 -43
- package/package.json +1 -1
- package/src/acp/serve.js +89 -3
- package/src/agent/donecheck.js +19 -4
- package/src/agent/loop.js +44 -4
- package/src/agent/route.js +67 -1
- package/src/agent/session.js +35 -6
- package/src/backend/adapter.js +26 -4
- package/src/backend/http.js +2 -23
- package/src/backend/loosejson.js +109 -0
- package/src/backend/mcp.js +601 -41
- package/src/backend/probe.js +17 -3
- package/src/backend/textcalls.js +196 -0
- package/src/commands.js +26 -37
- package/src/completion.js +3 -0
- package/src/eval/run.js +73 -14
- package/src/exit.js +118 -0
- package/src/i18n/en.js +8 -1
- package/src/i18n/ja.js +8 -1
- package/src/i18n/ko.js +8 -1
- package/src/i18n/zh.js +8 -1
- package/src/lsp/diag.js +2 -1
- package/src/oneshot.js +163 -93
- package/src/repl.js +14 -2
- package/src/runlog.js +99 -0
- package/src/safety/authcmd.js +68 -17
- package/src/safety/gitsnap.js +286 -0
- package/src/safety/undo.js +107 -8
- package/src/safety/which.js +37 -0
- package/src/setup.js +3 -5
- package/src/slash.js +103 -0
- package/src/tools/importgraph.js +107 -0
- package/src/tools/index.js +24 -1
- package/src/tools/outline.js +21 -3
- package/src/tools/verify.js +103 -45
- package/src/ui/banner.js +8 -1
package/src/safety/authcmd.js
CHANGED
|
@@ -69,14 +69,29 @@ let 마지막명령줄 = null;
|
|
|
69
69
|
*/
|
|
70
70
|
export function 마지막명령() { return 마지막명령줄; }
|
|
71
71
|
|
|
72
|
-
|
|
73
|
-
|
|
72
|
+
/*
|
|
73
|
+
* ── 받아 둔 것과 승인은 **명령마다** 따로 든다 (2.1.3) ───────────────────────
|
|
74
|
+
*
|
|
75
|
+
* 둘 다 모듈에 하나뿐이었다. 한 판에 연결이 하나면 그래도 맞다 — 그런데 `/consult` 와 모델을 고른
|
|
76
|
+
* Task 는 **다른 프로필의 연결**을 연다(agent/models.js). 그 연결이 제 명령으로 열쇠를 청하면 들고
|
|
77
|
+
* 있던 앞 프로필의 토큰이 먼저 돌아와, A 게이트웨이의 토큰이 B 게이트웨이로 나갔다. B 의 명령은
|
|
78
|
+
* 묻는 자리에 닿지도 않았고, 닿았어도 A 를 허락한 답이 B 의 답으로 읽혔다.
|
|
79
|
+
*
|
|
80
|
+
* 열쇠는 명령줄로 가른다 — 같은 명령이면 같은 열쇠다(받기설정 이 정책·설정 어느 쪽이든 명령을 준다).
|
|
81
|
+
*/
|
|
82
|
+
const 받은것들 = new Map(); // 명령 → { token, headers, 만료, 언제 } — 메모리에만. 파일로 안 나간다.
|
|
83
|
+
const 물어본것들 = new Map(); // 명령 → 이번 판에 사람이 뭐라고 답했나 (true/false)
|
|
84
|
+
let 마지막받은 = null; // 화면이 연결을 안 줄 때 보여 줄 것
|
|
74
85
|
|
|
75
86
|
/** 검사와 `/model` 갈아타기가 부른다. */
|
|
76
|
-
export function 잊기() {
|
|
87
|
+
export function 잊기() { 받은것들.clear(); 물어본것들.clear(); 마지막받은 = null; 마지막명령줄 = null; }
|
|
77
88
|
|
|
78
|
-
/**
|
|
79
|
-
|
|
89
|
+
/**
|
|
90
|
+
* 지금 들고 있는 것. 없으면 null. 화면·심사서가 **토큰 없이** 상태만 볼 때.
|
|
91
|
+
* @param {object} [설정] 받기설정() 이 준 것 — 주면 그 명령의 것을, 안 주면 마지막에 받은 것을.
|
|
92
|
+
*/
|
|
93
|
+
export function 지금상태(설정 = null) {
|
|
94
|
+
const 받은것 = 설정 ? 받은것들.get(설정.명령) : 마지막받은;
|
|
80
95
|
if (!받은것) return null;
|
|
81
96
|
return { 있음: true, 만료: 받은것.만료, 남은초: Math.max(0, Math.round((받은것.만료 - Date.now()) / 1000)) };
|
|
82
97
|
}
|
|
@@ -251,16 +266,45 @@ export async function 한번받기(설정, { 기다림 = 기본기다림, signal
|
|
|
251
266
|
// 잘못된 것이고, 열어 두면 그 자리에서 영영 멈춘다.
|
|
252
267
|
stdio: ['ignore', 'pipe', 'pipe'],
|
|
253
268
|
windowsHide: true,
|
|
269
|
+
// 유닉스는 셸을 새 무리의 머리로 띄운다 — 끊을 때 무리째 거두게(거두기). 윈도우에서 detached 는 새 콘솔
|
|
270
|
+
// 창이라 안 쓴다(그쪽은 taskkill /T 가 나무를 따라간다). tools/index.js 의 Bash 와 같은 짝이다.
|
|
271
|
+
detached: process.platform !== 'win32',
|
|
254
272
|
});
|
|
255
273
|
} catch (err) {
|
|
256
274
|
return done({ ok: false, 왜: `명령을 못 띄웠습니다 (${err.message})`, 보인것: '' });
|
|
257
275
|
}
|
|
258
276
|
const 마치기 = (것) => { if (!끝났나) { 끝났나 = true; clearTimeout(시계); 끊기해제(); done(것); } };
|
|
277
|
+
/*
|
|
278
|
+
* ── 끊을 때는 **나무째** 거두고 우리 파이프를 놓는다 (2.1.3) ────────────────
|
|
279
|
+
*
|
|
280
|
+
* 명령은 셸을 거쳐 뜬다(tools/shell.js 의 셸명령). kill 은 셸만 죽이고, 윈도우에서는 그 밑의 진짜
|
|
281
|
+
* 로그인 도구(손자)가 살아 stdout 을 물고 있었다. 그러면 「못 받았습니다」 뒤에도 그 파이프가 deel 을
|
|
282
|
+
* 붙들어, 할 일을 다 마친 `deel run` 이 손자가 끝날 때까지 안 끝났다. 윈도우는 셸이 살아 있을 때
|
|
283
|
+
* taskkill /T 로 나무를 따라가야 하므로 kill 은 그 뒤에 한다(backend/mcp.js 닫기 와 같은 차례).
|
|
284
|
+
* 유닉스도 같았다 — 셸만 죽고 손자가 남았다(리눅스 CI 가 잡음). 무리(-pid)째 끊는다.
|
|
285
|
+
*/
|
|
286
|
+
const 거두기 = () => {
|
|
287
|
+
const 죽이기 = () => {
|
|
288
|
+
if (process.platform !== 'win32' && kid.pid) {
|
|
289
|
+
try { process.kill(-kid.pid, 'SIGKILL'); return; } catch { /* 무리가 없다 — 셸만 */ }
|
|
290
|
+
}
|
|
291
|
+
try { kid.kill('SIGKILL'); } catch { /* 이미 죽었으면 그만 */ }
|
|
292
|
+
};
|
|
293
|
+
if (process.platform === 'win32' && kid.pid && kid.exitCode === null) {
|
|
294
|
+
try {
|
|
295
|
+
const 나무 = spawn('taskkill', ['/pid', String(kid.pid), '/T', '/F'], { windowsHide: true, stdio: 'ignore' });
|
|
296
|
+
나무.once('error', 죽이기);
|
|
297
|
+
나무.once('exit', 죽이기);
|
|
298
|
+
나무.unref();
|
|
299
|
+
} catch { 죽이기(); }
|
|
300
|
+
} else 죽이기();
|
|
301
|
+
try { kid.stdout?.destroy(); kid.stderr?.destroy(); } catch { /* 이미 닫힘 */ }
|
|
302
|
+
};
|
|
259
303
|
const 시계 = setTimeout(() => {
|
|
260
|
-
|
|
304
|
+
거두기();
|
|
261
305
|
마치기({ ok: false, 왜: `${Math.round(기다림 / 1000)}초를 기다렸는데 안 끝났습니다`, 보인것: '' });
|
|
262
306
|
}, 기다림);
|
|
263
|
-
const 끊기 = () => {
|
|
307
|
+
const 끊기 = () => { 거두기(); 마치기({ ok: false, 왜: '중단했습니다', 보인것: '' }); };
|
|
264
308
|
const 끊기해제 = () => signal?.removeEventListener?.('abort', 끊기);
|
|
265
309
|
if (signal?.aborted) return 끊기();
|
|
266
310
|
signal?.addEventListener?.('abort', 끊기, { once: true });
|
|
@@ -280,8 +324,8 @@ export async function 한번받기(설정, { 기다림 = 기본기다림, signal
|
|
|
280
324
|
kid.stdout.on('data', (b) => {
|
|
281
325
|
if (끝났나) return;
|
|
282
326
|
if (나온것.length + b.length > 받을상한) {
|
|
283
|
-
|
|
284
|
-
|
|
327
|
+
// 나무째 거두고 파이프를 놓는다 (위 거두기) — 셸 아래 손자가 계속 쓰고 있어도 EPIPE 를 맞고 멈춘다.
|
|
328
|
+
거두기();
|
|
285
329
|
// 먼저 자르고 가린다 — 1MB 짜리 한 줄을 통째로 가리기 에 넣으면 그 무늬들이
|
|
286
330
|
// 몇 분을 돈다(처음 고칠 때 검사가 여기서 멈춰 섰다).
|
|
287
331
|
const 첫줄 = 가리기(나온것.slice(0, 400).trim().split('\n')[0] ?? '', {}).글.slice(0, 120);
|
|
@@ -324,7 +368,8 @@ export async function 한번받기(설정, { 기다림 = 기본기다림, signal
|
|
|
324
368
|
export async function 열쇠(설정, { 다시 = false, 물어보기 = null, signal = null, 알림 = null, 기다림 = 기본기다림 } = {}) {
|
|
325
369
|
if (!설정) return { ok: false, 왜: '열쇠받기가 설정되어 있지 않습니다' };
|
|
326
370
|
|
|
327
|
-
if (다시)
|
|
371
|
+
if (다시) 받은것들.delete(설정.명령);
|
|
372
|
+
const 받은것 = 받은것들.get(설정.명령);
|
|
328
373
|
if (받은것 && 받은것.만료 - 미리 > Date.now()) {
|
|
329
374
|
return { ok: true, token: 받은것.token, headers: 받은것.headers, 만료: 받은것.만료, 그대로: true };
|
|
330
375
|
}
|
|
@@ -356,19 +401,22 @@ export async function 열쇠(설정, { 다시 = false, 물어보기 = null, sign
|
|
|
356
401
|
* 거기서까지 막으면 사람이 승인을 했는데도 열쇠가 안 붙는다.
|
|
357
402
|
*/
|
|
358
403
|
if (설정.곳 !== '정책') {
|
|
359
|
-
|
|
360
|
-
if (
|
|
404
|
+
const 답 = 물어본것들.get(설정.명령);
|
|
405
|
+
if (답 === false) return { ok: false, 왜: '이 판에서는 안 부르기로 했습니다' };
|
|
406
|
+
if (답 === undefined) {
|
|
361
407
|
if (typeof 물어보기 !== 'function') {
|
|
362
408
|
return { ok: false, 왜: '이 명령을 띄워도 되는지 물어볼 자리가 없습니다 — 승인 없이는 안 띄웁니다' };
|
|
363
409
|
}
|
|
364
|
-
|
|
365
|
-
|
|
410
|
+
const 허락 = !!(await 물어보기(설정));
|
|
411
|
+
물어본것들.set(설정.명령, 허락);
|
|
412
|
+
if (!허락) return { ok: false, 왜: '이 판에서는 안 부르기로 했습니다' };
|
|
366
413
|
}
|
|
367
414
|
}
|
|
368
415
|
|
|
369
416
|
const r = await 한번받기(설정, { signal, 알림, 기다림 });
|
|
370
417
|
if (!r.ok) return r;
|
|
371
|
-
|
|
418
|
+
마지막받은 = { token: r.token, headers: r.headers, 만료: r.만료, 언제: Date.now() };
|
|
419
|
+
받은것들.set(설정.명령, 마지막받은);
|
|
372
420
|
return { ok: true, token: r.token, headers: r.headers, 만료: r.만료, ms: r.ms, 그대로: false };
|
|
373
421
|
}
|
|
374
422
|
|
|
@@ -380,7 +428,10 @@ export async function 열쇠(설정, { 다시 = false, 물어보기 = null, sign
|
|
|
380
428
|
*/
|
|
381
429
|
export function 가림(글) {
|
|
382
430
|
const 것들 = [];
|
|
383
|
-
|
|
384
|
-
for (const
|
|
431
|
+
// 들고 있는 것 **전부**를 가린다 — 명령마다 따로 들게 된 뒤로(위 받은것들) 하나만 가리면 다른 연결의 토큰이 샌다.
|
|
432
|
+
for (const 받은것 of 받은것들.values()) {
|
|
433
|
+
if (받은것?.token) 것들.push(받은것.token);
|
|
434
|
+
for (const v of Object.values(받은것?.headers ?? {})) if (v) 것들.push(String(v));
|
|
435
|
+
}
|
|
385
436
|
return 가리기(String(글 ?? ''), { 열쇠들: 것들 }).글;
|
|
386
437
|
}
|
|
@@ -0,0 +1,286 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 셸이 바꾼 파일을 git 으로 알아내 되돌리기에 올린다 (2.1.3).
|
|
3
|
+
*
|
|
4
|
+
* ── 왜 이게 필요한가 ───────────────────────────────────────────────────
|
|
5
|
+
*
|
|
6
|
+
* Bash 의 되돌리기는 **명령줄에 적힌 이름**을 보고 떴다(tools/index.js 의 바꾸기전스냅샷). 셸이 푸는
|
|
7
|
+
* 와일드카드(`rm *.tmp`)와 스크립트 안에서 고치는 것(`npm run format` · `node 고치기.js` ·
|
|
8
|
+
* `npx prettier --write .`)은 이름이 명령줄에 없어 떠 둔 것이 없었고, /undo 뒤에도 그대로였다.
|
|
9
|
+
* 모델이 파일을 제일 많이 바꾸는 길이 바로 그 둘이다.
|
|
10
|
+
*
|
|
11
|
+
* ── 어떻게 ────────────────────────────────────────────────────────────
|
|
12
|
+
*
|
|
13
|
+
* 명령 **앞**에 `git status` 로 지금 고치던 것 · 안 올린 것을 보고, 그 파일들만 사본을 떠 둔다(보통 몇 개다).
|
|
14
|
+
* 명령 **뒤**에 한 번 더 보고 견준다.
|
|
15
|
+
*
|
|
16
|
+
* 앞에 깨끗했던 파일이 바뀌거나 사라졌다 → 앞 모습은 명령 전 HEAD 에 있다 (git 이 들고 있다). git 이 작업 폴더에
|
|
17
|
+
* 꺼내 줄 모습으로 받는다 — `git restore` 와 같다(줄 끝 변환까지 · 헤드꺼내기)
|
|
18
|
+
* 앞에 고치던 · 안 올린 파일이 바뀌었다 → 앞 모습은 떠 둔 사본
|
|
19
|
+
* 앞에 없던 파일이 생겼다 → 「원래 없던 자리」 — 되돌리면 지운다
|
|
20
|
+
*
|
|
21
|
+
* 그렇게 안 것을 되돌리기 이력(safety/undo.js)에 **여느 기록과 같은 모양으로** 적는다. 그러면 /undo 가
|
|
22
|
+
* 밖을 가리키는 기록 · 링크 너머 · 바이너리를 다루는 규칙을 그대로 탄다 — 되돌리는 길을 새로 만들지 않는다.
|
|
23
|
+
*
|
|
24
|
+
* ── 안 하는 것 ────────────────────────────────────────────────────────
|
|
25
|
+
*
|
|
26
|
+
* · .git 에 아무것도 안 쓴다. `git add` 로 나무를 뜨는 길도 있었지만, 그러면 명령마다 남의 저장소에
|
|
27
|
+
* 객체를 쌓는다(큰 파일이면 크게). 읽기만 하는 status · cat-file 만 부르고, status 도
|
|
28
|
+
* `--no-optional-locks` 로 색인을 안 고친다.
|
|
29
|
+
* · 무시되는 파일(.gitignore)은 못 본다 — git 이 안 보는 자리다. 못 본 것을 되돌린다고 말하지 않는다.
|
|
30
|
+
* · git 의 색인(스테이징)과 커밋은 안 되돌린다. 되돌리는 것은 작업 폴더의 파일이다.
|
|
31
|
+
* · 저장소가 아니면 아무것도 안 한다(null). 없는 안전망을 있다고 하지 않는다.
|
|
32
|
+
*/
|
|
33
|
+
import { spawnSync } from 'node:child_process';
|
|
34
|
+
import { randomBytes } from 'node:crypto';
|
|
35
|
+
import { readFileSync, statSync } from 'node:fs';
|
|
36
|
+
import { join, resolve } from 'node:path';
|
|
37
|
+
import { 경로에서찾기 } from './which.js';
|
|
38
|
+
|
|
39
|
+
// 명령 전에 사본을 떠 둘 파일의 상한. 넘친 것은 「못 떴다」 로 말한다.
|
|
40
|
+
const 사본수상한 = 400;
|
|
41
|
+
const 사본한개상한 = 4 * 1024 * 1024;
|
|
42
|
+
const 사본합상한 = 32 * 1024 * 1024;
|
|
43
|
+
// git 이 이보다 오래 걸리는 저장소면 이 판에서는 그만둔다 — 명령마다 사람이 기다린다.
|
|
44
|
+
const 느림문턱 = 3000;
|
|
45
|
+
// 앞 모습을 꺼내는 데는 더 기다린다 — 명령은 이미 돌았고, 여기서 끊기면 그 파일들을 되돌릴 수 없다.
|
|
46
|
+
const 꺼내기문턱 = 10000;
|
|
47
|
+
// 수정 시각이 이만큼 안쪽이면 시각을 못 믿는다 — FAT 은 2초 단위라, 한 틈에 두 번 쓰면 크기 · 시각이 같다.
|
|
48
|
+
const 흐릿한틈 = 3000;
|
|
49
|
+
|
|
50
|
+
/** 이 뿌리에서는 그만뒀다 (느리다). 프로세스가 사는 동안 기억한다. */
|
|
51
|
+
const 그만둔곳 = new Set();
|
|
52
|
+
let 깃자리 = undefined;
|
|
53
|
+
|
|
54
|
+
function 깃찾기() {
|
|
55
|
+
// 작업 폴더 안의 git.exe 를 집지 않게 우리가 PATH 에서 찾는다 (which.js) — 절대 경로 칸만 본다.
|
|
56
|
+
if (깃자리 === undefined) 깃자리 = 경로에서찾기(['git']);
|
|
57
|
+
return 깃자리;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function 깃(뿌리, 인자, { 바이트 = false, 입력 = undefined, 제한 = 느림문턱 } = {}) {
|
|
61
|
+
const 파일 = 깃찾기();
|
|
62
|
+
if (!파일) return null;
|
|
63
|
+
const r = spawnSync(파일, ['--no-optional-locks', '-c', 'core.quotepath=false', ...인자], {
|
|
64
|
+
cwd: 뿌리, windowsHide: true, timeout: 제한, maxBuffer: 256 * 1024 * 1024,
|
|
65
|
+
// 입력은 Buffer 로 — 글로 주면 encoding 을 입력의 인코딩으로도 써서 'buffer' 에서 던진다.
|
|
66
|
+
encoding: 바이트 ? 'buffer' : 'utf8', input: 입력 === undefined ? undefined : Buffer.from(입력, 'utf8'),
|
|
67
|
+
// 사람이 켜 둔 페이저 · 비밀번호 묻기가 끼지 않게.
|
|
68
|
+
env: { ...process.env, GIT_PAGER: 'cat', GIT_TERMINAL_PROMPT: '0', GIT_OPTIONAL_LOCKS: '0' },
|
|
69
|
+
});
|
|
70
|
+
if (r.error || r.status !== 0) return { ok: false, 느림: r.error?.code === 'ETIMEDOUT' || r.signal === 'SIGTERM' };
|
|
71
|
+
return { ok: true, out: r.stdout };
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* `git status --porcelain=v1 -z` 를 읽는다. 열쇠는 저장소 꼭대기 기준 경로(슬래시), 값은 두 글자 상태.
|
|
76
|
+
* 이름 바꾸기(R · C)는 옛 이름도 같이 올린다 — 옛 자리는 사라진 파일이다.
|
|
77
|
+
*/
|
|
78
|
+
function 상태읽기(뿌리) {
|
|
79
|
+
const r = 깃(뿌리, ['status', '--porcelain=v1', '-z', '--untracked-files=all', '--ignore-submodules=all', '--', '.']);
|
|
80
|
+
if (!r?.ok) return r?.느림 ? { 느림: true } : null;
|
|
81
|
+
const 표 = new Map();
|
|
82
|
+
const 칸 = String(r.out).split('\0');
|
|
83
|
+
for (let i = 0; i < 칸.length; i++) {
|
|
84
|
+
const 줄 = 칸[i];
|
|
85
|
+
if (줄.length < 4) continue;
|
|
86
|
+
const 상태 = 줄.slice(0, 2);
|
|
87
|
+
표.set(줄.slice(3), 상태);
|
|
88
|
+
if (상태[0] === 'R' || 상태[0] === 'C') {
|
|
89
|
+
const 옛 = 칸[++i];
|
|
90
|
+
if (옛 && 상태[0] === 'R') 표.set(옛, ' D');
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
return { 표 };
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/*
|
|
97
|
+
* 저장소 꼭대기 기준 경로를 **작업 폴더 기준**으로. 밖이거나 deel 제 살림(.deel)이면 null.
|
|
98
|
+
*
|
|
99
|
+
* 절대 경로끼리 견주지 않는다 — 윈도우에서 git 이 주는 꼭대기(긴 이름 · 슬래시)와 우리 뿌리(짧은 이름 · 대소문자)가
|
|
100
|
+
* 글자로 달라, 안의 파일이 전부 밖으로 읽힐 수 있다. git 이 준 앞머리(`--show-prefix`)를 떼고 뿌리에 붙인다.
|
|
101
|
+
*/
|
|
102
|
+
const 살림꼴 = /(^|\/)\.deel(\/|$)/i;
|
|
103
|
+
function 맡을경로(앞머리, 경로) {
|
|
104
|
+
if (!경로.startsWith(앞머리)) return null;
|
|
105
|
+
const rel = 경로.slice(앞머리.length);
|
|
106
|
+
if (!rel || 살림꼴.test(rel)) return null;
|
|
107
|
+
return rel;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function 모양(abs) {
|
|
111
|
+
try { const s = statSync(abs); return s.isFile() ? { 크기: s.size, 시각: s.mtimeMs } : { 폴더: true }; } catch { return null; }
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/*
|
|
115
|
+
* 명령 전 HEAD 에 있던 모습을 **한꺼번에** 꺼낸다. 돌려주는 표: 경로 → Buffer(있던 모습) · null(HEAD 에 없던 것).
|
|
116
|
+
* 표에 없는 경로는 못 꺼낸 것이다 — 「없던 것」 과 다르다. 없던 것으로 적으면 /undo 가 멀쩡한 파일을 지운다.
|
|
117
|
+
* HEAD 부터 못 읽었으면 null.
|
|
118
|
+
*
|
|
119
|
+
* 파일마다 `git cat-file` 을 띄우면 포매터가 200 파일을 고친 뒤 윈도우에서 8초를 기다렸다. 두 번만 띄운다 —
|
|
120
|
+
* `--batch-check` 로 있나 없나를, `--batch --filters` 로 내용을. `--filters` 는 작업 폴더에 꺼낼 때의 변환
|
|
121
|
+
* (core.autocrlf 의 CRLF · .gitattributes)을 태운다. 안 태우면 윈도우용 git 기본값(autocrlf=true)에서
|
|
122
|
+
* /undo 가 CRLF 파일을 LF 로 되돌렸다.
|
|
123
|
+
*
|
|
124
|
+
* `--filters` 의 머리줄 크기는 변환 **전** 크기라 믿을 수 없다. 그래서 물음마다 뒤에 없는 개체 하나(아무도 못
|
|
125
|
+
* 맞출 제비 번호)를 물어, 그 「missing」 줄을 경계로 쓴다.
|
|
126
|
+
*/
|
|
127
|
+
function 헤드꺼내기(뿌리, 머리, 경로들) {
|
|
128
|
+
// 줄바꿈이 든 이름은 한 줄 물음에 못 싣는다 — 그 자리는 표에 없다(못 꺼냄).
|
|
129
|
+
const 실을것 = 경로들.filter((p) => !/[\r\n]/.test(p));
|
|
130
|
+
const 확인 = 깃(뿌리, ['cat-file', '--batch-check'], { 입력: `${[머리, ...실을것.map((p) => `${머리}:${p}`)].join('\n')}\n`, 제한: 꺼내기문턱 });
|
|
131
|
+
if (!확인?.ok) return null;
|
|
132
|
+
const 줄들 = String(확인.out).split('\n');
|
|
133
|
+
if (!/^[0-9a-f]+ commit \d+$/.test(줄들[0] ?? '')) return null;
|
|
134
|
+
const 표 = new Map();
|
|
135
|
+
const 꺼낼것 = [];
|
|
136
|
+
실을것.forEach((p, i) => {
|
|
137
|
+
const 줄 = 줄들[i + 1] ?? '';
|
|
138
|
+
if (줄.endsWith(' missing')) 표.set(p, null); // HEAD 에 없다 — 명령이 새로 만든 것
|
|
139
|
+
else { const m = /^([0-9a-f]+) blob \d+$/.exec(줄); if (m) 꺼낼것.push([p, m[1]]); }
|
|
140
|
+
});
|
|
141
|
+
if (!꺼낼것.length) return 표;
|
|
142
|
+
const 제비 = randomBytes(20).toString('hex');
|
|
143
|
+
const 내용 = 깃(뿌리, ['cat-file', '--batch', '--filters'], {
|
|
144
|
+
바이트: true, 제한: 꺼내기문턱, 입력: 꺼낼것.map(([p, oid]) => `${oid} ${p}\n${제비} x\n`).join(''),
|
|
145
|
+
});
|
|
146
|
+
if (!내용?.ok) return 표;
|
|
147
|
+
const 몸 = 내용.out;
|
|
148
|
+
const 경계 = Buffer.from(`\n${제비} missing\n`);
|
|
149
|
+
let i = 0;
|
|
150
|
+
for (const [p, oid] of 꺼낼것) {
|
|
151
|
+
const 머리끝 = 몸.indexOf(0x0a, i);
|
|
152
|
+
if (머리끝 < 0 || !몸.subarray(i, 머리끝).toString('latin1').startsWith(`${oid} blob `)) break;
|
|
153
|
+
const 끝 = 몸.indexOf(경계, 머리끝 + 1);
|
|
154
|
+
if (끝 < 0) break;
|
|
155
|
+
표.set(p, Buffer.from(몸.subarray(머리끝 + 1, 끝)));
|
|
156
|
+
i = 끝 + 경계.length;
|
|
157
|
+
}
|
|
158
|
+
return 표;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* 명령 **앞** 모습을 뜬다.
|
|
163
|
+
*
|
|
164
|
+
* @returns {null | {뿌리, 앞머리, 머리, 표: Map, 사본: Map}}
|
|
165
|
+
* null — 저장소가 아니거나, git 이 없거나, 이 저장소에서는 그만뒀다(느려서)
|
|
166
|
+
*/
|
|
167
|
+
export function 깃앞보기(뿌리) {
|
|
168
|
+
try {
|
|
169
|
+
뿌리 = resolve(String(뿌리));
|
|
170
|
+
if (그만둔곳.has(뿌리) || !깃찾기()) return null;
|
|
171
|
+
const 시작 = Date.now();
|
|
172
|
+
const 뜬때 = 시작;
|
|
173
|
+
// 앞머리와 HEAD 를 한 번에 묻는다 — 명령마다 도는 자리라 git 을 한 번 덜 띄운다. 커밋이 아직 없으면 HEAD 가
|
|
174
|
+
// 없어 실패하니, 그때만 앞머리를 따로 묻는다.
|
|
175
|
+
let 앞머리;
|
|
176
|
+
let 머리 = null;
|
|
177
|
+
const 둘 = 깃(뿌리, ['rev-parse', '--show-prefix', 'HEAD']);
|
|
178
|
+
if (둘?.ok) {
|
|
179
|
+
const 줄들 = String(둘.out).split('\n');
|
|
180
|
+
앞머리 = 줄들[0].trim();
|
|
181
|
+
머리 = (줄들[1] ?? '').trim() || null;
|
|
182
|
+
} else {
|
|
183
|
+
const 꼭 = 깃(뿌리, ['rev-parse', '--show-prefix']);
|
|
184
|
+
if (!꼭?.ok) return null;
|
|
185
|
+
앞머리 = String(꼭.out).trim();
|
|
186
|
+
}
|
|
187
|
+
const 상태 = 상태읽기(뿌리);
|
|
188
|
+
if (!상태 || 상태.느림 || Date.now() - 시작 > 느림문턱) {
|
|
189
|
+
if (상태?.느림 || Date.now() - 시작 > 느림문턱) 그만둔곳.add(뿌리);
|
|
190
|
+
return null;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
// 고치던 것 · 안 올린 것의 사본. 이 파일들의 앞 모습은 git 에 없다.
|
|
194
|
+
const 사본 = new Map();
|
|
195
|
+
let 합 = 0;
|
|
196
|
+
for (const [경로] of 상태.표) {
|
|
197
|
+
const rel = 맡을경로(앞머리, 경로);
|
|
198
|
+
if (rel === null) continue;
|
|
199
|
+
const 꼴 = 모양(join(뿌리, rel));
|
|
200
|
+
if (!꼴) { 사본.set(경로, { 없음: true }); continue; }
|
|
201
|
+
if (꼴.폴더) continue;
|
|
202
|
+
if (사본.size >= 사본수상한 || 꼴.크기 > 사본한개상한 || 합 + 꼴.크기 > 사본합상한) {
|
|
203
|
+
사본.set(경로, { ...꼴, 못뜸: '사본 상한' });
|
|
204
|
+
continue;
|
|
205
|
+
}
|
|
206
|
+
try {
|
|
207
|
+
const buf = readFileSync(join(뿌리, rel));
|
|
208
|
+
합 += buf.length;
|
|
209
|
+
사본.set(경로, { ...꼴, buf });
|
|
210
|
+
} catch (err) { 사본.set(경로, { ...꼴, 못뜸: err?.code ?? '못 읽음' }); }
|
|
211
|
+
}
|
|
212
|
+
return { 뿌리, 앞머리, 머리, 표: 상태.표, 사본, 뜬때 };
|
|
213
|
+
} catch {
|
|
214
|
+
return null; // 뜨다 터져서 명령이 못 도는 일은 없어야 한다
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/**
|
|
219
|
+
* 명령 **뒤**에 견주고, 바뀐 것을 되돌리기 이력에 적는다.
|
|
220
|
+
*
|
|
221
|
+
* 이번 턴에 이미 뜬 파일은 안 적는다(History.떴나) — 그 기록이 턴 처음 모습이다. 낱말 보고 뜬 것과 겹쳐도 한 번이다.
|
|
222
|
+
*
|
|
223
|
+
* @returns {{뜬것: string[], 못뜬것: {abs: string, 왜: string}[]}} 뜬것 은 절대 경로
|
|
224
|
+
*/
|
|
225
|
+
export function 깃뒤적기(앞, { history } = {}) {
|
|
226
|
+
const 뜬것 = [];
|
|
227
|
+
const 못뜬것 = [];
|
|
228
|
+
if (!앞 || typeof history?.뒤늦은기록 !== 'function') return { 뜬것, 못뜬것 };
|
|
229
|
+
try {
|
|
230
|
+
const 뒤 = 상태읽기(앞.뿌리);
|
|
231
|
+
if (!뒤 || 뒤.느림) return { 뜬것, 못뜬것 };
|
|
232
|
+
const 할것 = []; // [경로, abs, 지금, 앞모습] — 앞모습 이 '헤드' 면 아래에서 한꺼번에 꺼낸다
|
|
233
|
+
for (const 경로 of new Set([...앞.표.keys(), ...뒤.표.keys()])) {
|
|
234
|
+
const rel = 맡을경로(앞.앞머리, 경로);
|
|
235
|
+
if (rel === null) continue;
|
|
236
|
+
const abs = join(앞.뿌리, rel);
|
|
237
|
+
// 이번 턴에 이미 뜬 자리는 History.뒤늦은기록 이 거른다 — 그 기록이 턴 처음 모습이다.
|
|
238
|
+
const 지금 = 모양(abs);
|
|
239
|
+
if (지금?.폴더) continue;
|
|
240
|
+
const 떠둔것 = 앞.사본.get(경로);
|
|
241
|
+
|
|
242
|
+
let 앞모습; // Buffer · null(없던 자리) · undefined(안 바뀜) · false(바뀌었는데 앞 모습이 없다)
|
|
243
|
+
if (앞.표.has(경로)) {
|
|
244
|
+
// 명령 전부터 고치던 · 안 올린 · 지워 둔 파일. 떠 둔 것과 지금이 같으면 안 바뀐 것이다.
|
|
245
|
+
if (!떠둔것) continue; // 폴더였거나 안 맡는 자리
|
|
246
|
+
// 크기 · 시각이 같아도 그 시각이 뜬 때와 가까우면 믿지 않는다(흐릿한틈) — 한 틈에 두 번 쓴 것일 수 있다.
|
|
247
|
+
const 시각믿음 = 떠둔것.시각 < (앞.뜬때 ?? 0) - 흐릿한틈;
|
|
248
|
+
if (떠둔것.없음) 앞모습 = 지금 ? null : undefined;
|
|
249
|
+
else if (!지금) 앞모습 = 떠둔것.buf ?? false;
|
|
250
|
+
else if (지금.크기 === 떠둔것.크기 && 지금.시각 === 떠둔것.시각 && (시각믿음 || !떠둔것.buf)) 앞모습 = undefined;
|
|
251
|
+
else if (떠둔것.buf) {
|
|
252
|
+
let 지금것 = null;
|
|
253
|
+
try { 지금것 = readFileSync(abs); } catch { /* 못 읽으면 바뀐 것으로 본다 */ }
|
|
254
|
+
앞모습 = 지금것 && 지금것.equals(떠둔것.buf) ? undefined : 떠둔것.buf;
|
|
255
|
+
} else 앞모습 = false;
|
|
256
|
+
if (앞모습 === false) { 못뜬것.push({ abs, 왜: 떠둔것.못뜸 ?? '사본 없음' }); continue; }
|
|
257
|
+
} else {
|
|
258
|
+
// 명령 전에 깨끗했던 자리 — 앞 모습은 명령 전 HEAD 에 있다. 상태 글자(??)로 「새 파일」 을 가르지 않는다 —
|
|
259
|
+
// `git rm --cached` 로 추적만 뗀 파일도 ?? 로 나온다. HEAD 에 물어 가른다. 커밋이 아직 없으면 없던 자리다.
|
|
260
|
+
앞모습 = 앞.머리 ? '헤드' : null;
|
|
261
|
+
}
|
|
262
|
+
if (앞모습 === undefined) continue;
|
|
263
|
+
할것.push([경로, abs, 지금, 앞모습]);
|
|
264
|
+
}
|
|
265
|
+
const 헤드볼것 = 할것.filter((x) => x[3] === '헤드').map((x) => x[0]);
|
|
266
|
+
// 꺼내다 터져도 떠 둔 사본으로 적을 것은 적는다 — 꺼내지 못한 것만 「못 떴다」 로.
|
|
267
|
+
let 헤드 = null;
|
|
268
|
+
try { if (헤드볼것.length) 헤드 = 헤드꺼내기(앞.뿌리, 앞.머리, 헤드볼것); } catch { 헤드 = null; }
|
|
269
|
+
for (const [경로, abs, 지금, 앞것] of 할것) {
|
|
270
|
+
let 앞모습 = 앞것;
|
|
271
|
+
if (앞모습 === '헤드') {
|
|
272
|
+
앞모습 = 헤드?.get(경로);
|
|
273
|
+
if (앞모습 === undefined) { 못뜬것.push({ abs, 왜: 'git 에서 앞 모습을 못 꺼냈습니다' }); continue; }
|
|
274
|
+
}
|
|
275
|
+
if (앞모습 === null && !지금) continue; // 생겼다 사라졌다 — 되돌릴 것이 없다
|
|
276
|
+
const rec = history.뒤늦은기록(abs, 앞모습, 'Bash');
|
|
277
|
+
if (!rec) continue;
|
|
278
|
+
if (rec.skipped) 못뜬것.push({ abs, 왜: rec.skipped });
|
|
279
|
+
else 뜬것.push(abs);
|
|
280
|
+
}
|
|
281
|
+
} catch { /* 적다 터져도 명령은 이미 돌았다 — 적은 데까지만 말한다 */ }
|
|
282
|
+
return { 뜬것, 못뜬것 };
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
/** 검사가 원래대로 돌려놓을 때. */
|
|
286
|
+
export function 깃뜨기잊기() { 그만둔곳.clear(); 깃자리 = undefined; }
|
package/src/safety/undo.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
// 되돌리기. 승인 프롬프트를 안 쓰는 대신 이게 안전망이다.
|
|
2
2
|
// 파일을 고치기 전에 항상 이전 내용을 떠 놓고, /undo 로 턴 단위로 되돌린다.
|
|
3
3
|
import { join, dirname, basename, resolve, relative, isAbsolute, sep } from 'node:path';
|
|
4
|
-
import { readFileSync, writeFileSync, existsSync, mkdirSync, rmSync, rmdirSync, appendFileSync, statSync, renameSync, realpathSync } from 'node:fs';
|
|
4
|
+
import { readFileSync, writeFileSync, existsSync, mkdirSync, rmSync, rmdirSync, appendFileSync, statSync, renameSync, realpathSync, openSync, readSync, closeSync } from 'node:fs';
|
|
5
5
|
import { 줄로끝나나, 본인만잠그기 } from '../jsonl.js';
|
|
6
6
|
import { looksBinary } from '../tools/encoding.js';
|
|
7
7
|
import { 살림폴더만들기 } from './audit.js';
|
|
@@ -327,6 +327,31 @@ export class History {
|
|
|
327
327
|
return rec;
|
|
328
328
|
}
|
|
329
329
|
|
|
330
|
+
/**
|
|
331
|
+
* 명령이 **이미 바꾼** 파일의 앞 모습을 적는다 (2.1.3 · safety/gitsnap.js).
|
|
332
|
+
*
|
|
333
|
+
* 앞 모습은 부르는 쪽이 준다 — git 이 들고 있던 것이나 명령 전에 떠 둔 사본. `앞` 이 Buffer 면 그 내용,
|
|
334
|
+
* null 이면 「원래 없던 자리」(되돌리면 지운다). 바이너리는 snapshot 과 같이 못 뜬 것으로 적고 크기를 남긴다.
|
|
335
|
+
* 이번 턴에 이미 뜬 자리면 안 적는다 — 그 기록이 턴 처음 모습이다.
|
|
336
|
+
*/
|
|
337
|
+
뒤늦은기록(absPath, 앞, label) {
|
|
338
|
+
if (this.떴나(absPath)) return null; // 이번 턴 처음 모습이 이미 있다
|
|
339
|
+
if (앞 === null) return this.없던자리기록(absPath, label);
|
|
340
|
+
const 뜬것 = 뜨기(Buffer.from(앞));
|
|
341
|
+
const rec = { turn: this.turn, at: new Date().toISOString(), path: absPath, before: 뜬것.before, label };
|
|
342
|
+
const 상대 = this.#상대(absPath);
|
|
343
|
+
if (상대 !== null) rec.rel = 상대;
|
|
344
|
+
if (뜬것.enc) rec.enc = 뜬것.enc;
|
|
345
|
+
if (뜬것.skipped) { rec.skipped = 뜬것.skipped; rec.크기 = 앞.length; }
|
|
346
|
+
this.#덧붙이기(rec);
|
|
347
|
+
this.#잠그기();
|
|
348
|
+
this.#이번턴.set(absPath, rec);
|
|
349
|
+
const 낮춘 = absPath.toLowerCase();
|
|
350
|
+
this.#낮춘이름.set(낮춘, [...(this.#낮춘이름.get(낮춘) ?? []), absPath]);
|
|
351
|
+
this.#maybePrune();
|
|
352
|
+
return rec;
|
|
353
|
+
}
|
|
354
|
+
|
|
330
355
|
/*
|
|
331
356
|
* ── 떠 놓고 **못 쓴** 기록을 거둔다 ───────────────────────────────────
|
|
332
357
|
*
|
|
@@ -357,7 +382,7 @@ export class History {
|
|
|
357
382
|
if (남길것.length !== recs.length) {
|
|
358
383
|
try {
|
|
359
384
|
writeFileSync(this.file, 남길것.map(적을줄).join('\n') + (남길것.length ? '\n' : ''), 'utf8');
|
|
360
|
-
this.#잠갔나 = false; this.#잠그기();
|
|
385
|
+
this.#잠갔나 = false; this.#잠그기(); this.#턴표 = null;
|
|
361
386
|
} catch { return false; }
|
|
362
387
|
}
|
|
363
388
|
if (this.#이번턴.get(rec.path) === rec) {
|
|
@@ -473,7 +498,7 @@ export class History {
|
|
|
473
498
|
try {
|
|
474
499
|
writeFileSync(this.file, 남길것.map(적을줄).join('\n') + (남길것.length ? '\n' : ''), 'utf8');
|
|
475
500
|
// 통째로 다시 쓰면 새 파일일 수 있다 — 빗장을 다시 건다 (agent/store.js 도 같다).
|
|
476
|
-
this.#잠갔나 = false; this.#잠그기();
|
|
501
|
+
this.#잠갔나 = false; this.#잠그기(); this.#턴표 = null;
|
|
477
502
|
this.줄이기못함 = null;
|
|
478
503
|
} catch (err) {
|
|
479
504
|
/*
|
|
@@ -540,10 +565,80 @@ export class History {
|
|
|
540
565
|
return 것들;
|
|
541
566
|
}
|
|
542
567
|
|
|
568
|
+
/*
|
|
569
|
+
* ── 턴 수는 덧붙인 만큼만 읽는다 (2.1.2) ────────────────────────────────
|
|
570
|
+
*
|
|
571
|
+
* 대화 화면은 턴이 끝날 때마다 여기로 되돌릴 턴 수를 센다(repl.js). 여태 all() 을 불러 이력
|
|
572
|
+
* 파일을 **매번 처음부터** 읽고 줄마다 JSON.parse 했다 — 이 파일은 고치기 전 원문을 통째로
|
|
573
|
+
* 담아 32MB 까지 자란다. 이력은 거의 늘 덧붙이기라, 지난번에 센 자리 뒤만 읽으면 된다.
|
|
574
|
+
*
|
|
575
|
+
* 센 자리는 **온전한 줄의 끝**(개행 뒤)에만 둔다. 뒤에 개행 없는 반쪽 줄이 있으면 적는 중일 수
|
|
576
|
+
* 있으니 이번 셈에만 넣고 자리는 안 옮긴다. 다른 쪽(다른 탭·다른 deel)이 파일을 통째로 다시
|
|
577
|
+
* 썼으면 처음부터 다시 센다 — 줄었거나, 크기가 같은데 시각이 바뀌었거나, 센 자리 앞뒤 조각이
|
|
578
|
+
* 달라졌으면 다시 쓴 것으로 본다. 이 이력이 제 손으로 다시 쓰는 자리(prune·undo·버리기)는
|
|
579
|
+
* 표를 버린다.
|
|
580
|
+
*/
|
|
581
|
+
#턴표 = null;
|
|
582
|
+
|
|
583
|
+
/** [시작, 끝) 바이트를 읽는다. 끝이 파일보다 크면 있는 만큼. */
|
|
584
|
+
#바이트(시작, 끝) {
|
|
585
|
+
const 길이 = Math.max(0, 끝 - 시작);
|
|
586
|
+
const buf = Buffer.alloc(길이);
|
|
587
|
+
if (!길이) return buf;
|
|
588
|
+
const fd = openSync(this.file, 'r');
|
|
589
|
+
try {
|
|
590
|
+
let 읽음 = 0;
|
|
591
|
+
while (읽음 < 길이) {
|
|
592
|
+
const n = readSync(fd, buf, 읽음, 길이 - 읽음, 시작 + 읽음);
|
|
593
|
+
if (!n) break;
|
|
594
|
+
읽음 += n;
|
|
595
|
+
}
|
|
596
|
+
return buf.subarray(0, 읽음);
|
|
597
|
+
} finally { closeSync(fd); }
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
/** 한 줄을 표에 센다. all() 과 같은 자로 — 깨졌거나 기록 꼴이 아니면 깨진 줄이다. */
|
|
601
|
+
static #줄셈(표, 줄) {
|
|
602
|
+
if (!줄) return;
|
|
603
|
+
let r;
|
|
604
|
+
try { r = JSON.parse(줄); } catch { 표.깨진 += 1; return; }
|
|
605
|
+
if (!r || typeof r !== 'object' || Array.isArray(r)) { 표.깨진 += 1; return; }
|
|
606
|
+
if (!표.본턴.has(r.turn)) { 표.본턴.add(r.turn); 표.턴들.push(r.turn); }
|
|
607
|
+
}
|
|
608
|
+
|
|
543
609
|
turns() {
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
610
|
+
let 판;
|
|
611
|
+
try { 판 = statSync(this.file); } catch { this.#턴표 = null; this.깨진줄 = 0; return []; }
|
|
612
|
+
const 조각 = 64;
|
|
613
|
+
let 표 = this.#턴표;
|
|
614
|
+
const 다시썼나 = !표
|
|
615
|
+
|| 판.size < 표.크기
|
|
616
|
+
|| (판.size === 표.크기 && 판.mtimeMs !== 표.시각)
|
|
617
|
+
|| (표.크기 > 0 && !this.#바이트(0, Math.min(조각, 표.크기)).equals(표.머리))
|
|
618
|
+
|| (표.크기 > 0 && !this.#바이트(Math.max(0, 표.크기 - 조각), 표.크기).equals(표.끝));
|
|
619
|
+
if (다시썼나) 표 = { 크기: 0, 시각: 0, 머리: Buffer.alloc(0), 끝: Buffer.alloc(0), 턴들: [], 본턴: new Set(), 깨진: 0 };
|
|
620
|
+
|
|
621
|
+
// 센 자리 뒤를 읽는다. 온전한 줄까지만 표에 넣고, 반쪽 줄은 이번 셈에만.
|
|
622
|
+
const 새것 = 판.size > 표.크기 ? this.#바이트(표.크기, 판.size) : Buffer.alloc(0);
|
|
623
|
+
const 마지막개행 = 새것.lastIndexOf(0x0a);
|
|
624
|
+
if (마지막개행 >= 0) {
|
|
625
|
+
for (const 줄 of 새것.subarray(0, 마지막개행 + 1).toString('utf8').split('\n')) History.#줄셈(표, 줄);
|
|
626
|
+
표.크기 += 마지막개행 + 1;
|
|
627
|
+
표.머리 = this.#바이트(0, Math.min(조각, 표.크기));
|
|
628
|
+
표.끝 = this.#바이트(Math.max(0, 표.크기 - 조각), 표.크기);
|
|
629
|
+
}
|
|
630
|
+
표.시각 = 판.mtimeMs;
|
|
631
|
+
this.#턴표 = 표;
|
|
632
|
+
|
|
633
|
+
const 꼬리 = 마지막개행 >= 0 ? 새것.subarray(마지막개행 + 1) : 새것;
|
|
634
|
+
if (!꼬리.length) {
|
|
635
|
+
this.깨진줄 = 표.깨진;
|
|
636
|
+
return [...표.턴들];
|
|
637
|
+
}
|
|
638
|
+
const 이번 = { 턴들: [...표.턴들], 본턴: new Set(표.본턴), 깨진: 표.깨진 };
|
|
639
|
+
History.#줄셈(이번, 꼬리.toString('utf8'));
|
|
640
|
+
this.깨진줄 = 이번.깨진;
|
|
641
|
+
return 이번.턴들;
|
|
547
642
|
}
|
|
548
643
|
|
|
549
644
|
/**
|
|
@@ -723,7 +818,7 @@ export class History {
|
|
|
723
818
|
let 이력줄임 = { ok: true };
|
|
724
819
|
try {
|
|
725
820
|
writeFileSync(this.file, keep.map(적을줄).join('\n') + (keep.length ? '\n' : ''), 'utf8');
|
|
726
|
-
this.#잠갔나 = false; this.#잠그기();
|
|
821
|
+
this.#잠갔나 = false; this.#잠그기(); this.#턴표 = null;
|
|
727
822
|
} catch (err) {
|
|
728
823
|
이력줄임 = { ok: false, 왜: err?.message ?? String(err) };
|
|
729
824
|
}
|
|
@@ -771,7 +866,11 @@ function 같은물건(p, q) {
|
|
|
771
866
|
}
|
|
772
867
|
|
|
773
868
|
function safeRead(p) {
|
|
774
|
-
|
|
869
|
+
return 뜨기(readFileSync(p));
|
|
870
|
+
}
|
|
871
|
+
|
|
872
|
+
/** 바이트를 이력에 적을 모양으로 (safeRead 와 gitsnap 의 앞 모습이 같이 쓴다). */
|
|
873
|
+
function 뜨기(buf) {
|
|
775
874
|
// 바이너리는 내용을 담지 않는다. '없던 파일' 과는 다른 값으로 알린다 —
|
|
776
875
|
// 이 둘을 같은 null 로 뭉쳐 놨던 것이 /undo 가 파일을 지우던 원인이다.
|
|
777
876
|
if (looksBinary(buf)) return { before: null, skipped: '바이너리' };
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
// 실행 파일을 PATH 에서 **우리가** 찾는다 — 셸이나 운영체제가 찾게 두지 않는다.
|
|
2
|
+
//
|
|
3
|
+
// tools/verify.js 에서 떼어 냈다 (2.1.3). 되돌리기의 git 뜨기(safety/gitsnap.js)도 같은 자로 git 을 찾는다 —
|
|
4
|
+
// 두 벌이면 언젠가 한 벌만 고친다. 여기는 fs 와 path 만 부른다(되돌리기는 설정이 깨져도 돌아야 하는 자리다).
|
|
5
|
+
|
|
6
|
+
import { statSync } from 'node:fs';
|
|
7
|
+
import { resolve, join, delimiter, isAbsolute, relative } from 'node:path';
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* 이름으로 실행 파일을 PATH 에서 찾는다.
|
|
11
|
+
*
|
|
12
|
+
* 지금 폴더를 뜻하는 칸(빈 칸·`.`·상대 경로)과 작업 폴더 안의 칸은 건너뛴다. 그 자리의
|
|
13
|
+
* 파일은 저장소가 심을 수 있다. 윈도우에서는 `.exe` 만 본다 — `.cmd`·`.bat` 은 결국
|
|
14
|
+
* cmd.exe 를 부르는 것이라 셸을 안 거친다는 뜻이 없어진다.
|
|
15
|
+
*
|
|
16
|
+
* @returns {string|null} 절대 경로. 못 찾으면 null — 그러면 「도구가 없다」 로 말한다.
|
|
17
|
+
*/
|
|
18
|
+
export function 경로에서찾기(이름들, { env = process.env, 뿌리 = null } = {}) {
|
|
19
|
+
const 윈 = process.platform === 'win32';
|
|
20
|
+
// 윈도우 환경 이름은 대소문자를 안 가린다(Path · PATH). 거른 환경 객체는 평범한 객체라 직접 찾는다.
|
|
21
|
+
const 칸 = Object.keys(env ?? {}).find((k) => (윈 ? k.toUpperCase() === 'PATH' : k === 'PATH'));
|
|
22
|
+
const 목록 = String((칸 && env[칸]) ?? '').split(delimiter);
|
|
23
|
+
const 뿌리abs = 뿌리 ? resolve(뿌리) : null;
|
|
24
|
+
for (const 이름 of 이름들) {
|
|
25
|
+
for (const 날것 of 목록) {
|
|
26
|
+
const 폴더 = 날것.trim().replace(/^"(.*)"$/, '$1');
|
|
27
|
+
if (!폴더 || !isAbsolute(폴더)) continue;
|
|
28
|
+
if (뿌리abs) {
|
|
29
|
+
const rel = relative(뿌리abs, resolve(폴더));
|
|
30
|
+
if (rel === '' || (!rel.startsWith('..') && !isAbsolute(rel))) continue;
|
|
31
|
+
}
|
|
32
|
+
const 후보 = join(폴더, 윈 ? `${이름}.exe` : 이름);
|
|
33
|
+
try { if (statSync(후보).isFile()) return 후보; } catch { /* 여기엔 없다 */ }
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
return null;
|
|
37
|
+
}
|
package/src/setup.js
CHANGED
|
@@ -16,11 +16,9 @@ import { 바깥인가, 봉인됐나 } from './safety/runmode.js';
|
|
|
16
16
|
import { 제공자들, 제공자고르기, 어디것일까, 주소후보, 막힌까닭, 열쇠다듬기 } from './providers/index.js';
|
|
17
17
|
import { writeFileSync } from 'node:fs';
|
|
18
18
|
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
say('');
|
|
23
|
-
}
|
|
19
|
+
// 머리 한 줄은 ui/banner.js 에 있다 — 진입점이 이 파일(40개를 끌고 온다) 없이 쓰게 (2.1.2).
|
|
20
|
+
export { banner } from './ui/banner.js';
|
|
21
|
+
import { banner } from './ui/banner.js';
|
|
24
22
|
|
|
25
23
|
/**
|
|
26
24
|
* 사람이 친 주소가 주소 꼴인가. 틀렸으면 까닭(사람 말), 멀쩡하면 null.
|