deel-local-cli 1.7.0 → 1.10.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.
Files changed (61) hide show
  1. package/README.ko.md +1166 -0
  2. package/README.md +1223 -1107
  3. package/bin/deel.js +78 -12
  4. package/package.json +4 -4
  5. package/src/acp/map.js +1 -1
  6. package/src/acp/serve.js +40 -3
  7. package/src/agent/compact.js +314 -296
  8. package/src/agent/effort.js +27 -7
  9. package/src/agent/evidence.js +2 -0
  10. package/src/agent/filemem.js +141 -0
  11. package/src/agent/grade.js +51 -1
  12. package/src/agent/loop.js +218 -40
  13. package/src/agent/session.js +136 -14
  14. package/src/agent/store.js +186 -9
  15. package/src/agent/threads.js +26 -1
  16. package/src/backend/adapter.js +272 -23
  17. package/src/backend/learn.js +24 -0
  18. package/src/backend/mcp.js +96 -5
  19. package/src/backend/probe.js +131 -61
  20. package/src/backend/quota.js +29 -5
  21. package/src/backend/retry.js +3 -0
  22. package/src/backend/scanui.js +1 -1
  23. package/src/backend/toolfit.js +325 -0
  24. package/src/commands.js +170 -61
  25. package/src/completion.js +20 -2
  26. package/src/config.js +47 -2
  27. package/src/i18n/en.js +210 -3
  28. package/src/i18n/index.js +40 -0
  29. package/src/i18n/ja.js +204 -3
  30. package/src/i18n/ko.js +270 -3
  31. package/src/i18n/zh.js +204 -3
  32. package/src/lsp/client.js +49 -5
  33. package/src/oneshot.js +92 -5
  34. package/src/pack/sbom.js +30 -4
  35. package/src/pack/selfpack.js +26 -10
  36. package/src/pack/sheet.en.js +288 -0
  37. package/src/pack/tar.js +65 -2
  38. package/src/plugins/manage.js +46 -9
  39. package/src/repl.js +344 -61
  40. package/src/reset.js +397 -0
  41. package/src/safety/audit.js +95 -8
  42. package/src/safety/authcmd.js +316 -0
  43. package/src/safety/guard.js +143 -0
  44. package/src/safety/keystore.js +114 -38
  45. package/src/safety/undo.js +25 -6
  46. package/src/tools/desc.en.js +19 -14
  47. package/src/tools/index.js +349 -75
  48. package/src/tools/jobs.js +175 -42
  49. package/src/tools/lsp.js +5 -4
  50. package/src/tools/outline.js +7 -3
  51. package/src/tools/task.js +10 -6
  52. package/src/tools/todo.js +9 -2
  53. package/src/tools/verify.js +42 -7
  54. package/src/tools/webfetch.js +94 -10
  55. package/src/ui/export.js +1 -1
  56. package/src/ui/md.js +201 -5
  57. package/src/ui/motion.js +0 -1
  58. package/src/ui/pastechip.js +50 -3
  59. package/src/ui/pick.js +115 -0
  60. package/src/ui/screen.js +23 -3
  61. package/README.en.md +0 -1117
