deel-local-cli 0.9.0 → 1.0.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.en.md +470 -27
- package/README.md +479 -31
- package/bin/deel.js +234 -186
- package/package.json +3 -2
- package/src/agent/compact.js +131 -138
- package/src/agent/effort.js +44 -11
- package/src/agent/loop.js +574 -251
- package/src/agent/memory.js +152 -0
- package/src/agent/mention.js +164 -0
- package/src/agent/modes.js +11 -3
- package/src/agent/recall.js +209 -0
- package/src/agent/salvage.js +182 -0
- package/src/agent/session.js +140 -10
- package/src/agent/store.js +36 -11
- package/src/backend/adapter.js +266 -183
- package/src/backend/ctxsize.js +133 -61
- package/src/backend/detect.js +10 -1
- package/src/backend/learn.js +102 -0
- package/src/backend/mcp.js +304 -0
- package/src/commands.js +493 -53
- package/src/oneshot.js +327 -0
- package/src/repl.js +715 -479
- package/src/report.js +19 -5
- package/src/safety/guard.js +123 -7
- package/src/safety/undo.js +96 -11
- package/src/tools/encoding.js +24 -2
- package/src/tools/fsutil.js +70 -0
- package/src/tools/index.js +471 -27
- package/src/tools/webfetch.js +37 -1
- package/src/ui/ansi.js +13 -1
- package/src/ui/diff.js +255 -0
- package/src/ui/level.js +3 -1
- package/src/ui/prompt.js +13 -1
- package/src/ui/screen.js +169 -0
- package/src/ui/status.js +81 -17
- package/src/ui/tui.js +419 -0
- package/src/version.js +28 -0
package/src/report.js
CHANGED
|
@@ -64,13 +64,27 @@ export function verdict(facts, results) {
|
|
|
64
64
|
return { level, notes };
|
|
65
65
|
}
|
|
66
66
|
|
|
67
|
+
// 판정 이름은 한 군데서만 정한다.
|
|
68
|
+
//
|
|
69
|
+
// 예전에는 화면용 tag 만 한국어였고, 파일로 떨어지는 보고서에는 `판정: ready`
|
|
70
|
+
// 처럼 영어 키가 그대로 찍혔다. 그 파일은 사내 심사자가 읽으라고 만든 것이라
|
|
71
|
+
// 가장 중요한 한 줄이 영어로 나가고 있었다.
|
|
72
|
+
const VERDICT_NAME = {
|
|
73
|
+
ready: '준비됨', limited: '제한적', blocked: '막힘', stop: '연결실패',
|
|
74
|
+
};
|
|
75
|
+
|
|
67
76
|
const VERDICT_STYLE = {
|
|
68
|
-
ready: { tag: c.green(
|
|
69
|
-
limited: { tag: c.yellow(
|
|
70
|
-
blocked: { tag: c.red(
|
|
71
|
-
stop: { tag: c.red(
|
|
77
|
+
ready: { tag: c.green(` ${VERDICT_NAME.ready} `), line: '이 연결로 deel 를 돌릴 수 있습니다.' },
|
|
78
|
+
limited: { tag: c.yellow(` ${VERDICT_NAME.limited} `), line: '돌아가긴 합니다. 편집 신뢰성 보강이 필요합니다.' },
|
|
79
|
+
blocked: { tag: c.red(` ${VERDICT_NAME.blocked} `), line: '핵심 기능이 막혀 있습니다. 아래를 먼저 해결해야 합니다.' },
|
|
80
|
+
stop: { tag: c.red(` ${VERDICT_NAME.stop} `), line: '연결 자체가 되지 않았습니다.' },
|
|
72
81
|
};
|
|
73
82
|
|
|
83
|
+
/** 판정을 사람 말로. 모르는 값이면 그대로 돌려준다 — 지어내지 않는다. */
|
|
84
|
+
export function verdictName(level) {
|
|
85
|
+
return VERDICT_NAME[level] ?? String(level ?? '');
|
|
86
|
+
}
|
|
87
|
+
|
|
74
88
|
export function renderVerdict(v) {
|
|
75
89
|
const s = VERDICT_STYLE[v.level];
|
|
76
90
|
say('');
|
|
@@ -105,7 +119,7 @@ export function plainReport(facts, results, v) {
|
|
|
105
119
|
const time = ' '.repeat(Math.max(0, 9 - t.length)) + t;
|
|
106
120
|
lines.push(` ${label} ${state} ${time} ${r.detail}`);
|
|
107
121
|
}
|
|
108
|
-
lines.push('-'.repeat(70), '', `판정: ${v.level}`, '');
|
|
122
|
+
lines.push('-'.repeat(70), '', `판정: ${verdictName(v.level)} (${v.level})`, ` ${VERDICT_STYLE[v.level]?.line ?? ''}`, '');
|
|
109
123
|
for (const n of v.notes) lines.push(` - ${n}`);
|
|
110
124
|
lines.push('');
|
|
111
125
|
return lines.join('\n');
|
package/src/safety/guard.js
CHANGED
|
@@ -1,22 +1,62 @@
|
|
|
1
1
|
// 자율 실행의 울타리.
|
|
2
2
|
// 승인 프롬프트를 안 쓰는 대신 (1) 작업 범위 밖은 못 건드리고
|
|
3
3
|
// (2) 되돌릴 수 없는 명령만 막는다. 나머지는 전부 통과시킨다.
|
|
4
|
-
import { resolve, relative, isAbsolute, sep } from 'node:path';
|
|
4
|
+
import { resolve, relative, isAbsolute, sep, dirname, basename } from 'node:path';
|
|
5
|
+
import { realpathSync } from 'node:fs';
|
|
6
|
+
import { homedir } from 'node:os';
|
|
7
|
+
import { 내부살림 } from '../tools/fsutil.js';
|
|
5
8
|
|
|
6
9
|
export class ScopeError extends Error {}
|
|
7
10
|
export class BlockedError extends Error {}
|
|
8
11
|
|
|
12
|
+
/** 이 절대경로가 base 밖인가. */
|
|
13
|
+
function 밖(base, abs) {
|
|
14
|
+
const rel = relative(base, abs);
|
|
15
|
+
return rel.startsWith('..' + sep) || rel === '..' || (isAbsolute(rel) && rel !== '');
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* 링크를 따라간 진짜 자리.
|
|
20
|
+
*
|
|
21
|
+
* 윈도우에는 정션(junction)이 흔하다. 작업 폴더 안에 바깥을 가리키는 정션이
|
|
22
|
+
* 하나 있으면, 경로 글자만 봐서는 범위 안인데 실제로 닿는 곳은 바깥이다.
|
|
23
|
+
* 글자로만 검사하던 울타리가 그 한 칸으로 통째로 열린다.
|
|
24
|
+
*
|
|
25
|
+
* 아직 없는 파일도 검사해야 한다 — 새로 쓰는 자리가 그렇다. 그래서 있는
|
|
26
|
+
* 자리가 나올 때까지 위로 올라가 거기서 링크를 풀고, 남은 이름을 다시 붙인다.
|
|
27
|
+
*/
|
|
28
|
+
function 진짜자리(p) {
|
|
29
|
+
const 남은 = [];
|
|
30
|
+
let cur = p;
|
|
31
|
+
for (let i = 0; i < 64; i++) {
|
|
32
|
+
try {
|
|
33
|
+
const r = realpathSync(cur);
|
|
34
|
+
return 남은.length ? resolve(r, ...남은) : r;
|
|
35
|
+
} catch { /* 아직 없는 자리 — 한 칸 위로 */ }
|
|
36
|
+
const 위 = dirname(cur);
|
|
37
|
+
if (위 === cur) return p; // 뿌리까지 갔는데도 없다. 글자 그대로 본다.
|
|
38
|
+
남은.unshift(basename(cur));
|
|
39
|
+
cur = 위;
|
|
40
|
+
}
|
|
41
|
+
return p;
|
|
42
|
+
}
|
|
43
|
+
|
|
9
44
|
// 작업 범위 — deel 를 띄운 폴더. 그 밖은 읽기도 쓰기도 막는다.
|
|
10
45
|
export function makeScope(root) {
|
|
11
46
|
const base = resolve(root);
|
|
47
|
+
// 뿌리 자신도 링크 아래 있을 수 있다(맥의 /tmp 가 그렇다). 그래서 양쪽 다 푼 값으로 견준다.
|
|
48
|
+
let _진짜뿌리 = null;
|
|
49
|
+
const 진짜뿌리 = () => (_진짜뿌리 ??= 진짜자리(base));
|
|
12
50
|
return {
|
|
13
51
|
root: base,
|
|
14
52
|
resolve(p) {
|
|
15
53
|
if (!p || typeof p !== 'string') throw new ScopeError('경로가 비었습니다');
|
|
16
54
|
const abs = isAbsolute(p) ? resolve(p) : resolve(base, p);
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
55
|
+
if (밖(base, abs)) throw new ScopeError(`작업 범위 밖입니다: ${p}\n 범위: ${base}`);
|
|
56
|
+
// 글자로는 안인데 링크를 따라가면 밖인 자리. 정션 하나로 울타리가 열린다.
|
|
57
|
+
const 진짜 = 진짜자리(abs);
|
|
58
|
+
if (진짜 !== abs && 밖(진짜뿌리(), 진짜)) {
|
|
59
|
+
throw new ScopeError(`작업 범위 밖입니다 (링크가 밖을 가리킵니다): ${p}\n 실제 자리: ${진짜}\n 범위: ${base}`);
|
|
20
60
|
}
|
|
21
61
|
return abs;
|
|
22
62
|
},
|
|
@@ -35,7 +75,9 @@ const BLOCKED = [
|
|
|
35
75
|
{ re: /\b(rd|rmdir)\s+\/s\b/i, why: '폴더를 통째로 지웁니다 — 정션이 있으면 원본까지 딸려 갑니다' },
|
|
36
76
|
{ re: /\bdel\s+\/[sq]\b.*\\\*/i, why: '하위 폴더까지 전부 지웁니다' },
|
|
37
77
|
{ re: /git\s+push\b[^|;&]*--force(?!-with-lease)/i, why: '원격 이력을 덮어씁니다 (--force-with-lease 를 쓰세요)' },
|
|
38
|
-
|
|
78
|
+
// 낱말만 보면 안 된다. `node scripts/shutdown.js` 나 `npm run reboot` 이
|
|
79
|
+
// '시스템을 끕니다' 로 막히고 감사기록에까지 남았다. 명령의 **첫 낱말**일 때만 본다.
|
|
80
|
+
{ re: /(^|[|;&]\s*)(sudo\s+)?(shutdown|reboot|halt|poweroff)\b/i, why: '시스템을 끕니다' },
|
|
39
81
|
{ re: /curl[^|]*\|\s*(ba)?sh/i, why: '받은 스크립트를 그대로 실행합니다' },
|
|
40
82
|
{ re: /\biwr\b[^|]*\|\s*iex\b/i, why: '받은 스크립트를 그대로 실행합니다' },
|
|
41
83
|
];
|
|
@@ -48,8 +90,82 @@ export function checkCommand(cmd) {
|
|
|
48
90
|
return true;
|
|
49
91
|
}
|
|
50
92
|
|
|
51
|
-
|
|
52
|
-
|
|
93
|
+
/**
|
|
94
|
+
* 명령줄에 적힌 경로들을 뽑아 울타리 안인지 본다.
|
|
95
|
+
*
|
|
96
|
+
* 왜 필요한가:
|
|
97
|
+
* Read 도구는 .deel/config.json 을 막는다. 게이트웨이 열쇠가 그 안에 있기
|
|
98
|
+
* 때문이다. 그런데 Bash 로는 `type .deel\config.json` 한 줄이면 그냥 읽혔다.
|
|
99
|
+
* 읽힌 열쇠는 대화에 실려 게이트웨이로 나가고, 세션 기록으로 디스크에도 남는다.
|
|
100
|
+
* 열쇠를 그 열쇠의 주인에게 보내는 셈이다.
|
|
101
|
+
*
|
|
102
|
+
* 범위도 마찬가지였다. 시스템 프롬프트는 '이 폴더 밖은 읽지도 쓰지도 못한다'
|
|
103
|
+
* 고 말하는데, Bash 한 줄로 `cat ../../비밀` 이 됐다. 말이 거짓이 되면
|
|
104
|
+
* 모델은 그 말을 안 믿고, 사용자는 울타리가 있다고 믿는다. 둘 다 나쁘다.
|
|
105
|
+
*
|
|
106
|
+
* 어떻게 고르나:
|
|
107
|
+
* 경로처럼 보이는 낱말만 본다(/ 나 \ 가 있거나, ~ 또는 드라이브로 시작).
|
|
108
|
+
* 그리고 **밖으로 나가는 것만** 막는다. 안에서 도는 것은 전부 통과다.
|
|
109
|
+
* 그래서 sed 의 's/a/b/' 나 npm 의 some/pkg 같은 것은 걸리지 않는다 —
|
|
110
|
+
* 글자로는 경로처럼 보여도 풀어 보면 폴더 안이기 때문이다.
|
|
111
|
+
*/
|
|
112
|
+
export function checkPaths(cmd, scope) {
|
|
113
|
+
if (!scope) return true;
|
|
114
|
+
for (const t of 경로낱말(String(cmd))) {
|
|
115
|
+
let abs;
|
|
116
|
+
try { abs = scope.resolve(t); }
|
|
117
|
+
catch (e) {
|
|
118
|
+
if (e instanceof ScopeError) {
|
|
119
|
+
throw new ScopeError(`${e.message}\n 이 명령 안에 있습니다: ${String(cmd).slice(0, 120)}`);
|
|
120
|
+
}
|
|
121
|
+
continue;
|
|
122
|
+
}
|
|
123
|
+
const 이유 = 내부살림(abs);
|
|
124
|
+
if (이유) throw new BlockedError(`${이유}\n 이 명령 안에 있습니다: ${String(cmd).slice(0, 120)}`);
|
|
125
|
+
}
|
|
126
|
+
return true;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// 셸이 알아서 풀어 주는 자리표. 우리도 같이 풀어야 실제로 닿는 곳을 본다.
|
|
130
|
+
function 자리표풀기(s) {
|
|
131
|
+
return s
|
|
132
|
+
.replace(/^~(?=[/\\]|$)/, homedir())
|
|
133
|
+
.replace(/\$\{?HOME\}?|%USERPROFILE%|\$\{?USERPROFILE\}?/gi, homedir());
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/** 명령줄에서 경로처럼 보이는 낱말만 골라낸다. */
|
|
137
|
+
export function 경로낱말(cmd) {
|
|
138
|
+
const out = [];
|
|
139
|
+
// 따옴표 안은 통째로 한 낱말, 밖은 공백과 셸 기호로 자른다.
|
|
140
|
+
const re = /"([^"]*)"|'([^']*)'|[^\s|;&<>()]+/g;
|
|
141
|
+
let m;
|
|
142
|
+
while ((m = re.exec(cmd))) {
|
|
143
|
+
let t = m[1] ?? m[2] ?? m[0];
|
|
144
|
+
if (!t) continue;
|
|
145
|
+
if (t.startsWith('-')) continue; // 옵션
|
|
146
|
+
if (/^[a-z][a-z0-9+.-]*:\/\//i.test(t)) continue; // 주소(http://, file:// …)
|
|
147
|
+
// 리디렉션이 붙어 온 것(>out.txt)은 기호를 떼고 본다.
|
|
148
|
+
t = t.replace(/^[<>]+/, '');
|
|
149
|
+
if (!t) continue;
|
|
150
|
+
const 풀린 = 자리표풀기(t);
|
|
151
|
+
const 경로같나 = 풀린.includes('/') || 풀린.includes('\\') || /^[a-zA-Z]:$/.test(풀린);
|
|
152
|
+
if (!경로같나) continue;
|
|
153
|
+
// 남은 자리표가 있으면(우리가 못 푼 것) 어디로 갈지 모르니 검사하지 않는다.
|
|
154
|
+
// 여기서 억지로 풀면 엉뚱한 자리를 막게 된다.
|
|
155
|
+
if (/[$%]/.test(풀린)) continue;
|
|
156
|
+
out.push(풀린);
|
|
157
|
+
}
|
|
158
|
+
return out;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* 변경성 동작은 실패해도 다시 실행하지 않는다 — 두 번 실행되면 사고다. (RPA 에서 얻은 원칙)
|
|
163
|
+
*
|
|
164
|
+
* 이것도 **첫 낱말**일 때만 본다. 전에는 아무 데나 있으면 걸렸다.
|
|
165
|
+
* 그래서 `node scripts/copy.js` 나 `grep -r "del" src/` 가 '변경성 명령' 이 되어
|
|
166
|
+
* 두 번째 시도가 막혔다. 진짜로 파일을 옮기는 명령과 이름만 스친 것은 다르다.
|
|
167
|
+
*/
|
|
168
|
+
const MUTATING = /(^|[|;&]\s*)(sudo\s+)?(git\s+(commit|push|merge|rebase|reset)|npm\s+(publish|install)|pip\s+install|mv|cp|del|rm|move|copy|curl\s+-X\s*(POST|PUT|DELETE|PATCH))\b/i;
|
|
53
169
|
|
|
54
170
|
export function isMutating(cmd) {
|
|
55
171
|
return MUTATING.test(String(cmd));
|
package/src/safety/undo.js
CHANGED
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
// 파일을 고치기 전에 항상 이전 내용을 떠 놓고, /undo 로 턴 단위로 되돌린다.
|
|
3
3
|
import { join } from 'node:path';
|
|
4
4
|
import { readFileSync, writeFileSync, existsSync, mkdirSync, rmSync, appendFileSync, readdirSync, statSync } from 'node:fs';
|
|
5
|
+
import { looksBinary } from '../tools/encoding.js';
|
|
5
6
|
|
|
6
7
|
// 되돌리기 이력은 파일 내용을 통째로 담는다. 이만큼 커지면 오래된 턴을 버린다.
|
|
7
8
|
const MAX_BYTES = 32 * 1024 * 1024;
|
|
@@ -15,17 +16,63 @@ export class History {
|
|
|
15
16
|
this.turn = 0;
|
|
16
17
|
}
|
|
17
18
|
|
|
18
|
-
|
|
19
|
+
/**
|
|
20
|
+
* 새 턴 시작 — /undo 는 턴 하나를 통째로 되돌린다.
|
|
21
|
+
*
|
|
22
|
+
* 번호는 시각으로 짓되 **반드시 늘어나게** 한다.
|
|
23
|
+
*
|
|
24
|
+
* 예전에는 그냥 Date.now() 였다. 그러면 같은 밀리초에 두 턴이 시작될 때
|
|
25
|
+
* 번호가 똑같아지고, 두 턴이 하나로 뭉친다. 그 상태에서 /undo 를 한 번 하면
|
|
26
|
+
* 두 턴 어치가 한꺼번에 되돌아간다 — 사용자가 시키지 않은 것까지 되돌린다.
|
|
27
|
+
* 되돌리기는 이 프로그램의 안전망이라, 여기서 조용히 틀리는 것이 가장 나쁘다.
|
|
28
|
+
*
|
|
29
|
+
* 리눅스 CI 에서 실제로 잡혔다. 윈도우는 Date.now() 눈금이 굵어(약 15ms)
|
|
30
|
+
* 우연히 안 겹쳤고, 리눅스에서는 다섯 턴이 같은 밀리초에 들어갔다.
|
|
31
|
+
* '내 기계에서는 되는데' 가 이렇게 생긴다.
|
|
32
|
+
*/
|
|
19
33
|
nextTurn() {
|
|
20
|
-
|
|
34
|
+
const 지금 = Date.now();
|
|
35
|
+
this.turn = 지금 > this.turn ? 지금 : this.turn + 1;
|
|
36
|
+
this.#이번턴 = new Map();
|
|
21
37
|
return this.turn;
|
|
22
38
|
}
|
|
23
39
|
|
|
24
|
-
// 파일을
|
|
40
|
+
// 이번 턴에 이미 떠 놓은 파일들. 같은 파일을 또 뜰 이유가 없다 — 아래 참고.
|
|
41
|
+
#이번턴 = new Map();
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* 파일을 고치기 직전에 부른다.
|
|
45
|
+
*
|
|
46
|
+
* 세 가지 결과가 있고, **셋을 절대 섞으면 안 된다.**
|
|
47
|
+
* before: '…' 내용을 떴다 → 되돌리면 이 내용으로 되돌아간다
|
|
48
|
+
* before: null 원래 없던 파일이다 → 되돌리면 지운다
|
|
49
|
+
* skipped: '…' 못 떴다 → 되돌릴 때 **건드리지 않는다**
|
|
50
|
+
*
|
|
51
|
+
* 셋째가 없어서 사고가 났었다. 못 뜬 파일도 before 가 null 이었고, 되돌리기는
|
|
52
|
+
* 그걸 '원래 없던 파일' 로 읽어 rmSync 했다. 그림·hwp 를 덮어쓴 뒤 /undo 를
|
|
53
|
+
* 누르면 남아 있던 잔해까지 사라졌다 — 안전망이 파일을 지우는 것이다.
|
|
54
|
+
*/
|
|
25
55
|
snapshot(absPath, label) {
|
|
26
|
-
|
|
27
|
-
|
|
56
|
+
/*
|
|
57
|
+
* 한 턴에 같은 파일은 한 번만 뜬다.
|
|
58
|
+
*
|
|
59
|
+
* 되돌리기는 턴 하나를 통째로 되돌린다 — 그 턴에 손대기 **전** 상태로.
|
|
60
|
+
* 그러니 필요한 것은 그 턴의 첫 스냅샷 하나뿐이고, undo() 도 실제로 첫
|
|
61
|
+
* 기록만 본다. 두 번째부터는 쓰이지 않는 파일 사본이 디스크에 쌓일 뿐이다.
|
|
62
|
+
*
|
|
63
|
+
* 그냥 낭비로 끝나지 않는다. 큰 파일을 Append 로 여덟 번 이어 붙이면 그
|
|
64
|
+
* 파일이 여덟 벌 쌓이고, 이력이 32MB 를 넘으면 오래된 턴부터 버려진다 —
|
|
65
|
+
* 즉 **정말 되돌려야 할 옛 기록이 밀려난다.** 안전망이 제 무게로 무너진다.
|
|
66
|
+
*/
|
|
67
|
+
const 이미 = this.#이번턴.get(absPath);
|
|
68
|
+
if (이미) return 이미;
|
|
69
|
+
|
|
70
|
+
const 뜬것 = existsSync(absPath) ? safeRead(absPath) : { before: null };
|
|
71
|
+
const rec = { turn: this.turn, at: new Date().toISOString(), path: absPath, before: 뜬것.before, label };
|
|
72
|
+
if (뜬것.enc) rec.enc = 뜬것.enc;
|
|
73
|
+
if (뜬것.skipped) rec.skipped = 뜬것.skipped;
|
|
28
74
|
appendFileSync(this.file, JSON.stringify(rec) + '\n', 'utf8');
|
|
75
|
+
this.#이번턴.set(absPath, rec);
|
|
29
76
|
this.#maybePrune();
|
|
30
77
|
return rec;
|
|
31
78
|
}
|
|
@@ -98,10 +145,25 @@ export class History {
|
|
|
98
145
|
const restored = [];
|
|
99
146
|
for (const [path, rec] of first) {
|
|
100
147
|
try {
|
|
101
|
-
if (rec.
|
|
102
|
-
|
|
148
|
+
if (rec.skipped) {
|
|
149
|
+
// 내용을 못 떠 놓은 파일이다. 되돌릴 것이 없으니 손대지 않는다.
|
|
150
|
+
// 조용히 넘기지는 않는다 — 되돌아간 줄 알면 사용자가 그 위에 계속 일한다.
|
|
151
|
+
restored.push({ path, how: `그대로 둠 (${rec.skipped} — 내용을 떠 두지 못했습니다)`, skipped: true });
|
|
152
|
+
} else if (rec.before === null) {
|
|
153
|
+
// 옛 이력에는 skipped 표시가 없다. 그때 못 뜬 파일도 여기로 온다.
|
|
154
|
+
// 지금 디스크에 있는 것이 바이너리면 우리가 만든 파일일 리 없으니 안 지운다.
|
|
155
|
+
if (!existsSync(path)) continue;
|
|
156
|
+
let 바이너리 = false;
|
|
157
|
+
try { 바이너리 = looksBinary(readFileSync(path)); } catch { 바이너리 = true; }
|
|
158
|
+
if (바이너리) {
|
|
159
|
+
restored.push({ path, how: '그대로 둠 (바이너리 — 내용을 떠 두지 못했습니다)', skipped: true });
|
|
160
|
+
continue;
|
|
161
|
+
}
|
|
162
|
+
rmSync(path, { force: true });
|
|
163
|
+
restored.push({ path, how: '삭제됨(원래 없던 파일)' });
|
|
103
164
|
} else {
|
|
104
|
-
|
|
165
|
+
// enc 가 붙어 있으면 UTF-8 로 담을 수 없던 파일이다 — 바이트를 그대로 되돌린다.
|
|
166
|
+
writeFileSync(path, rec.enc === 'b64' ? Buffer.from(rec.before, 'base64') : Buffer.from(rec.before, 'utf8'));
|
|
105
167
|
restored.push({ path, how: '되돌림' });
|
|
106
168
|
}
|
|
107
169
|
} catch (err) {
|
|
@@ -115,9 +177,32 @@ export class History {
|
|
|
115
177
|
}
|
|
116
178
|
}
|
|
117
179
|
|
|
180
|
+
/**
|
|
181
|
+
* 되돌릴 내용을 뜬다.
|
|
182
|
+
*
|
|
183
|
+
* UTF-8 로 오갈 수 있는 파일이면 글자 그대로 담는다 — 이력 파일이 사람 눈에도
|
|
184
|
+
* 보이고, 자리도 덜 먹는다.
|
|
185
|
+
*
|
|
186
|
+
* 그럴 수 없는 파일이면 바이트를 base64 로 담는다. 사내 파일은 CP949 가 흔한데,
|
|
187
|
+
* 그걸 UTF-8 로 읽으면 못 옮기는 바이트가 전부 U+FFFD 한 글자로 뭉개진다.
|
|
188
|
+
* 되돌아가는 것은 그 뭉개진 글자다 — 원래 바이트는 그 순간 없어진다.
|
|
189
|
+
* '가나다'(b0a1 b3aa b4d9) 를 되돌리면 efbfbd 가 여섯 번 찍힌다.
|
|
190
|
+
*
|
|
191
|
+
* 되돌리기는 이 프로그램의 안전망이다. 안전망이 파일을 망가뜨리면
|
|
192
|
+
* 아예 없느니만 못하다. 그래서 오갈 수 있는지를 실제로 해 보고 정한다.
|
|
193
|
+
*
|
|
194
|
+
* 바이너리 판정은 **읽기 도구와 똑같은 잣대**를 쓴다(looksBinary — 앞 8,000바이트).
|
|
195
|
+
* 전에는 여기서만 파일 전체를 봤다. 그래서 8,000바이트 뒤에 NUL 이 하나 들어 있는
|
|
196
|
+
* 평범한 소스 파일이 Read·Edit 은 되는데 스냅샷만 비는 상태가 됐다. 그러고 /undo 를
|
|
197
|
+
* 누르면 멀쩡한 파일이 지워졌다. 두 곳의 잣대가 다르면 반드시 이런 틈이 생긴다.
|
|
198
|
+
*/
|
|
118
199
|
function safeRead(p) {
|
|
119
200
|
const buf = readFileSync(p);
|
|
120
|
-
// 바이너리는
|
|
121
|
-
|
|
122
|
-
return
|
|
201
|
+
// 바이너리는 내용을 담지 않는다. '없던 파일' 과는 다른 값으로 알린다 —
|
|
202
|
+
// 이 둘을 같은 null 로 뭉쳐 놨던 것이 /undo 가 파일을 지우던 원인이다.
|
|
203
|
+
if (looksBinary(buf)) return { before: null, skipped: '바이너리' };
|
|
204
|
+
const text = buf.toString('utf8');
|
|
205
|
+
// 되짚어 봐서 바이트가 그대로면 UTF-8 이 맞다.
|
|
206
|
+
if (Buffer.from(text, 'utf8').equals(buf)) return { before: text };
|
|
207
|
+
return { before: buf.toString('base64'), enc: 'b64' };
|
|
123
208
|
}
|
package/src/tools/encoding.js
CHANGED
|
@@ -246,7 +246,20 @@ export function consoleCodepage() {
|
|
|
246
246
|
return _cp;
|
|
247
247
|
}
|
|
248
248
|
|
|
249
|
-
/**
|
|
249
|
+
/**
|
|
250
|
+
* 바이트를 글로. 무엇으로 읽었는지도 같이 돌려준다.
|
|
251
|
+
*
|
|
252
|
+
* 돌려주는 encoding 은 **되돌려 쓸 때 그대로 넣을 이름**이다. 읽을 때 쓴 해독기
|
|
253
|
+
* 이름이 아니다. 이 둘이 갈리는 자리가 UTF-8 BOM 이다.
|
|
254
|
+
*
|
|
255
|
+
* BOM 있는 UTF-8 파일도 해독기는 그냥 'utf-8' 이면 된다 — 앞 3바이트를 잘라내고
|
|
256
|
+
* 넘기니까. 그런데 그 이름을 그대로 돌려주면 되돌려 쓸 때 BOM 이 없어진다.
|
|
257
|
+
* 한 글자 고쳤을 뿐인데 엑셀에서 CSV 한글이 깨지고, .ps1 이 오작동한다.
|
|
258
|
+
* 화면의 /diff 에는 의도한 변경만 보이니 원인을 연결할 방법이 없다.
|
|
259
|
+
*
|
|
260
|
+
* encode() 에는 'utf-8-bom' 을 받는 자리가 처음부터 있었다. 다만 그 이름을
|
|
261
|
+
* 만들어 주는 곳이 없어서 한 번도 안 불렸다 — 끊어져 있던 길을 여기서 잇는다.
|
|
262
|
+
*/
|
|
250
263
|
export function decode(buf, { fallback = null, system = null } = {}) {
|
|
251
264
|
const found = detect(buf, { fallback, system });
|
|
252
265
|
const body = found.bom ? buf.subarray(found.bom) : buf;
|
|
@@ -257,7 +270,8 @@ export function decode(buf, { fallback = null, system = null } = {}) {
|
|
|
257
270
|
text = body.toString('utf8');
|
|
258
271
|
return { text, encoding: 'utf-8', sure: false, why: `${found.id} 를 이 Node 가 모름` };
|
|
259
272
|
}
|
|
260
|
-
|
|
273
|
+
const 되돌릴이름 = found.id === 'utf-8' && found.bom ? 'utf-8-bom' : found.id;
|
|
274
|
+
return { text, encoding: 되돌릴이름, sure: found.sure, why: found.why, bom: found.bom ?? 0 };
|
|
261
275
|
}
|
|
262
276
|
|
|
263
277
|
// ── 되돌려 쓰기 ─────────────────────────────────────────────────────────
|
|
@@ -306,6 +320,14 @@ export function encode(text, encoding = 'utf-8') {
|
|
|
306
320
|
return { buf: Buffer.concat([Buffer.from([0xEF, 0xBB, 0xBF]), Buffer.from(text, 'utf8')]), lost: [] };
|
|
307
321
|
}
|
|
308
322
|
if (id === 'utf-16le') return { buf: Buffer.concat([Buffer.from([0xFF, 0xFE]), Buffer.from(text, 'utf16le')]), lost: [] };
|
|
323
|
+
if (id === 'utf-16be') {
|
|
324
|
+
// Node 는 utf16be 로 쓸 줄 모른다. LE 로 쓰고 두 바이트씩 뒤집으면 그게 BE 다.
|
|
325
|
+
// 없으면 아래 역표 만들기로 떨어지고, 그건 실패해서 조용히 UTF-8 이 된다 —
|
|
326
|
+
// 한 글자 고쳤을 뿐인데 파일 전체가 다른 인코딩이 되는 자리라 여기서 막는다.
|
|
327
|
+
const le = Buffer.from(text, 'utf16le');
|
|
328
|
+
for (let i = 0; i + 1 < le.length; i += 2) { const t = le[i]; le[i] = le[i + 1]; le[i + 1] = t; }
|
|
329
|
+
return { buf: Buffer.concat([Buffer.from([0xFE, 0xFF]), le]), lost: [] };
|
|
330
|
+
}
|
|
309
331
|
|
|
310
332
|
let map;
|
|
311
333
|
try { map = reverseTable(id); }
|
package/src/tools/fsutil.js
CHANGED
|
@@ -32,11 +32,81 @@ export function copyDir(from, to, { skipped = [] } = {}) {
|
|
|
32
32
|
return { skipped };
|
|
33
33
|
}
|
|
34
34
|
|
|
35
|
+
/**
|
|
36
|
+
* 다른 코딩 도구들이 제 살림을 넣어 두는 자리.
|
|
37
|
+
*
|
|
38
|
+
* 프로젝트 파일이 아니라 그 도구의 기록이다 — 지난 대화, 명령 이력, 캐시,
|
|
39
|
+
* 그리고 열쇠. 이 작업과 아무 상관이 없는데 훑을 때 걸려 나오면 모델이
|
|
40
|
+
* 그것부터 읽는다. 실제로 .claude/history.jsonl 을 읽어 컨텍스트를 채운 적이 있다.
|
|
41
|
+
*
|
|
42
|
+
* **목록은 여기 한 곳에만 둔다.** 전에는 훑는 쪽과 읽기 막는 쪽에 따로 적어
|
|
43
|
+
* 놨는데, 그러면 한쪽에만 새 이름을 넣는 날이 반드시 온다. 훑을 때는 안 걸리는데
|
|
44
|
+
* 이름을 대면 읽히는, 설명하기 어려운 상태가 된다.
|
|
45
|
+
*
|
|
46
|
+
* 새 도구는 계속 나온다. 여기 없는 이름이 보이면 그냥 한 줄 더하면 된다.
|
|
47
|
+
*/
|
|
48
|
+
export const 남의도구살림 = new Set([
|
|
49
|
+
'.claude', '.codex', '.cursor', '.gemini', '.aider', '.continue', '.cline',
|
|
50
|
+
'.roo', '.kilocode', '.windsurf', '.opencode', '.zed', '.trae', '.augment',
|
|
51
|
+
'.qodo', '.tabnine', '.cody', '.sourcegraph', '.copilot', '.amazonq', '.junie',
|
|
52
|
+
'.codeium', '.goose', '.crush', '.gptme', '.openhands', '.devin',
|
|
53
|
+
]);
|
|
54
|
+
|
|
55
|
+
// .aider.chat.history.md 처럼 폴더가 아니라 파일로 흘리는 것들도 있다.
|
|
56
|
+
export const 남의도구파일 = /^\.(aider|copilot|continue|cline|codeium|windsurf)[.-]/i;
|
|
57
|
+
|
|
35
58
|
export const SKIP_DIRS = new Set([
|
|
36
59
|
'node_modules', '.git', '.deel', '.svn', '.hg', 'dist', 'build',
|
|
37
60
|
'.next', '.nuxt', '.cache', '__pycache__', '.venv', 'venv', 'target',
|
|
61
|
+
...남의도구살림,
|
|
38
62
|
]);
|
|
39
63
|
|
|
64
|
+
/**
|
|
65
|
+
* 이 파일이 '읽어 봐야 도움이 안 되는 살림' 인가.
|
|
66
|
+
*
|
|
67
|
+
* 왜 막나:
|
|
68
|
+
* 실제로 이런 일이 있었다 —
|
|
69
|
+
* ◧ Read(~/.deel/audit.jsonl) 77줄
|
|
70
|
+
* ◧ Read(~/.claude/history.jsonl) 35줄
|
|
71
|
+
* 감사기록은 이 프로그램이 방금 무엇을 했는지 적어 둔 것이다. 그걸 다시 읽어
|
|
72
|
+
* 대화에 넣으면 모델이 제 그림자를 좇는다. 사용자가 시킨 일과는 아무 상관이
|
|
73
|
+
* 없고, 컨텍스트만 찬다.
|
|
74
|
+
*
|
|
75
|
+
* 설정 파일은 더 나쁘다. 게이트웨이 열쇠(apiKey)가 그 안에 있다.
|
|
76
|
+
* 읽는 순간 그 열쇠가 대화에 실려 모델로 나가고, 세션 기록으로 디스크에도 남는다.
|
|
77
|
+
* 열쇠를 그 열쇠의 주인에게 보내는 셈이다.
|
|
78
|
+
*
|
|
79
|
+
* 막는 것이지 숨기는 것이 아니다 — 왜 안 되는지 그대로 말해 준다.
|
|
80
|
+
* @returns {string|null} 막을 이유. 막을 것이 아니면 null.
|
|
81
|
+
*/
|
|
82
|
+
export function 내부살림(abs) {
|
|
83
|
+
const 조각 = String(abs ?? '').replace(/\\/g, '/').split('/');
|
|
84
|
+
const 이름 = 조각[조각.length - 1] ?? '';
|
|
85
|
+
const i = 조각.lastIndexOf('.deel');
|
|
86
|
+
if (i >= 0) {
|
|
87
|
+
const 안 = 조각.slice(i + 1);
|
|
88
|
+
if (안[0] === 'config.json') {
|
|
89
|
+
return 'deel 자신의 설정 파일입니다. 게이트웨이 열쇠가 들어 있어 읽지 않습니다.'
|
|
90
|
+
+ ' 연결 상태가 궁금하면 사용자에게 /status 를 쳐 보라고 하세요.';
|
|
91
|
+
}
|
|
92
|
+
if (['audit.jsonl', 'sessions', 'history'].includes(안[0])) {
|
|
93
|
+
return 'deel 자신의 기록입니다(무엇을 했는지 적어 둔 것). 지금 하는 일에 도움이 안 되고'
|
|
94
|
+
+ ' 컨텍스트만 차서 읽지 않습니다. 필요한 것은 대화에 이미 다 있습니다.';
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
// 남의 도구 살림. 목록은 남의도구살림 한 곳에만 있다 — 훑는 쪽과 같은 것을 본다.
|
|
98
|
+
const 걸린것 = 조각.find((seg) => 남의도구살림.has(seg)) ?? (남의도구파일.test(이름) ? 이름 : null);
|
|
99
|
+
if (걸린것) {
|
|
100
|
+
return `${걸린것} 은 다른 코딩 도구가 제 기록을 넣어 두는 자리입니다.`
|
|
101
|
+
+ ' 지난 대화·명령 이력·열쇠 같은 것이라 이 작업과 상관이 없고, 읽으면 컨텍스트만 찹니다.'
|
|
102
|
+
+ ' 정말 그 안의 내용이 필요하면 사용자에게 직접 물어보세요.';
|
|
103
|
+
}
|
|
104
|
+
if (이름 === 'audit.jsonl') {
|
|
105
|
+
return 'deel 자신의 기록입니다. 읽어도 지금 하는 일에 도움이 안 됩니다.';
|
|
106
|
+
}
|
|
107
|
+
return null;
|
|
108
|
+
}
|
|
109
|
+
|
|
40
110
|
// glob 을 정규식으로. ** * ? {a,b} [abc] 를 지원한다.
|
|
41
111
|
export function globToRegex(pattern) {
|
|
42
112
|
let re = '';
|