deel-local-cli 2.1.2 → 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.
@@ -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; }
@@ -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
  *
@@ -841,7 +866,11 @@ function 같은물건(p, q) {
841
866
  }
842
867
 
843
868
  function safeRead(p) {
844
- const buf = readFileSync(p);
869
+ return 뜨기(readFileSync(p));
870
+ }
871
+
872
+ /** 바이트를 이력에 적을 모양으로 (safeRead 와 gitsnap 의 앞 모습이 같이 쓴다). */
873
+ function 뜨기(buf) {
845
874
  // 바이너리는 내용을 담지 않는다. '없던 파일' 과는 다른 값으로 알린다 —
846
875
  // 이 둘을 같은 null 로 뭉쳐 놨던 것이 /undo 가 파일을 지우던 원인이다.
847
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/slash.js ADDED
@@ -0,0 +1,103 @@
1
+ /**
2
+ * 슬래시 명령을 이름으로 찾는 규칙 — 대화 화면(commands.js)과 `deel run`(oneshot.js)이 같이 쓴다.
3
+ *
4
+ * 여기가 commands.js 안에 있었다. 그래서 `deel run` 은 이 셋을 쓰려고 대화 화면의 명령 전부
5
+ * (화면 · 설정 · 플러그인 · 스캔 …)를 읽었다. 규칙은 한 벌로 두되, 가벼운 자리로 뺀다 (2.1.3).
6
+ * commands.js 는 여기서 받아 그대로 내보낸다 — 부르던 자리는 안 바뀐다.
7
+ */
8
+ /** `ext:ReviewCode` 의 꼬리 — 언제나 낮춰서 돌려준다. 찾기와 「비슷한 것」 이 같은 잣대를 쓰게 한다. */
9
+ const 꼬리이름 = (이름) => String(이름 ?? '').split(':').pop().toLowerCase();
10
+
11
+ /*
12
+ * ── 이 PC 에서 찾은 슬래시 명령을 이름으로 찾는다 ───────────────────────
13
+ *
14
+ * 대화 화면(commands.js 의 default 갈래)과 배치(oneshot.js)가 **같은 규칙**을 써야 한다. 같은 글자를
15
+ * 쳤는데 창에 따라 되고 안 되면 사람은 어느 쪽을 믿을지 정할 수 없다. 그래서 두 벌로 적지
16
+ * 않고 여기 한 군데에 둔다.
17
+ *
18
+ * 찾는 차례는 셋이다 — 적은 그대로 · 대소문자 무시 · 꼬리 이름.
19
+ * 마지막 칸이 `x.name.split(':').pop() === 낮춘` 이었다 (8회차 그밖 명령2). 왼쪽은 파일에
20
+ * 적힌 그대로고 오른쪽은 낮춘 말이라 둘이 만날 수가 없다. `ext:ReviewCode` 는 `/ReviewCode`
21
+ * 로도 `/reviewcode` 로도 「모르는 명령」 이었고, 「비슷한 것」 에도 안 떴다 — 깔려 있는
22
+ * 명령이 어느 길로도 안 보이는 자리다. 배치에서는 그대로 1 로 선다.
23
+ */
24
+ export function 슬래시명령찾기(목록, 부른이름) {
25
+ const 것들 = 목록 ?? [];
26
+ const 낮춘 = String(부른이름 ?? '').toLowerCase();
27
+ return 것들.find((x) => x.name === 부른이름)
28
+ ?? 것들.find((x) => x.name.toLowerCase() === 낮춘)
29
+ ?? 것들.find((x) => 꼬리이름(x.name) === 낮춘)
30
+ ?? null;
31
+ }
32
+
33
+ /** 못 찾았을 때 「비슷한 것」 으로 늘어놓을 것. 찾기와 같은 잣대(낮춘 꼬리)를 쓴다. */
34
+ export function 비슷한슬래시명령(목록, 부른이름, 몇 = 5) {
35
+ const 낮춘 = String(부른이름 ?? '').toLowerCase();
36
+ if (!낮춘) return [];
37
+ return (목록 ?? [])
38
+ .filter((x) => x.name.toLowerCase().includes(낮춘) || 낮춘.includes(꼬리이름(x.name)))
39
+ .slice(0, 몇);
40
+ }
41
+
42
+ /*
43
+ * 프롬프트 인자를 가른다. 빈칸으로 가르되 따옴표("…" · '…')로 묶은 것은 한 인자다 — 파일 이름에 빈칸이
44
+ * 든다. 마지막 인자는 남은 글을 **그대로** 받는다 — 여러 줄 코드를 넘기면 줄바꿈 · 들여쓰기가 살아야 한다.
45
+ * 남은 것이 통째로 한 따옴표 묶음이면 따옴표만 벗긴다.
46
+ */
47
+ function 인자가르기(글, 몇) {
48
+ const s = String(글 ?? '');
49
+ const 값들 = [];
50
+ let i = 0;
51
+ for (let k = 0; k < 몇; k++) {
52
+ while (i < s.length && /\s/.test(s[i])) i++;
53
+ if (i >= s.length) break;
54
+ if (k === 몇 - 1) {
55
+ const 남은 = s.slice(i).trimEnd();
56
+ const 묶음 = /^(["'])((?:(?!\1)[\s\S])*)\1$/.exec(남은);
57
+ 값들.push(묶음 ? 묶음[2] : 남은);
58
+ break;
59
+ }
60
+ const 끝 = s[i] === '"' || s[i] === "'" ? s.indexOf(s[i], i + 1) : -1;
61
+ if (끝 > i) { 값들.push(s.slice(i + 1, 끝)); i = 끝 + 1; continue; }
62
+ const 시작 = i;
63
+ while (i < s.length && !/\s/.test(s[i])) i++;
64
+ 값들.push(s.slice(시작, i));
65
+ }
66
+ return 값들;
67
+ }
68
+
69
+ /**
70
+ * `/mcp__<서버>__<이름> 인자…` — MCP 서버가 낸 프롬프트를 받아 글로 편다 (2.1.3 · backend/mcp.js 의 프롬프트받기).
71
+ *
72
+ * 대화 화면과 `deel run` 이 같은 것을 쓴다 — 슬래시명령찾기 를 한 벌로 둔 것과 같은 까닭이다.
73
+ * 인자는 빈칸으로 갈라 적힌 차례대로 채우고, 마지막 인자가 나머지를 다 받는다(한 문장을 통째로 넘기게 ·
74
+ * 인자가르기).
75
+ *
76
+ * @returns null — 그런 프롬프트가 없다(부르는 쪽은 여느 슬래시 명령을 찾는다)
77
+ * { 탈 } — 있는데 못 폈다 · { text, 서버이름, 이름 } — 모델에게 보낼 글
78
+ */
79
+ export async function MCP프롬프트펴기(서버들, 부른이름, 인자글 = '', { signal = null } = {}) {
80
+ const { 이름풀기 } = await import('./backend/mcp.js');
81
+ const 갈린것 = 이름풀기(부른이름);
82
+ if (!갈린것) return null;
83
+ const 서버 = (서버들 ?? []).find((s) => s?.이름 === 갈린것.서버);
84
+ const 프롬프트 = (서버?.프롬프트 ?? []).find((p) => p?.name === 갈린것.도구);
85
+ if (!프롬프트) return null;
86
+ const 인자이름들 = (프롬프트.arguments ?? []).map((a) => a.name);
87
+ const 값들 = 인자가르기(인자글, 인자이름들.length);
88
+ const 인자 = {};
89
+ 인자이름들.forEach((n, i) => { if (값들[i]) 인자[n] = 값들[i]; });
90
+ // hasOwn — 인자 이름이 constructor · toString 이면 `in` 은 Object 원형에서 찾아 「있다」 고 한다.
91
+ const 빠진것 = (프롬프트.arguments ?? []).filter((a) => a.required && !Object.hasOwn(인자, a.name)).map((a) => a.name);
92
+ if (빠진것.length) {
93
+ return { 탈: `인자가 모자랍니다 — /${부른이름} ${인자이름들.map((n) => `<${n}>`).join(' ')} (빠진 것: ${빠진것.join(' · ')})` };
94
+ }
95
+ if (!서버.쓸수있나()) return { 탈: `${서버.이름} 서버가 죽었습니다: ${서버.죽음 ?? '이유 모름'}` };
96
+ try {
97
+ const text = await 서버.프롬프트받기(프롬프트.name, 인자, { signal });
98
+ if (!text.trim()) return { 탈: `${서버.이름} 서버가 빈 프롬프트를 줬습니다` };
99
+ return { text, 서버이름: 서버.이름, 이름: 프롬프트.name };
100
+ } catch (e) {
101
+ return { 탈: `프롬프트를 못 받았습니다 — ${String(e?.message ?? e)}` };
102
+ }
103
+ }
@@ -0,0 +1,107 @@
1
+ /**
2
+ * 파일끼리 누가 누구를 불러 쓰나 — Outline 이 **많이 불리는 파일부터** 싣게 센다 (2.1.3).
3
+ *
4
+ * ── 왜 이게 필요한가 ───────────────────────────────────────────────────
5
+ *
6
+ * Outline 은 폴더의 뼈대를 최근에 손댄 순서로 싣고 창에 맞춰 뒤를 자른다. 그래서 오래 안 건드린 핵심 모듈 —
7
+ * 파일 수십 개가 불러 쓰는 것 — 이 큰 저장소에서 제일 먼저 잘렸다. 모델은 그 모듈이 있는 줄 모르고, 이미
8
+ * 있는 것을 새로 만든다.
9
+ *
10
+ * ── 무엇을 세나 ────────────────────────────────────────────────────────
11
+ *
12
+ * js · ts `import … from './x'` · `import './x'` · `import('./x')` · `require('./x')` · `export … from './x'`
13
+ * — 점으로 시작하는 것만. 꾸러미 이름(`react`)은 이 폴더의 파일이 아니다.
14
+ * 확장자를 빼고 적은 것 · 폴더(index) · `.js` 로 적고 `.ts` 인 것까지 짚는다.
15
+ * py `from a.b import c` · `from .b import c` · `import a.b` — 모듈 파일과 `__init__.py`.
16
+ *
17
+ * 한 파일이 같은 곳을 여러 번 불러도 한 번이다. 정규식이라 문자열 속 `require('x')` 를 셀 수도 있다 —
18
+ * Outline 머리말과 같은 까닭으로 괜찮다. 여기 셈은 **무엇을 먼저 보여 줄지**에만 쓰인다.
19
+ * 읽는 양에 상한을 둔다. 넘으면 넘은 파일은 안 세고(불린 수 0), 그 사실은 부르는 쪽이 적는다.
20
+ */
21
+ import { readFileSync, statSync } from 'node:fs';
22
+ import { dirname, extname, join, resolve } from 'node:path';
23
+
24
+ export const 셀파일상한 = 3000;
25
+ const 한개상한 = 256 * 1024;
26
+
27
+ const js확장 = ['.js', '.mjs', '.cjs', '.jsx', '.ts', '.tsx', '.mts', '.cts'];
28
+ const js꼴 = new Set(js확장);
29
+ const js무늬 = /(?:\bfrom\s*|\bimport\s*\(\s*|\brequire\s*\(\s*|^\s*import\s+)(['"])(\.{1,2}\/[^'"\n]*|\.{1,2})\1/gm;
30
+ const py무늬from = /^\s*from\s+(\.*)([\w.]*)\s+import\s+\(?([^\n#)]*)/gm;
31
+ const py무늬import = /^\s*import\s+([\w.]+(?:\s+as\s+\w+)?(?:\s*,\s*[\w.]+(?:\s+as\s+\w+)?)*)/gm;
32
+
33
+ /**
34
+ * @param {string} 뿌리 작업 폴더 (파이썬 절대 가져오기의 기준)
35
+ * @param {string[]} 파일들 절대 경로. 이 안에서만 짚고 센다.
36
+ * @returns {Map<string, number>} 절대 경로 → 그 파일을 불러 쓰는 **다른 파일** 수. 넘친 것이 있으면 `.넘침` 에 수.
37
+ */
38
+ export function 들여옴세기(뿌리, 파일들, { 상한 = 셀파일상한 } = {}) {
39
+ const 셈 = new Map();
40
+ // 윈도우 · 맥은 대소문자를 안 가린다 — 적힌 글자와 디스크 글자가 달라도 같은 파일로 짚는다.
41
+ const 안가림 = process.platform === 'win32' || process.platform === 'darwin';
42
+ const 열쇠 = (p) => (안가림 ? p.toLowerCase() : p);
43
+ const 있는것 = new Map();
44
+ for (const p of 파일들) 있는것.set(열쇠(resolve(p)), resolve(p));
45
+ const 짚기 = (후보들) => { for (const c of 후보들) { const 찾음 = 있는것.get(열쇠(c)); if (찾음) return 찾음; } return null; };
46
+
47
+ const js짚기 = (파일, 적힌) => {
48
+ const t = resolve(dirname(파일), 적힌);
49
+ const 후보 = [t, ...js확장.map((e) => t + e), ...js확장.map((e) => join(t, `index${e}`))];
50
+ // ts 는 `./x.js` 로 적고 `x.ts` 를 둔다 (NodeNext).
51
+ const e = extname(t);
52
+ if (e === '.js' || e === '.mjs' || e === '.cjs') {
53
+ const 몸 = t.slice(0, -e.length);
54
+ 후보.push(`${몸}.ts`, `${몸}.tsx`, `${몸}${e === '.mjs' ? '.mts' : e === '.cjs' ? '.cts' : '.ts'}`);
55
+ }
56
+ return 짚기(후보);
57
+ };
58
+ const py모듈 = (기준, 이름) => {
59
+ const 몸 = join(기준, ...이름.split('.').filter(Boolean));
60
+ return 짚기([`${몸}.py`, `${몸}.pyi`, join(몸, '__init__.py')]);
61
+ };
62
+
63
+ let 본수 = 0;
64
+ let 넘침 = 0;
65
+ for (const 파일 of 파일들) {
66
+ const e = extname(파일).toLowerCase();
67
+ const js = js꼴.has(e);
68
+ const py = e === '.py' || e === '.pyi';
69
+ if (!js && !py) continue;
70
+ if (본수 >= 상한) { 넘침++; continue; }
71
+ 본수++;
72
+ let 글;
73
+ try {
74
+ if (statSync(파일).size > 한개상한) continue;
75
+ 글 = readFileSync(파일, 'utf8');
76
+ } catch { continue; }
77
+ const 나 = resolve(파일);
78
+ const 부른것 = new Set();
79
+ if (js) {
80
+ for (const m of 글.matchAll(js무늬)) { const t = js짚기(나, m[2]); if (t) 부른것.add(t); }
81
+ } else {
82
+ for (const m of 글.matchAll(py무늬from)) {
83
+ const 점 = m[1].length;
84
+ const 모듈 = m[2];
85
+ let 기준 = resolve(뿌리);
86
+ if (점) { 기준 = dirname(나); for (let i = 1; i < 점; i++) 기준 = dirname(기준); }
87
+ if (모듈) { const t = py모듈(기준, 모듈); if (t) 부른것.add(t); }
88
+ // `from pkg import util` 의 util 이 하위 모듈일 수 있다.
89
+ for (const 이름 of m[3].split(',').map((x) => x.trim().split(/\s+/)[0]).filter((x) => /^\w+$/.test(x))) {
90
+ const t = py모듈(기준, 모듈 ? `${모듈}.${이름}` : 이름);
91
+ if (t) 부른것.add(t);
92
+ }
93
+ }
94
+ for (const m of 글.matchAll(py무늬import)) {
95
+ for (const 조각 of m[1].split(',')) {
96
+ const 이름 = 조각.trim().split(/\s+/)[0];
97
+ const t = py모듈(resolve(뿌리), 이름);
98
+ if (t) 부른것.add(t);
99
+ }
100
+ }
101
+ }
102
+ 부른것.delete(나);
103
+ for (const t of 부른것) 셈.set(t, (셈.get(t) ?? 0) + 1);
104
+ }
105
+ 셈.넘침 = 넘침;
106
+ return 셈;
107
+ }
@@ -18,13 +18,14 @@ import { TODO_TOOL } from './todo.js';
18
18
  import { TASK_TOOL } from './task.js';
19
19
  import { 고를말 } from '../agent/agents.js';
20
20
  import { OUTLINE_TOOL } from './outline.js';
21
- import { VERIFY_TOOL } from './verify.js';
21
+ import { VERIFY_TOOL, 한파일문법 } from './verify.js';
22
22
  import { DEF_TOOL, REFS_TOOL } from './lsp.js';
23
23
  import { 편집후진단, 붙이기 as 진단붙이기, 데우기 } from '../lsp/diag.js';
24
24
  import { 프로젝트갈래 } from '../lsp/servers.js';
25
25
  import { allow as allowedIn } from '../agent/modes.js';
26
26
  import { 도구정의, 이름풀기 } from '../backend/mcp.js';
27
27
  import { 셸환경 } from '../safety/shellenv.js';
28
+ import { 깃앞보기, 깃뒤적기 } from '../safety/gitsnap.js';
28
29
  import { isExcelPath, readExcel, toText as excelText, summarize as excelSummary } from './excel.js';
29
30
  import { isDocPath, readDoc, toText as docText, summarize as docSummary, looksOldHwp, 옛hwp안내, 문서는못고침 } from './docs.js';
30
31
  import { hwpx만들기, 만든말 } from './hwpxwrite.js';
@@ -725,6 +726,12 @@ function 나중에생긴것적기(떠본것, ctx) {
725
726
  for (const abs of 떠본것?.없던채뜬것 ?? []) {
726
727
  try { if (existsSync(abs) && !statSync(abs).isDirectory()) 떠본것.뜬것.push(ctx.scope.show(abs)); } catch { /* 넘어간다 */ }
727
728
  }
729
+ // 낱말로 못 짚은 것 — git 으로 견줘 안 것 (2.1.3 · safety/gitsnap.js). 이번 턴에 이미 뜬 것은 거기서 거른다.
730
+ if (떠본것?.깃앞) {
731
+ const 깃것 = 깃뒤적기(떠본것.깃앞, { history: ctx.history });
732
+ for (const abs of 깃것.뜬것) 떠본것.뜬것.push(ctx.scope.show(abs));
733
+ for (const x of 깃것.못뜬것) 떠본것.못뜬것.push(`${ctx.scope.show(x.abs)} (${x.왜})`);
734
+ }
728
735
  }
729
736
 
730
737
  /** 지금 파일이 몇 줄인가. 붙인 뒤 '얼마나 찼는지' 를 사실로 말해 주려고 센다. */
@@ -3109,6 +3116,12 @@ export const TOOLS = {
3109
3116
  * 그래도 손으로 옮기고 지우는 흔한 자리는 이걸로 덮인다.
3110
3117
  */
3111
3118
  const 떠본것 = 바꾸기전스냅샷(cmd, ctx);
3119
+ /*
3120
+ * git 저장소면 명령 앞 모습도 떠 둔다 (2.1.3 · safety/gitsnap.js). 낱말로는 못 짚는 자리 — 와일드카드 ·
3121
+ * 스크립트 · 포매터가 바꾼 파일 — 를 명령 뒤에 견줘 되돌리기에 올린다. 뒤에서 도는 명령은 언제 끝날지
3122
+ * 몰라 견줄 자리가 없으니 안 뜬다.
3123
+ */
3124
+ if (args.background !== true && ctx.history?.뒤늦은기록) 떠본것.깃앞 = 깃앞보기(ctx.scope.root);
3112
3125
  const 뜬것 = 떠본것.뜬것;
3113
3126
  /*
3114
3127
  * 못 뜬 것과 상한에 걸린 것은 **결과에 실어 보낸다.**
@@ -4118,6 +4131,16 @@ async function 고친뒤진단(name, r, ctx) {
4118
4131
  데우기(뿌리, 볼것[0]);
4119
4132
 
4120
4133
  const 것들 = await Promise.all(볼것.map((abs) => 편집후진단(뿌리, abs)));
4134
+ /*
4135
+ * 언어 서버가 말을 못 줬으면(없거나 · 아직 데우는 중) 문법만이라도 본다 (2.1.3 · verify.js 의 한파일문법).
4136
+ * 서버가 답했으면 안 본다 — 오류 0개도 답이다. 두 번 보면 같은 탈을 두 줄로 적는다.
4137
+ */
4138
+ if (것들.some((d) => d === null)) {
4139
+ const env = 셸환경(process.env, { 남길것: ctx.셸남길것 ?? [] }).env;
4140
+ await Promise.all(볼것.map(async (abs, i) => {
4141
+ if (것들[i] === null) 것들[i] = await 한파일문법(abs, { 뿌리, env });
4142
+ }));
4143
+ }
4121
4144
  let 답 = r;
4122
4145
  for (let i = 0; i < 볼것.length; i++) {
4123
4146
  답 = 진단붙이기(답, 것들[i], ctx.scope.show(볼것[i]));