@@ -0,0 +1,316 @@
1
+ // 열쇠를 우리가 갖고 있지 않고, 그때그때 **받아 온다.**
2
+ //
3
+ // ── 왜 필요한가 ────────────────────────────────────────────────────────
4
+ //
5
+ // 사내 게이트웨이는 고정된 열쇠를 안 준다. 한 시간짜리 토큰을 주고, 그것도
6
+ // 사내 로그인(SSO)을 거쳐야 나온다. 그러면 지금 구조로는 이렇게 된다.
7
+ //
8
+ // 1. 아침에 사내 포털에서 토큰을 복사한다
9
+ // 2. `deel setup` 에 붙여 넣는다
10
+ // 3. 점심 때 만료된다. 화면에는 `HTTP 401` 한 줄
11
+ // 4. 1번으로
12
+ //
13
+ // 이건 못 쓰는 물건이다. 그리고 3번이 제일 나쁘다 — 401 은 「열쇠가 틀렸다」
14
+ // 와 「열쇠가 늙었다」 를 구별해 주지 않아서, 사람은 열쇠를 다시 발급받으러
15
+ // 간다. 발급받아도 한 시간 뒤 같은 화면을 본다.
16
+ //
17
+ // 그래서 **열쇠를 적어 두는 대신, 열쇠를 얻는 방법을 적어 둔다.**
18
+ //
19
+ // "열쇠받기": { "명령": "az account get-access-token --query accessToken -o tsv",
20
+ // "수명": 3600 }
21
+ //
22
+ // ── 왜 이름이 `열쇠받기` 인가 ──────────────────────────────────────────
23
+ //
24
+ // 처음 계획은 `인증` 이었다. 그런데 프로필에는 이미 `auth` 가 있고, 그건
25
+ // **머리말 모양**(bearer · x-api-key · api-key)을 뜻한다. 한 덩이 안에
26
+ // `auth` 와 `인증` 이 서로 다른 뜻으로 나란히 앉으면, 둘 중 하나는 반드시
27
+ // 잘못 적힌다. 하는 일 그대로 부른다.
28
+ //
29
+ // ── 지키는 선 ──────────────────────────────────────────────────────────
30
+ //
31
+ // 1) **경로는 사람이 직접 적는다.** 찾아 주지 않는다. `az` 가 PATH 에 있나
32
+ // 보고 알아서 부르는 짓을 하면, 이 프로그램이 언제 무엇을 실행할지 사람이
33
+ // 모르게 된다. 못 적었으면 못 쓰는 것이고, 그게 맞다.
34
+ // 2) **딴 프로세스로 띄운다.** import 하지 않는다. 남이 적어 준 코드를 우리
35
+ // 프로세스 안에서 돌리면 그 코드가 우리 메모리(다른 열쇠·대화)를 본다.
36
+ // 3) **봉인(offline)에서는 안 부른다.** 이 안에서만 돌겠다고 해 놓고 사내
37
+ // 포털에 로그인하러 나가면 그건 약속을 깬 것이다.
38
+ // 4) **실을 자리가 없으면 안 부른다.** `auth: 'none'` 인 연결(로컬 Ollama 가
39
+ // 그렇다)에는 열쇠를 실을 머리말이 아예 없다. 브라우저만 뜨고 끝난다.
40
+ // 5) **한 번 묻고 기억한다.** 관리 정책이 준 명령은 안 묻는다 — 회사가 정한
41
+ // 것을 개인이 승인하는 모양은 뜻이 안 맞는다.
42
+ // 6) **디스크에 안 적는다.** 받은 토큰은 이 프로세스 메모리에만 있다. 파일에
43
+ // 적으면 지금 config.json 에 평문으로 두는 것과 같아진다.
44
+ // 7) **가린다.** 토큰도, 머리말 값도, 명령이 stderr 로 뱉은 것도. 사내 로그인
45
+ // 도구는 오류 메시지에 토큰을 통째로 찍는 것이 드물지 않다.
46
+ import { spawn } from 'node:child_process';
47
+ import { 셸명령 } from '../tools/shell.js';
48
+ import { isOffline } from './network.js';
49
+ import { 가리기 } from './secrets.js';
50
+
51
+ /** 기다려 주는 시간. 사내 로그인은 브라우저를 열고 사람을 기다린다. */
52
+ export const 기본기다림 = 180000;
53
+
54
+ /** 수명을 안 알려 줄 때 몇 초짜리로 볼까. 짧게 잡는다 — 늦게 아는 것보다 낫다. */
55
+ export const 기본수명 = 3300;
56
+
57
+ /*
58
+ * 만료 얼마 전부터 미리 받아 오나.
59
+ *
60
+ * 딱 만료 시각까지 쓰면, 보내는 순간에는 살아 있던 토큰이 게이트웨이에 닿을
61
+ * 때 죽어 있다. 그 한 번이 401 이고, 사람 눈에는 그냥 실패로 보인다.
62
+ */
63
+ export const 미리 = 60000;
64
+
65
+ let 마지막명령줄 = null;
66
+ /**
67
+ * 마지막으로 띄운 명령줄. 검사가 **열쇠가 명령줄에 안 실렸는지** 볼 때 쓴다.
68
+ * (safety/keystore.js 의 마지막명령줄 과 같은 뜻이다.)
69
+ */
70
+ export function 마지막명령() { return 마지막명령줄; }
71
+
72
+ let 받은것 = null; // { token, headers, 만료, 언제 } — 메모리에만. 파일로 안 나간다.
73
+ let 물어본것 = null; // 이번 판에 사람이 뭐라고 답했나 (true/false)
74
+
75
+ /** 검사와 `/model` 갈아타기가 부른다. */
76
+ export function 잊기() { 받은것 = null; 물어본것 = null; 마지막명령줄 = null; }
77
+
78
+ /** 지금 들고 있는 것. 없으면 null. 화면·심사서가 **토큰 없이** 상태만 볼 때. */
79
+ export function 지금상태() {
80
+ if (!받은것) return null;
81
+ return { 있음: true, 만료: 받은것.만료, 남은초: Math.max(0, Math.round((받은것.만료 - Date.now()) / 1000)) };
82
+ }
83
+
84
+ /**
85
+ * 이 프로필이 열쇠를 받아 오게 되어 있나.
86
+ *
87
+ * 정책이 이긴다. 회사가 "이 게이트웨이는 이 명령으로만" 이라고 정했으면
88
+ * 개인 설정이 그것을 못 바꾼다 — safety/policy.js 와 같은 순서다.
89
+ *
90
+ * @returns {{명령: string, 수명: number, 곳: '정책'|'설정'}|null}
91
+ */
92
+ export function 받기설정(프로필, { 정책값 = null } = {}) {
93
+ /*
94
+ * 영어 이름도 받는다.
95
+ *
96
+ * 설정 파일은 **사람이 손으로 적는 것**이다. `열쇠받기`·`명령`·`수명` 은
97
+ * 한글 자판이 없는 사람에게는 옮겨 적을 수조차 없는 글자다. 화면을 영어로
98
+ * 켤 수 있게 해 놓고 설정만 한글로 두면, 그 사람은 이 기능을 못 쓴다.
99
+ *
100
+ * 코드 안의 이름은 그대로 한국어다 — 그건 이 저장소의 뜻이라 안 바꾼다.
101
+ * 바깥에서 들어오는 이름만 두 벌 받는다 (price.js 의 입력·in·input 과 같다).
102
+ */
103
+ const 고르기 = (것, 곳) => {
104
+ const 명령 = String(것?.명령 ?? 것?.command ?? '').trim();
105
+ if (!명령) return null;
106
+ const 수명 = Number(것?.수명 ?? 것?.ttl);
107
+ return { 명령, 수명: Number.isFinite(수명) && 수명 > 0 ? Math.floor(수명) : 기본수명, 곳 };
108
+ };
109
+ const 칸 = (x) => x?.열쇠받기 ?? x?.authCommand ?? null;
110
+ return 고르기(칸(정책값), '정책') ?? 고르기(칸(프로필), '설정');
111
+ }
112
+
113
+ /**
114
+ * 지금 이 연결에서 열쇠받기를 써도 되나. 안 되면 왜 안 되는지를 같이 준다.
115
+ *
116
+ * 「쓸 수 있나」 와 「적혀 있나」 는 다른 물음이다. 적혀 있는데 봉인이라 안
117
+ * 부르는 것은 고장이 아니라 약속을 지키는 것이고, 그 사실이 화면에 보여야
118
+ * 사람이 왜 열쇠가 안 붙는지 안다.
119
+ */
120
+ export function 쓸수있나(설정, { auth = 'bearer', 봉인 = isOffline() } = {}) {
121
+ if (!설정) return { 된다: false, 왜: null };
122
+ if (봉인) return { 된다: false, 왜: '봉인(offline) 중이라 열쇠를 받으러 나가지 않습니다' };
123
+ /*
124
+ * 실을 자리가 없으면 안 받는다.
125
+ *
126
+ * 처음에는 「이 컴퓨터 안의 서버(127.0.0.1)면 안 받는다」 로 잡았다.
127
+ * 막으려던 것은 로컬 Ollama 에 사내 토큰을 실어 보내는 일이었는데,
128
+ * 주소로 가르면 **localhost 로 회사 게이트웨이를 중계하는 구성**까지
129
+ * 같이 막힌다 — 사이드카 프록시나 `kubectl port-forward` 가 그 모양이고,
130
+ * 그때는 주소만 이 안이지 열쇠는 진짜로 필요하다.
131
+ *
132
+ * 진짜 갈림은 주소가 아니라 `auth` 다. 로컬 Ollama 프로필은 `auth: 'none'`
133
+ * 이라 애초에 열쇠를 실을 자리가 없다 — 막으려던 것이 정확히 여기서 막힌다.
134
+ * 그리고 자리가 없는데 명령을 띄우면 브라우저만 뜨고 아무 일도 안 된다.
135
+ */
136
+ if (String(auth ?? 'none') === 'none') {
137
+ return { 된다: false, 왜: '이 연결은 열쇠를 안 씁니다 (auth: none) — 받아 봐야 실을 자리가 없습니다' };
138
+ }
139
+ return { 된다: true, 왜: null };
140
+ }
141
+
142
+ /*
143
+ * 토큰으로 볼 수 있는 글자.
144
+ *
145
+ * 사내 로그인 도구는 토큰만 깔끔하게 뱉지 않는다. 배너를 찍고, 「Logged in as
146
+ * …」 를 찍고, 그 다음 줄에 토큰을 찍는다. 그걸 통째로 Authorization 에 실으면
147
+ * 게이트웨이는 400 을 주고, 화면에는 열쇠가 틀린 것처럼 보인다.
148
+ *
149
+ * 그래서 **한 줄이고 토큰 글자만인 것**만 받는다. 아니면 거절하고, 무엇이
150
+ * 왔는지 첫 줄을 보여 준다 — 그래야 사람이 `--query` 를 붙일 줄 안다.
151
+ */
152
+ const 토큰글자 = /^[A-Za-z0-9._~+/=-]{16,8192}$/;
153
+
154
+ /**
155
+ * 명령이 뱉은 것을 읽는다.
156
+ *
157
+ * 두 가지를 받는다.
158
+ * · 토큰 한 줄
159
+ * · `{"token": "...", "expires_at": 1700000000, "headers": {"X-Tenant": "..."}}`
160
+ *
161
+ * @returns {{ok: true, token: string, headers: object, 만료: number|null}
162
+ * |{ok: false, 왜: string, 보인것: string}}
163
+ */
164
+ export function 읽기(글, { 수명 = 기본수명, 지금 = Date.now() } = {}) {
165
+ const s = String(글 ?? '').trim();
166
+ if (!s) return { ok: false, 왜: '아무것도 안 나왔습니다', 보인것: '' };
167
+
168
+ const 첫줄 = s.split('\n')[0].trim().slice(0, 120);
169
+
170
+ if (s.startsWith('{')) {
171
+ let 것;
172
+ try { 것 = JSON.parse(s); } catch (err) {
173
+ return { ok: false, 왜: `JSON 처럼 시작하는데 못 읽었습니다 (${err.message})`, 보인것: 첫줄 };
174
+ }
175
+ const token = String(것?.token ?? 것?.access_token ?? '').trim();
176
+ if (!token) return { ok: false, 왜: 'JSON 은 읽었는데 token 이 없습니다', 보인것: 첫줄 };
177
+ if (!토큰글자.test(token)) return { ok: false, 왜: 'token 에 토큰이 아닌 글자가 들었습니다', 보인것: 첫줄 };
178
+ /*
179
+ * expires_at 은 **초**로 온다(유닉스 시각). 밀리초로 주는 곳도 있어서
180
+ * 자릿수로 가른다 — 초로 읽어 버리면 1970년으로 계산돼 늘 만료로 보이고,
181
+ * 그러면 한마디마다 로그인 명령을 부른다.
182
+ */
183
+ const 값 = Number(것?.expires_at ?? 것?.expiresAt);
184
+ let 만료 = null;
185
+ if (Number.isFinite(값) && 값 > 0) 만료 = 값 > 1e12 ? 값 : 값 * 1000;
186
+ const headers = {};
187
+ for (const [k, v] of Object.entries(것?.headers ?? {})) {
188
+ // 머리말 이름에 못 쓰는 글자가 있으면 요청 자체가 안 만들어진다.
189
+ if (!/^[A-Za-z0-9!#$%&'*+.^_`|~-]+$/.test(String(k))) continue;
190
+ headers[String(k)] = String(v);
191
+ }
192
+ return { ok: true, token, headers, 만료: 만료 ?? 지금 + 수명 * 1000 };
193
+ }
194
+
195
+ if (!토큰글자.test(s)) {
196
+ const 줄수 = s.split('\n').length;
197
+ return {
198
+ ok: false,
199
+ 왜: 줄수 > 1
200
+ ? `${줄수}줄이 나왔습니다 — 토큰 한 줄만 나오게 해 주세요 (배너·안내문이 섞이면 게이트웨이가 거절합니다)`
201
+ : '토큰으로 보이지 않는 글자가 섞여 있습니다',
202
+ 보인것: 첫줄,
203
+ };
204
+ }
205
+ return { ok: true, token: s, headers: {}, 만료: 지금 + 수명 * 1000 };
206
+ }
207
+
208
+ /**
209
+ * 명령을 띄우고 나온 것을 읽는다. 여기서는 캐시도 승인도 안 본다 — 그건 위층.
210
+ *
211
+ * @returns {Promise<{ok: boolean, token?, headers?, 만료?, 왜?, 보인것?, ms: number}>}
212
+ */
213
+ export async function 한번받기(설정, { 기다림 = 기본기다림, signal = null, 알림 = null } = {}) {
214
+ const t0 = Date.now();
215
+ const { file, args } = 셸명령(설정.명령);
216
+ 마지막명령줄 = [file, ...args];
217
+
218
+ 알림?.({ type: '시작', 명령: 설정.명령 });
219
+
220
+ const 결과 = await new Promise((done) => {
221
+ let 나온것 = '';
222
+ let 탈난것 = '';
223
+ let 끝났나 = false;
224
+ let kid;
225
+ try {
226
+ kid = spawn(file, args, {
227
+ // stdin 은 안 연다. 여기서 사람에게 뭘 물어보는 명령이면 그건 설정이
228
+ // 잘못된 것이고, 열어 두면 그 자리에서 영영 멈춘다.
229
+ stdio: ['ignore', 'pipe', 'pipe'],
230
+ windowsHide: true,
231
+ });
232
+ } catch (err) {
233
+ return done({ ok: false, 왜: `명령을 못 띄웠습니다 (${err.message})`, 보인것: '' });
234
+ }
235
+ const 마치기 = (것) => { if (!끝났나) { 끝났나 = true; clearTimeout(시계); 끊기해제(); done(것); } };
236
+ const 시계 = setTimeout(() => {
237
+ try { kid.kill('SIGKILL'); } catch { /* 이미 죽었으면 그만 */ }
238
+ 마치기({ ok: false, 왜: `${Math.round(기다림 / 1000)}초를 기다렸는데 안 끝났습니다`, 보인것: '' });
239
+ }, 기다림);
240
+ const 끊기 = () => { try { kid.kill('SIGKILL'); } catch { /* 그만 */ } 마치기({ ok: false, 왜: '중단했습니다', 보인것: '' }); };
241
+ const 끊기해제 = () => signal?.removeEventListener?.('abort', 끊기);
242
+ if (signal?.aborted) return 끊기();
243
+ signal?.addEventListener?.('abort', 끊기, { once: true });
244
+
245
+ kid.stdout.on('data', (b) => { 나온것 += b; });
246
+ kid.stderr.on('data', (b) => { 탈난것 += b; });
247
+ kid.on('error', (err) => 마치기({ ok: false, 왜: `명령을 못 띄웠습니다 (${err.message})`, 보인것: '' }));
248
+ kid.on('close', (code) => {
249
+ if (code !== 0) {
250
+ /*
251
+ * 실패한 까닭은 stderr 에 있다. 그런데 사내 로그인 도구는 그 자리에
252
+ * 토큰을 통째로 찍기도 한다. 그래서 보여 주되 **가려서** 보여 준다.
253
+ */
254
+ const 첫줄 = 가리기(탈난것.trim().split('\n')[0] ?? '', {}).글.slice(0, 200);
255
+ return 마치기({ ok: false, 왜: `종료코드 ${code}`, 보인것: 첫줄 });
256
+ }
257
+ 마치기({ ...읽기(나온것, { 수명: 설정.수명 }), 나온바이트: 나온것.length });
258
+ });
259
+ });
260
+
261
+ const ms = Date.now() - t0;
262
+ 알림?.({ type: '끝', ok: !!결과.ok, ms });
263
+ return { ...결과, ms };
264
+ }
265
+
266
+ /**
267
+ * 쓸 열쇠를 내놓는다. 들고 있는 것이 아직 살아 있으면 그것, 아니면 받아 온다.
268
+ *
269
+ * @param {object} o
270
+ * @param {object} o.설정 받기설정() 이 준 것
271
+ * @param {boolean} [o.다시] 들고 있는 것을 버리고 새로 받는다 (401 을 맞았을 때)
272
+ * @param {Function} [o.물어보기] async () => boolean. 정책이 준 명령이면 안 부른다.
273
+ */
274
+ export async function 열쇠(설정, { 다시 = false, 물어보기 = null, signal = null, 알림 = null, 기다림 = 기본기다림 } = {}) {
275
+ if (!설정) return { ok: false, 왜: '열쇠받기가 설정되어 있지 않습니다' };
276
+
277
+ if (다시) 받은것 = null;
278
+ if (받은것 && 받은것.만료 - 미리 > Date.now()) {
279
+ return { ok: true, token: 받은것.token, headers: 받은것.headers, 만료: 받은것.만료, 그대로: true };
280
+ }
281
+
282
+ /*
283
+ * 물어보기.
284
+ *
285
+ * 정책이 준 명령은 안 묻는다 — 회사가 정한 것을 개인이 승인하는 모양은
286
+ * 뜻이 안 맞고, 어차피 아니라고 답할 수도 없다.
287
+ *
288
+ * 한 판에 한 번만 묻는다. 토큰이 한 시간짜리면 세 시간 일하는 동안 세 번
289
+ * 받아 오는데, 세 번 다 물으면 사람은 그냥 손이 가는 대로 누른다.
290
+ */
291
+ if (설정.곳 !== '정책' && 물어보기) {
292
+ if (물어본것 === false) return { ok: false, 왜: '이 판에서는 안 부르기로 했습니다' };
293
+ if (물어본것 === null) {
294
+ 물어본것 = !!(await 물어보기(설정));
295
+ if (!물어본것) return { ok: false, 왜: '이 판에서는 안 부르기로 했습니다' };
296
+ }
297
+ }
298
+
299
+ const r = await 한번받기(설정, { signal, 알림, 기다림 });
300
+ if (!r.ok) return r;
301
+ 받은것 = { token: r.token, headers: r.headers, 만료: r.만료, 언제: Date.now() };
302
+ return { ok: true, token: r.token, headers: r.headers, 만료: r.만료, ms: r.ms, 그대로: false };
303
+ }
304
+
305
+ /**
306
+ * 화면에 낼 글에서 받아 온 토큰을 지운다.
307
+ *
308
+ * 머리말 **값**도 같이 지운다. `X-Tenant: 12345` 는 토큰이 아니지만 사내
309
+ * 식별자이고, 그걸 화면 사진이나 심사서에 그대로 남길 까닭이 없다.
310
+ */
311
+ export function 가림(글) {
312
+ const 것들 = [];
313
+ if (받은것?.token) 것들.push(받은것.token);
314
+ for (const v of Object.values(받은것?.headers ?? {})) if (v) 것들.push(String(v));
315
+ return 가리기(String(글 ?? ''), { 열쇠들: 것들 }).글;
316
+ }
@@ -193,9 +193,15 @@ export function checkCommand(cmd) {
193
193
  */
194
194
  export function checkPaths(cmd, scope) {
195
195
  if (!scope) return true;
196
+ // 폴더를 옮기고 나면 뒤 낱말은 전부 맨 이름이라 아래 검사에 안 걸린다.
197
+ // 그래서 옮기는 것부터 먼저 본다 (폴더옮김검사 머리말).
198
+ const 옮긴자리 = 폴더옮김검사(String(cmd), scope);
196
199
  for (const t of 경로낱말(String(cmd))) {
197
200
  // 자료가 아닌 자리는 울타리를 안 묻는다 (봐주는자리 머리말).
198
201
  if (봐주는자리(t)) continue;
202
+ // cd 가 가리킨 자리는 위에서 **옮겨 간 자리 기준**으로 이미 봤다. 여기서
203
+ // 또 보면 뿌리 기준으로 풀려서 `cd src/a && cd ../..` 가 밖으로 읽힌다.
204
+ if (옮긴자리.has(t)) continue;
199
205
  let abs;
200
206
  try { abs = scope.resolve(t); }
201
207
  catch (e) {
@@ -369,6 +375,143 @@ export function 경로낱말(cmd) {
369
375
  return out;
370
376
  }
371
377
 
378
+ /*
379
+ * ── 폴더를 옮기면 그 뒤로는 볼 글자가 없다 ─────────────────────────────
380
+ *
381
+ * 위 경로낱말() 은 **경로처럼 생긴 것**만 고른다 — 빗금이 있거나 드라이브로
382
+ * 시작하는 것. 그래서 `cd .deel` 다음에 오는 `config.json` 은 낱말 후보로도
383
+ * 안 뽑혔고, 아무 검사도 안 받았다. 화면에서는 이랬다 —
384
+ *
385
+ * ▶ Bash(type .deel\config.json) └ 막힘 (열쇠가 든 파일입니다)
386
+ * ▶ Bash(cd .deel && type config.json) └ 그냥 읽혔다
387
+ *
388
+ * 같은 파일인데 한 줄은 막고 한 줄은 통과다. 그러면 잠근 뜻이 없을 뿐 아니라
389
+ * 더 나쁘다 — 시스템 프롬프트와 화면은 「막혀 있다」 고 말하는데 사실이
390
+ * 아니게 된다. 그 말이 거짓이 되면 모델은 그 말을 안 믿고, 사용자는 울타리가
391
+ * 있다고 믿는다. (checkPaths 머리말에 적어 둔 것과 같은 까닭이다.)
392
+ *
393
+ * 그래서 **들어가는 것 자체**를 위반으로 본다. 들어간 뒤에는 우리가 볼 수
394
+ * 있는 글자가 남지 않아서, 거기서 막을 방법이 아예 없기 때문이다.
395
+ *
396
+ * 여기서도 넓히지 않는다 — 안에서 도는 cd 는 전부 그대로 돈다. 모델이 하는
397
+ * 일의 절반이 `cd 하위폴더 && 무엇` 이라, 그걸 막으면 도구를 못 쓴다.
398
+ */
399
+ const 폴더옮김 = /^(cd|chdir|pushd|sl|set-location|push-location)$/i;
400
+ const 되돌리기 = /^(popd|pop-location)$/i;
401
+ /** 셸을 한 겹 씌워 부르는 것들. 안 들여다보면 `sh -c "cd .deel …"` 로 그냥 지나간다. */
402
+ const 셸꼴 = /^(?:.*[/\\])?(bash|sh|zsh|dash|ksh|fish|cmd|powershell|pwsh)(\.exe)?$/i;
403
+
404
+ /** 명령을 실행 단위로 자른다. 따옴표 안의 `;` 는 글자지 이음매가 아니다. */
405
+ function 마디들(cmd) {
406
+ const 마디 = [];
407
+ let 지금 = '';
408
+ let 따옴 = null;
409
+ for (let i = 0; i < cmd.length; i++) {
410
+ const c = cmd[i];
411
+ if (따옴) { 지금 += c; if (c === 따옴) 따옴 = null; continue; }
412
+ if (c === '"' || c === "'") { 따옴 = c; 지금 += c; continue; }
413
+ if (c === '\n' || c === ';') { 마디.push(지금); 지금 = ''; continue; }
414
+ if (c === '&' || c === '|') {
415
+ 마디.push(지금); 지금 = '';
416
+ if (cmd[i + 1] === c) i++; // && · ||
417
+ continue;
418
+ }
419
+ 지금 += c;
420
+ }
421
+ 마디.push(지금);
422
+ return 마디;
423
+ }
424
+
425
+ /** 한 마디를 낱말로. 따옴표는 벗기되, 벗겼다는 것은 남긴다(셸 한 겹을 알아보려고). */
426
+ function 낱말들(마디) {
427
+ const out = [];
428
+ const re = /"([^"]*)"|'([^']*)'|[^\s]+/g;
429
+ let m;
430
+ while ((m = re.exec(마디))) {
431
+ const 안 = m[1] ?? m[2];
432
+ out.push({ 글: 안 ?? m[0], 쌌나: 안 != null });
433
+ }
434
+ return out;
435
+ }
436
+
437
+ /**
438
+ * cd 가 어디로 가라는 것인가. 모르면 null — 모르는 것을 막으면 안 된다.
439
+ *
440
+ * 옵션은 건너뛴다: 파워셸의 `-Path`·`-LiteralPath`, 윈도우 cmd 의 `/d`.
441
+ * `/d` 만 콕 집어 본다 — `/etc` 도 빗금으로 시작하지만 그건 진짜 갈 자리다.
442
+ */
443
+ function 갈자리(뒤) {
444
+ for (const w of 뒤) {
445
+ if (!w.글) continue;
446
+ if (w.글.startsWith('-')) continue;
447
+ if (/^\/d$/i.test(w.글)) continue;
448
+ return w.글;
449
+ }
450
+ return null;
451
+ }
452
+
453
+ /**
454
+ * 폴더를 옮기는 명령을 따라가며, 옮겨 간 자리가 울타리 안인지 본다.
455
+ *
456
+ * @returns {Set<string>} 여기서 이미 본 cd 목적지들. 부르는 쪽은 이걸 두 번
457
+ * 보지 않는다 — 뿌리 기준으로 다시 풀면 `cd src/a && cd ../..` 가 밖이 된다.
458
+ */
459
+ function 폴더옮김검사(cmd, scope, 시작 = null, 깊이 = 0, 본것 = new Set()) {
460
+ let 여기 = 시작 ?? scope.root;
461
+ const 쌓은것 = [];
462
+ for (const 마디 of 마디들(cmd)) {
463
+ const 낱말 = 낱말들(마디);
464
+ if (!낱말.length) continue;
465
+ const 첫 = 낱말[0].글;
466
+
467
+ // 셸을 한 겹 씌운 것은 안을 들여다본다. 그 안의 cd 는 딴 살림이라
468
+ // (하위 셸이다) 우리 자리는 안 바뀐다 — 결과만 받고 여기는 그대로 둔다.
469
+ if (깊이 < 2 && 셸꼴.test(첫)) {
470
+ for (const w of 낱말.slice(1)) if (w.쌌나) 폴더옮김검사(w.글, scope, 여기, 깊이 + 1, 본것);
471
+ continue;
472
+ }
473
+ if (되돌리기.test(첫)) { 여기 = 쌓은것.pop() ?? 여기; continue; }
474
+ if (!폴더옮김.test(첫)) continue;
475
+
476
+ const 목적 = 갈자리(낱말.slice(1));
477
+ // 인자 없는 cd(집으로) · `cd -`(직전 자리) 는 어디로 가는지 여기서 모른다.
478
+ // 모르는 것을 막으면 멀쩡한 명령이 죽는다.
479
+ if (목적 == null || 목적 === '-') continue;
480
+ const 풀린 = 자리표풀기(목적);
481
+ // 못 푼 자리표가 남아 있으면(`$BUILD_DIR`) 역시 어디로 갈지 모른다.
482
+ if (/[$%]/.test(풀린)) continue;
483
+
484
+ const 열린 = MSYS풀기(풀린);
485
+ const abs = isAbsolute(열린) ? resolve(열린) : resolve(여기, 열린);
486
+ 본것.add(풀린);
487
+
488
+ // 프로그램이 있는 자리로 들어가는 것은 자료를 만지는 일이 아니다.
489
+ // 푼 값이 아니라 **적힌 글자**로 본다 — 윈도우에서 resolve('/usr/bin') 은
490
+ // `C:\usr\bin` 이 되어 목록에 안 걸린다 (checkPaths 도 낱말로 본다).
491
+ if (봐주는자리(풀린)) { 여기 = abs; continue; }
492
+ try { scope.resolve(abs); }
493
+ catch (e) {
494
+ if (e instanceof ScopeError) {
495
+ throw new ScopeError(`${e.message}\n 이 명령 안에 있습니다: ${cmd.slice(0, 120)}`);
496
+ }
497
+ throw e;
498
+ }
499
+ // `.deel` 폴더 자신은 내부살림() 이 안 잡는다 — 그건 그 **안의 파일**을
500
+ // 보는 검사다. 여기서는 폴더에 발을 들이는 것이 곧 위반이다.
501
+ const 조각 = abs.replace(/\\/g, '/').split('/');
502
+ const 이유 = 조각.includes('.deel')
503
+ ? 'deel 자신의 살림 폴더입니다. 그 안에 게이트웨이 열쇠가 든 config.json 이 있어'
504
+ + ' 들어가지 않습니다 — 들어가면 뒤 명령이 맨 이름만으로 그 파일에 닿습니다.'
505
+ + ' 연결 상태가 궁금하면 사용자에게 /status 를 쳐 보라고 하세요.'
506
+ : 내부살림(abs);
507
+ if (이유) throw new BlockedError(`${이유}\n 이 명령 안에 있습니다: ${cmd.slice(0, 120)}`);
508
+
509
+ if (/^(pushd|push-location)$/i.test(첫)) 쌓은것.push(여기);
510
+ 여기 = abs;
511
+ }
512
+ return 본것;
513
+ }
514
+
372
515
  /**
373
516
  * 변경성 동작은 실패해도 다시 실행하지 않는다 — 두 번 실행되면 사고다. (RPA 에서 얻은 원칙)
374
517
  *