deel-local-cli 1.7.0 → 1.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/reset.js ADDED
@@ -0,0 +1,397 @@
1
+ // deel reset — 지우려고 다시 깔지 않게.
2
+ //
3
+ // ── 왜 있나 ────────────────────────────────────────────────────────────
4
+ //
5
+ // 연결을 바꿔 보다, 배운 것이 꼬여서, 남에게 넘기기 전에 — 되돌아갈 자리가
6
+ // 필요할 때가 있다. 지금까지는 그때마다 다시 깔았다. 그런데 다시 깔아도
7
+ // `~/.deel` 은 그대로 남으므로, 사실 다시 깔아도 초기화가 안 됐다.
8
+ //
9
+ // ── 이 파일이 조심하는 것 ──────────────────────────────────────────────
10
+ //
11
+ // 이건 이 프로그램에서 **사람의 진짜 파일을 잃게 할 수 있는 유일한 기능**이다.
12
+ // 그래서 무엇을 지우는지보다 **무엇을 안 지우는지**를 먼저 정해 놓았다.
13
+ //
14
+ // 되돌리기 스냅샷(.deel/history) 이 도구가 승인 프롬프트 대신 내건 안전망
15
+ // 그 자체다. 이걸 지우면 여태 고친 파일을
16
+ // 되돌릴 길이 사라진다.
17
+ // 감사기록(.deel/audit.jsonl) 사내 심사에 낼 근거다.
18
+ // .deel/mcp.json · .deelignore 사람이 손으로 적은 것이다. 우리가 만든
19
+ // 것이 아니면 우리가 지우지 않는다.
20
+ //
21
+ // 앞의 둘은 `all --hard` 로만 지운다. 뒤의 둘은 **어떤 길로도 안 지운다.**
22
+ //
23
+ // ── 설정이 깨져 있어도 돌아야 한다 ─────────────────────────────────────
24
+ //
25
+ // 초기화를 찾는 까닭이 대개 「설정이 깨져서」 다. 그런데 평소에 켜는 길은
26
+ // 설정을 먼저 읽는다. 그래서 이 파일은 config.js 의 load() 를 안 쓴다 —
27
+ // 파일을 직접 읽고, 못 읽으면 못 읽었다고 적은 채로 계속 간다.
28
+ // (`deel --version` 을 연결 없이 답하게 만든 것과 같은 이유다.)
29
+ import { join, resolve, sep } from 'node:path';
30
+ import { existsSync, readFileSync, readdirSync, rmSync } from 'node:fs';
31
+ import { c, say, mark, rule, pad } from './ui/ansi.js';
32
+ import { ask, confirm } from './ui/prompt.js';
33
+ import { homeDir } from './config.js';
34
+ import { 잠긴것인가, 잠금지우기, 보관방식 } from './safety/keystore.js';
35
+
36
+ /** 사람이 칠 수 있는 이름. bin/deel.js 의 도움말·완성 목록과 같아야 한다. */
37
+ export const 갈래들 = ['model', 'memory', 'sessions', 'learned', 'plugins', 'all'];
38
+
39
+ // ── 세는 것들 ───────────────────────────────────────────────────────────
40
+ //
41
+ // 세다가 나는 탈은 전부 삼킨다. 이건 '무엇이 있나' 를 보여 주려는 것이지
42
+ // 검사가 아니다 — 한 줄 못 셌다고 초기화 자체를 못 하게 하면 안 된다.
43
+
44
+ function 줄수(파일) {
45
+ try { return readFileSync(파일, 'utf8').split('\n').filter((l) => l.trim()).length; }
46
+ catch { return 0; }
47
+ }
48
+
49
+ function 안에것(폴더, 거르기 = () => true) {
50
+ try { return readdirSync(폴더, { withFileTypes: true }).filter(거르기).length; }
51
+ catch { return 0; }
52
+ }
53
+
54
+ /**
55
+ * 설정을 읽어 본다. **깨져 있어도 답한다.**
56
+ *
57
+ * @returns {{있나: boolean, 프로필: number|null, 잠긴열쇠: string[], 왜: string}}
58
+ */
59
+ export function 설정살피기(파일) {
60
+ if (!existsSync(파일)) return { 있나: false, 프로필: 0, 잠긴열쇠: [], 왜: '' };
61
+ let j;
62
+ try { j = JSON.parse(readFileSync(파일, 'utf8')); }
63
+ catch (err) {
64
+ // 깨진 설정이야말로 지우려는 것이다. 몇 개인지 모른다고 해서 못 지우면 안 된다.
65
+ // 줄바꿈을 빼고 한 줄로 만든다. JSON 오류에는 깨진 그 줄이 그대로 실려
66
+ // 오는데, 그게 화면에 들어가면 표가 두 줄로 갈라진다.
67
+ const 한줄 = String(err.message).replace(/\s+/g, ' ').trim().slice(0, 60);
68
+ return { 있나: true, 프로필: null, 잠긴열쇠: [], 왜: `읽을 수 없습니다 (${한줄})` };
69
+ }
70
+ const 목록 = Array.isArray(j?.profiles) ? j.profiles : [];
71
+ return {
72
+ 있나: true,
73
+ 프로필: 목록.length,
74
+ 잠긴열쇠: 목록.map((p) => p?.apiKey).filter((k) => 잠긴것인가(k)),
75
+ 왜: '',
76
+ };
77
+ }
78
+
79
+ /**
80
+ * 무엇이 어디에 얼마나 있나.
81
+ *
82
+ * 화면과 지우는 자리가 **같은 목록**을 본다. 두 벌이 되면 화면에는 안 뜨는
83
+ * 것이 지워지거나, 뜨는데 안 지워진다. 둘 다 겪으면 이 기능을 못 믿게 된다.
84
+ *
85
+ * @param {{home?: string, root?: string}} 어디
86
+ */
87
+ export function 살펴보기({ home = homeDir(), root = process.cwd() } = {}) {
88
+ const 집 = (...n) => join(home, ...n);
89
+ const 일 = (...n) => join(root, '.deel', ...n);
90
+
91
+ const 집설정 = 설정살피기(집('config.json'));
92
+ const 일설정 = 설정살피기(일('config.json'));
93
+ const 열쇠들 = [...집설정.잠긴열쇠, ...일설정.잠긴열쇠];
94
+
95
+ const 항목 = [
96
+ {
97
+ 키: 'model',
98
+ 이름: '연결·프로필',
99
+ 자리: [집('config.json'), 일('config.json')],
100
+ 몇: 집설정.프로필 === null || 일설정.프로필 === null
101
+ ? null : (집설정.프로필 ?? 0) + (일설정.프로필 ?? 0),
102
+ 단위: '개',
103
+ 탈: 집설정.왜 || 일설정.왜,
104
+ 뒤: 'deel setup 을 다시 하게 됩니다',
105
+ all: true,
106
+ // 설정 파일을 지우기 **전에** 잠금장치를 먼저 손봐야 한다. 파일을
107
+ // 지우고 나면 어느 열쇠가 잠겨 있었는지 알 길이 없다.
108
+ 열쇠: 열쇠들,
109
+ },
110
+ {
111
+ 키: 'memory',
112
+ 이름: '기억',
113
+ 자리: [일('memory.md')],
114
+ 몇: 줄수(일('memory.md')),
115
+ 단위: '줄',
116
+ 뒤: c.yellow('돌아오지 않습니다'),
117
+ all: true,
118
+ },
119
+ {
120
+ 키: 'sessions',
121
+ 이름: '대화 기록',
122
+ 자리: [일('sessions')],
123
+ 몇: 안에것(일('sessions'), (e) => e.isFile() && e.name.endsWith('.jsonl')),
124
+ 단위: '개',
125
+ 뒤: '이어하기(--continue · --resume)가 안 됩니다',
126
+ all: true,
127
+ },
128
+ {
129
+ 키: 'learned',
130
+ 이름: '배운 것',
131
+ 자리: [집('배운것.json'), 일('배운것.json')],
132
+ 몇: [집('배운것.json'), 일('배운것.json')].filter((p) => existsSync(p)).length,
133
+ 단위: '곳',
134
+ 뒤: '다시 배웁니다',
135
+ all: true,
136
+ },
137
+ {
138
+ // 우리가 만들어 쌓아 둔 것들. 사람이 적은 것이 아니라 안전하다.
139
+ 키: '만든것',
140
+ 이름: '증거·내보낸 것·임시',
141
+ 자리: [일('증거'), 일('export'), 일('tmp'), 일('붙인그림')],
142
+ 몇: [일('증거'), 일('export'), 일('tmp'), 일('붙인그림')].reduce((n, p) => n + 안에것(p), 0),
143
+ 단위: '개',
144
+ 뒤: '안전합니다',
145
+ all: true,
146
+ 숨은것: true, // 따로 고를 수는 없고 all 에만 딸려 간다
147
+ },
148
+ {
149
+ 키: 'plugins',
150
+ 이름: '플러그인',
151
+ 자리: [집('plugins')],
152
+ 몇: 안에것(집('plugins'), (e) => e.isDirectory() && !e.name.startsWith('.')),
153
+ 단위: '개',
154
+ 뒤: c.yellow('다시 설치해야 합니다'),
155
+ // 따로 고를 때만 지운다. 다시 받는 데 시간이 걸리고, 사람이 초기화하려는
156
+ // 것은 대개 플러그인이 아니라 연결 쪽이다.
157
+ all: false,
158
+ },
159
+ ];
160
+
161
+ // `--hard` 로만 지우는 것.
162
+ const 굳은것 = [
163
+ {
164
+ 키: 'history',
165
+ 이름: '되돌리기 스냅샷',
166
+ 자리: [일('history')],
167
+ 몇: 줄수(일('history', 'edits.jsonl')),
168
+ 단위: '자리',
169
+ 뒤: c.red('고친 파일을 되돌릴 수 없게 됩니다'),
170
+ },
171
+ {
172
+ 키: 'audit',
173
+ 이름: '감사기록',
174
+ 자리: [일('audit.jsonl')],
175
+ 몇: 줄수(일('audit.jsonl')),
176
+ 단위: '줄',
177
+ 뒤: c.red('사내 심사에 낼 근거가 사라집니다'),
178
+ },
179
+ ];
180
+
181
+ // 어떤 길로도 안 지우는 것. 화면에 이름을 적어 둔다 — 「왜 이건 안 지웠지」
182
+ // 를 나중에 묻게 하지 않으려고.
183
+ const 안건드림 = [
184
+ { 이름: '.deel/mcp.json', 있나: existsSync(일('mcp.json')) },
185
+ { 이름: '.deelignore', 있나: existsSync(join(root, '.deelignore')) },
186
+ { 이름: 'DEEL.md', 있나: existsSync(join(root, 'DEEL.md')) },
187
+ ];
188
+
189
+ return { home, root, 항목, 굳은것, 안건드림, 열쇠보관: 보관방식(열쇠들[0] ?? null) };
190
+ }
191
+
192
+ /**
193
+ * 이 자리를 건드려도 되나.
194
+ *
195
+ * 작업 폴더와 살림 폴더 **밖은 절대 안 건드린다.** 이 프로그램의 다른
196
+ * 모든 자리가 지키는 규칙이고, 지우는 자리에서 특히 그렇다.
197
+ * 울타리 그 자체(`~/.deel` 이나 작업 폴더 통째로)도 안 준다 — 목록에 없는
198
+ * 것까지 딸려 가면 이 기능을 못 믿게 된다.
199
+ */
200
+ export function 울타리안인가(경로, 울타리들) {
201
+ const p = resolve(경로);
202
+ return 울타리들.some((u) => {
203
+ const r = resolve(u);
204
+ return p !== r && p.startsWith(r + sep);
205
+ });
206
+ }
207
+
208
+ /**
209
+ * 고른 것을 지운다.
210
+ *
211
+ * @param {string} 무엇 갈래들 중 하나
212
+ * @param {{home?: string, root?: string, hard?: boolean}} 옵션
213
+ * @returns {{지운것: Array, 못한것: Array, 열쇠: object|null}}
214
+ */
215
+ export function 지우기(무엇, { home = homeDir(), root = process.cwd(), hard = false } = {}) {
216
+ const 본것 = 살펴보기({ home, root });
217
+ const 울타리 = [home, join(root, '.deel')];
218
+
219
+ const 고른것 = 무엇 === 'all'
220
+ ? [...본것.항목.filter((x) => x.all), ...(hard ? 본것.굳은것 : [])]
221
+ : 본것.항목.filter((x) => x.키 === 무엇);
222
+
223
+ const 지운것 = [];
224
+ const 못한것 = [];
225
+ let 열쇠 = null;
226
+
227
+ for (const 것 of 고른것) {
228
+ // 설정을 지우기 전에 잠금장치부터. 순서가 반대면 어느 열쇠였는지 잃는다.
229
+ if (것.키 === 'model' && 것.열쇠?.length) {
230
+ 열쇠 = 잠금지우기(것.열쇠[0]);
231
+ }
232
+ // 한 갈래가 여러 자리에 걸쳐 있다 (배운 것은 이 PC 와 이 폴더 둘 다).
233
+ // 화면에는 **갈래 하나로** 적는다 — 자리마다 한 줄씩 내면 「배운 것 2곳」
234
+ // 이 두 번 찍혀서, 네 곳을 지운 것처럼 읽힌다.
235
+ const 지운자리 = [];
236
+ for (const 자리 of 것.자리) {
237
+ if (!existsSync(자리)) continue;
238
+ if (!울타리안인가(자리, 울타리)) {
239
+ // 여기 오면 목록을 만드는 자리가 잘못된 것이다. 지우지 말고 말한다.
240
+ 못한것.push({ 이름: 것.이름, 자리, 왜: '작업 폴더 밖이라 안 건드렸습니다' });
241
+ continue;
242
+ }
243
+ try {
244
+ rmSync(자리, { recursive: true, force: true });
245
+ 지운자리.push(자리);
246
+ } catch (err) {
247
+ 못한것.push({ 이름: 것.이름, 자리, 왜: String(err.message).slice(0, 80) });
248
+ }
249
+ }
250
+ if (지운자리.length) {
251
+ 지운것.push({ 키: 것.키, 이름: 것.이름, 몇: 것.몇, 단위: 것.단위, 자리들: 지운자리 });
252
+ }
253
+ }
254
+ return { 지운것, 못한것, 열쇠 };
255
+ }
256
+
257
+ // ── 화면 ────────────────────────────────────────────────────────────────
258
+
259
+ function 몇자(것) {
260
+ if (것.몇 === null) return c.yellow('알 수 없음');
261
+ return 것.몇 ? `${것.몇.toLocaleString()}${것.단위}` : c.gray(`0${것.단위}`);
262
+ }
263
+
264
+ function 보여주기(본것) {
265
+ say('');
266
+ rule('지울 수 있는 것', 74);
267
+ say(` ${c.gray('살림 자리')} ${c.gray(본것.home)}`);
268
+ say(` ${c.gray('작업 폴더')} ${c.gray(본것.root)}`);
269
+ say('');
270
+ // 이름 칸은 pad() 로 맞춘다. padEnd 는 **글자 수**를 세는데 한글은 터미널에서
271
+ // 두 칸을 먹어서, 한글과 영문이 섞인 줄이 서로 어긋난다.
272
+ for (const 것 of 본것.항목) {
273
+ const 이름 = 것.숨은것 ? c.gray(것.이름) : c.bold(것.이름);
274
+ say(` ${pad(이름, 26)} ${pad(몇자(것), 10, 'right')}`);
275
+ if (것.탈) say(` ${c.yellow(것.탈)}`);
276
+ }
277
+ if (본것.항목.some((x) => x.키 === 'model' && x.열쇠?.length)) {
278
+ say(` ${pad(c.bold('잠긴 열쇠'), 26)} ${pad('', 10)} ${c.gray(본것.열쇠보관)}`);
279
+ }
280
+ say('');
281
+ rule('안 지웁니다', 74);
282
+ for (const 것 of 본것.굳은것) {
283
+ say(` ${pad(c.bold(것.이름), 26)} ${pad(몇자(것), 10, 'right')} ${c.gray('all --hard 를 줘야 지웁니다')}`);
284
+ }
285
+ const 남길것 = 본것.안건드림.filter((x) => x.있나).map((x) => x.이름);
286
+ say(` ${pad(c.bold('사람이 적은 것'), 26)} ${pad('', 10)} ${남길것.length ? c.gray(남길것.join(' · ')) : c.gray('없음')}`);
287
+ say(` ${c.gray(' 어떤 길로도 안 건드립니다.')}`);
288
+ say('');
289
+ }
290
+
291
+ function 지운뒤적기(결과) {
292
+ say('');
293
+ // 「완료」 한 줄은 확인이 안 된다. 무엇이 몇 개 없어졌는지 적는다.
294
+ if (!결과.지운것.length) {
295
+ say(` ${mark.warn} ${c.gray('지울 것이 없었습니다 — 이미 비어 있습니다.')}`);
296
+ }
297
+ for (const x of 결과.지운것) {
298
+ const 셈 = x.몇 === null ? '' : c.gray(` ${x.몇}${x.단위}`);
299
+ say(` ${mark.ok} ${c.bold(x.이름)}${셈}`);
300
+ for (const 자리 of x.자리들) say(` ${c.gray(자리)}`);
301
+ }
302
+ if (결과.열쇠) {
303
+ say(결과.열쇠.지움
304
+ ? ` ${mark.ok} ${c.bold('잠금장치의 열쇠')} ${c.gray(결과.열쇠.방식)}`
305
+ : ` ${mark.warn} ${c.gray(`잠금장치 — ${결과.열쇠.왜}`)}`);
306
+ }
307
+ for (const x of 결과.못한것) {
308
+ say(` ${mark.no} ${x.이름} ${c.gray(x.자리)}`);
309
+ say(` ${c.yellow(x.왜)}`);
310
+ }
311
+ say('');
312
+ return 결과.못한것.length ? 1 : 0;
313
+ }
314
+
315
+ /**
316
+ * `deel reset [갈래] [--hard] [--yes]`
317
+ *
318
+ * 인자 없이 부르면 **아무것도 안 지운다.** 보여 주고 묻는다.
319
+ */
320
+ export async function runReset(args = [], flags = {}) {
321
+ const home = homeDir();
322
+ const root = flags.root ? String(flags.root) : process.cwd();
323
+ const hard = flags.hard === true || flags.hard === 'true';
324
+ const 묻지마 = flags.yes === true || flags.yes === 'true';
325
+
326
+ let 무엇 = String(args[0] ?? '').trim().toLowerCase();
327
+ if (무엇 && !갈래들.includes(무엇)) {
328
+ say('');
329
+ say(` ${mark.no} ${c.red('모르는 갈래')} ${c.bold(무엇)}`);
330
+ say(` ${c.gray(갈래들.join(' · '))}`);
331
+ say('');
332
+ return 1;
333
+ }
334
+
335
+ const 본것 = 살펴보기({ home, root });
336
+ 보여주기(본것);
337
+
338
+ if (!무엇) {
339
+ /*
340
+ * 물어볼 사람이 없으면 안 묻는다.
341
+ *
342
+ * 파이프·CI 에서 `deel reset` 은 「무엇이 있나 보여 줘」 로 읽히는 것이
343
+ * 맞다. 여기서 답을 기다리면 스크립트가 그대로 멈춘다. 실제로 검사에서
344
+ * 한 번 멈춰 봤고, 멈춘 검사는 실패보다 나쁘다.
345
+ */
346
+ if (!process.stdin.isTTY) {
347
+ say(` ${c.gray('무엇을 지울지 같이 주세요:')} ${c.cyan(`deel reset <${갈래들.join('|')}>`)}`);
348
+ say('');
349
+ return 0;
350
+ }
351
+ const 답 = (await ask(`무엇을 지울까요? ${c.gray(갈래들.join(' · '))}`, { def: '' })).trim().toLowerCase();
352
+ if (!답) { say(` ${c.gray('그만둡니다. 아무것도 안 지웠습니다.')}`); say(''); return 0; }
353
+ if (!갈래들.includes(답)) {
354
+ say(` ${mark.no} ${c.red('모르는 갈래')} ${c.bold(답)} ${c.gray('— 아무것도 안 지웠습니다.')}`);
355
+ say('');
356
+ return 1;
357
+ }
358
+ 무엇 = 답;
359
+ }
360
+
361
+ // 되돌릴 수 없다는 것을 **지우기 전에** 적는다.
362
+ const 볼것 = 무엇 === 'all'
363
+ ? [...본것.항목.filter((x) => x.all), ...(hard ? 본것.굳은것 : [])]
364
+ : 본것.항목.filter((x) => x.키 === 무엇);
365
+ say(` ${c.bold(`지울 것 — ${무엇}${hard ? ' --hard' : ''}`)}`);
366
+ for (const 것 of 볼것) say(` ${c.gray('·')} ${것.이름} ${몇자(것)} ${c.gray('→')} ${것.뒤}`);
367
+ if (무엇 === 'all' && !hard) {
368
+ say(` ${c.gray('되돌리기 스냅샷·감사기록은 그대로 둡니다 (--hard 를 주면 그것도 지웁니다).')}`);
369
+ }
370
+ if (무엇 === 'all') {
371
+ say(` ${c.gray('플러그인은 안 지웁니다 —')} ${c.cyan('deel reset plugins')}`);
372
+ }
373
+ say(` ${c.yellow('되돌릴 수 없습니다.')}`);
374
+ say('');
375
+
376
+ if (!묻지마) {
377
+ if (!process.stdin.isTTY) {
378
+ say(` ${mark.no} ${c.yellow('물어볼 자리가 없습니다.')} ${c.gray('스크립트에서는')} ${c.cyan('--yes')} ${c.gray('를 주세요.')}`);
379
+ say('');
380
+ return 1;
381
+ }
382
+ if (!await confirm('정말 지울까요?', false)) {
383
+ say(` ${c.gray('그만둡니다. 아무것도 안 지웠습니다.')}`);
384
+ say('');
385
+ return 0;
386
+ }
387
+ // 되돌릴 안전망까지 지우는 것은 한 번 더 묻는다. 여기는 실수하면
388
+ // 「고친 파일을 되돌린다」 가 영영 안 된다.
389
+ if (무엇 === 'all' && hard && !await confirm(c.red('되돌리기 스냅샷·감사기록까지 지웁니다. 계속할까요?'), false)) {
390
+ say(` ${c.gray('그만둡니다. 아무것도 안 지웠습니다.')}`);
391
+ say('');
392
+ return 0;
393
+ }
394
+ }
395
+
396
+ return 지운뒤적기(지우기(무엇, { home, root, hard }));
397
+ }
@@ -20,9 +20,12 @@ export class Audit {
20
20
  tool(name, args, result) {
21
21
  return this.write('tool', {
22
22
  tool: name,
23
- // 목적 하위 작업(Task)이 쓰는 이름이다. 이게 없으면 감사기록에
23
+ // purpose 하위 작업(Task)이 쓰는 이름이다. 이게 없으면 감사기록에
24
24
  // '하위 작업을 돌렸다' 만 남고 **무엇을** 돌렸는지가 안 남는다.
25
- target: args?.file_path ?? args?.path ?? args?.pattern ?? args?.command ?? args?.목적 ?? null,
25
+ // 이름(목적)도 같이 본다 이름을 바꾼 이전의 기록이 남아 있고,
26
+ // 옛 이름으로 부르는 모델도 있다.
27
+ target: args?.file_path ?? args?.path ?? args?.pattern ?? args?.command
28
+ ?? args?.purpose ?? args?.목적 ?? null,
26
29
  ok: !result?.error,
27
30
  note: result?.error ?? result?.summary ?? null,
28
31
  });