deel-local-cli 1.6.3 → 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.
Files changed (53) hide show
  1. package/README.ko.md +1182 -0
  2. package/README.md +850 -636
  3. package/bin/deel.js +44 -3
  4. package/package.json +3 -3
  5. package/src/acp/map.js +1 -1
  6. package/src/acp/serve.js +25 -2
  7. package/src/agent/loop.js +41 -9
  8. package/src/agent/session.js +15 -9
  9. package/src/backend/adapter.js +277 -4
  10. package/src/backend/ctxsize.js +16 -2
  11. package/src/backend/detect.js +104 -6
  12. package/src/backend/price.js +197 -0
  13. package/src/backend/retry.js +3 -0
  14. package/src/backend/scanui.js +2 -1
  15. package/src/backend/vision.js +26 -4
  16. package/src/commands.js +141 -38
  17. package/src/completion.js +21 -2
  18. package/src/i18n/en.js +160 -1
  19. package/src/i18n/index.js +22 -0
  20. package/src/i18n/ja.js +152 -1
  21. package/src/i18n/ko.js +173 -1
  22. package/src/i18n/zh.js +152 -1
  23. package/src/oneshot.js +39 -6
  24. package/src/pack/selfpack.js +1 -1
  25. package/src/providers/anthropic.js +48 -0
  26. package/src/providers/bedrock.js +102 -0
  27. package/src/providers/custom.js +65 -0
  28. package/src/providers/gemini.js +64 -0
  29. package/src/providers/index.js +121 -0
  30. package/src/providers/openai.js +68 -0
  31. package/src/repl.js +198 -33
  32. package/src/report.js +3 -2
  33. package/src/reset.js +397 -0
  34. package/src/safety/audit.js +5 -2
  35. package/src/safety/authcmd.js +305 -0
  36. package/src/safety/keystore.js +53 -0
  37. package/src/safety/runmode.js +123 -0
  38. package/src/safety/secrets.js +61 -0
  39. package/src/safety/undo.js +3 -2
  40. package/src/setup.js +215 -12
  41. package/src/tools/desc.en.js +19 -14
  42. package/src/tools/index.js +141 -43
  43. package/src/tools/jobs.js +17 -13
  44. package/src/tools/lsp.js +5 -4
  45. package/src/tools/outline.js +7 -3
  46. package/src/tools/task.js +10 -6
  47. package/src/tools/todo.js +9 -2
  48. package/src/tools/verify.js +9 -3
  49. package/src/tools/webfetch.js +2 -1
  50. package/src/ui/export.js +1 -1
  51. package/src/ui/histline.js +67 -0
  52. package/src/ui/status.js +88 -3
  53. package/README.en.md +0 -1025
@@ -0,0 +1,305 @@
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
+ const 고르기 = (것, 곳) => {
94
+ const 명령 = String(것?.명령 ?? '').trim();
95
+ if (!명령) return null;
96
+ const 수명 = Number(것?.수명);
97
+ return { 명령, 수명: Number.isFinite(수명) && 수명 > 0 ? Math.floor(수명) : 기본수명, 곳 };
98
+ };
99
+ return 고르기(정책값?.열쇠받기, '정책') ?? 고르기(프로필?.열쇠받기, '설정');
100
+ }
101
+
102
+ /**
103
+ * 지금 이 연결에서 열쇠받기를 써도 되나. 안 되면 왜 안 되는지를 같이 준다.
104
+ *
105
+ * 「쓸 수 있나」 와 「적혀 있나」 는 다른 물음이다. 적혀 있는데 봉인이라 안
106
+ * 부르는 것은 고장이 아니라 약속을 지키는 것이고, 그 사실이 화면에 보여야
107
+ * 사람이 왜 열쇠가 안 붙는지 안다.
108
+ */
109
+ export function 쓸수있나(설정, { auth = 'bearer', 봉인 = isOffline() } = {}) {
110
+ if (!설정) return { 된다: false, 왜: null };
111
+ if (봉인) return { 된다: false, 왜: '봉인(offline) 중이라 열쇠를 받으러 나가지 않습니다' };
112
+ /*
113
+ * 실을 자리가 없으면 안 받는다.
114
+ *
115
+ * 처음에는 「이 컴퓨터 안의 서버(127.0.0.1)면 안 받는다」 로 잡았다.
116
+ * 막으려던 것은 로컬 Ollama 에 사내 토큰을 실어 보내는 일이었는데,
117
+ * 주소로 가르면 **localhost 로 회사 게이트웨이를 중계하는 구성**까지
118
+ * 같이 막힌다 — 사이드카 프록시나 `kubectl port-forward` 가 그 모양이고,
119
+ * 그때는 주소만 이 안이지 열쇠는 진짜로 필요하다.
120
+ *
121
+ * 진짜 갈림은 주소가 아니라 `auth` 다. 로컬 Ollama 프로필은 `auth: 'none'`
122
+ * 이라 애초에 열쇠를 실을 자리가 없다 — 막으려던 것이 정확히 여기서 막힌다.
123
+ * 그리고 자리가 없는데 명령을 띄우면 브라우저만 뜨고 아무 일도 안 된다.
124
+ */
125
+ if (String(auth ?? 'none') === 'none') {
126
+ return { 된다: false, 왜: '이 연결은 열쇠를 안 씁니다 (auth: none) — 받아 봐야 실을 자리가 없습니다' };
127
+ }
128
+ return { 된다: true, 왜: null };
129
+ }
130
+
131
+ /*
132
+ * 토큰으로 볼 수 있는 글자.
133
+ *
134
+ * 사내 로그인 도구는 토큰만 깔끔하게 뱉지 않는다. 배너를 찍고, 「Logged in as
135
+ * …」 를 찍고, 그 다음 줄에 토큰을 찍는다. 그걸 통째로 Authorization 에 실으면
136
+ * 게이트웨이는 400 을 주고, 화면에는 열쇠가 틀린 것처럼 보인다.
137
+ *
138
+ * 그래서 **한 줄이고 토큰 글자만인 것**만 받는다. 아니면 거절하고, 무엇이
139
+ * 왔는지 첫 줄을 보여 준다 — 그래야 사람이 `--query` 를 붙일 줄 안다.
140
+ */
141
+ const 토큰글자 = /^[A-Za-z0-9._~+/=-]{16,8192}$/;
142
+
143
+ /**
144
+ * 명령이 뱉은 것을 읽는다.
145
+ *
146
+ * 두 가지를 받는다.
147
+ * · 토큰 한 줄
148
+ * · `{"token": "...", "expires_at": 1700000000, "headers": {"X-Tenant": "..."}}`
149
+ *
150
+ * @returns {{ok: true, token: string, headers: object, 만료: number|null}
151
+ * |{ok: false, 왜: string, 보인것: string}}
152
+ */
153
+ export function 읽기(글, { 수명 = 기본수명, 지금 = Date.now() } = {}) {
154
+ const s = String(글 ?? '').trim();
155
+ if (!s) return { ok: false, 왜: '아무것도 안 나왔습니다', 보인것: '' };
156
+
157
+ const 첫줄 = s.split('\n')[0].trim().slice(0, 120);
158
+
159
+ if (s.startsWith('{')) {
160
+ let 것;
161
+ try { 것 = JSON.parse(s); } catch (err) {
162
+ return { ok: false, 왜: `JSON 처럼 시작하는데 못 읽었습니다 (${err.message})`, 보인것: 첫줄 };
163
+ }
164
+ const token = String(것?.token ?? 것?.access_token ?? '').trim();
165
+ if (!token) return { ok: false, 왜: 'JSON 은 읽었는데 token 이 없습니다', 보인것: 첫줄 };
166
+ if (!토큰글자.test(token)) return { ok: false, 왜: 'token 에 토큰이 아닌 글자가 들었습니다', 보인것: 첫줄 };
167
+ /*
168
+ * expires_at 은 **초**로 온다(유닉스 시각). 밀리초로 주는 곳도 있어서
169
+ * 자릿수로 가른다 — 초로 읽어 버리면 1970년으로 계산돼 늘 만료로 보이고,
170
+ * 그러면 한마디마다 로그인 명령을 부른다.
171
+ */
172
+ const 값 = Number(것?.expires_at ?? 것?.expiresAt);
173
+ let 만료 = null;
174
+ if (Number.isFinite(값) && 값 > 0) 만료 = 값 > 1e12 ? 값 : 값 * 1000;
175
+ const headers = {};
176
+ for (const [k, v] of Object.entries(것?.headers ?? {})) {
177
+ // 머리말 이름에 못 쓰는 글자가 있으면 요청 자체가 안 만들어진다.
178
+ if (!/^[A-Za-z0-9!#$%&'*+.^_`|~-]+$/.test(String(k))) continue;
179
+ headers[String(k)] = String(v);
180
+ }
181
+ return { ok: true, token, headers, 만료: 만료 ?? 지금 + 수명 * 1000 };
182
+ }
183
+
184
+ if (!토큰글자.test(s)) {
185
+ const 줄수 = s.split('\n').length;
186
+ return {
187
+ ok: false,
188
+ 왜: 줄수 > 1
189
+ ? `${줄수}줄이 나왔습니다 — 토큰 한 줄만 나오게 해 주세요 (배너·안내문이 섞이면 게이트웨이가 거절합니다)`
190
+ : '토큰으로 보이지 않는 글자가 섞여 있습니다',
191
+ 보인것: 첫줄,
192
+ };
193
+ }
194
+ return { ok: true, token: s, headers: {}, 만료: 지금 + 수명 * 1000 };
195
+ }
196
+
197
+ /**
198
+ * 명령을 띄우고 나온 것을 읽는다. 여기서는 캐시도 승인도 안 본다 — 그건 위층.
199
+ *
200
+ * @returns {Promise<{ok: boolean, token?, headers?, 만료?, 왜?, 보인것?, ms: number}>}
201
+ */
202
+ export async function 한번받기(설정, { 기다림 = 기본기다림, signal = null, 알림 = null } = {}) {
203
+ const t0 = Date.now();
204
+ const { file, args } = 셸명령(설정.명령);
205
+ 마지막명령줄 = [file, ...args];
206
+
207
+ 알림?.({ type: '시작', 명령: 설정.명령 });
208
+
209
+ const 결과 = await new Promise((done) => {
210
+ let 나온것 = '';
211
+ let 탈난것 = '';
212
+ let 끝났나 = false;
213
+ let kid;
214
+ try {
215
+ kid = spawn(file, args, {
216
+ // stdin 은 안 연다. 여기서 사람에게 뭘 물어보는 명령이면 그건 설정이
217
+ // 잘못된 것이고, 열어 두면 그 자리에서 영영 멈춘다.
218
+ stdio: ['ignore', 'pipe', 'pipe'],
219
+ windowsHide: true,
220
+ });
221
+ } catch (err) {
222
+ return done({ ok: false, 왜: `명령을 못 띄웠습니다 (${err.message})`, 보인것: '' });
223
+ }
224
+ const 마치기 = (것) => { if (!끝났나) { 끝났나 = true; clearTimeout(시계); 끊기해제(); done(것); } };
225
+ const 시계 = setTimeout(() => {
226
+ try { kid.kill('SIGKILL'); } catch { /* 이미 죽었으면 그만 */ }
227
+ 마치기({ ok: false, 왜: `${Math.round(기다림 / 1000)}초를 기다렸는데 안 끝났습니다`, 보인것: '' });
228
+ }, 기다림);
229
+ const 끊기 = () => { try { kid.kill('SIGKILL'); } catch { /* 그만 */ } 마치기({ ok: false, 왜: '중단했습니다', 보인것: '' }); };
230
+ const 끊기해제 = () => signal?.removeEventListener?.('abort', 끊기);
231
+ if (signal?.aborted) return 끊기();
232
+ signal?.addEventListener?.('abort', 끊기, { once: true });
233
+
234
+ kid.stdout.on('data', (b) => { 나온것 += b; });
235
+ kid.stderr.on('data', (b) => { 탈난것 += b; });
236
+ kid.on('error', (err) => 마치기({ ok: false, 왜: `명령을 못 띄웠습니다 (${err.message})`, 보인것: '' }));
237
+ kid.on('close', (code) => {
238
+ if (code !== 0) {
239
+ /*
240
+ * 실패한 까닭은 stderr 에 있다. 그런데 사내 로그인 도구는 그 자리에
241
+ * 토큰을 통째로 찍기도 한다. 그래서 보여 주되 **가려서** 보여 준다.
242
+ */
243
+ const 첫줄 = 가리기(탈난것.trim().split('\n')[0] ?? '', {}).글.slice(0, 200);
244
+ return 마치기({ ok: false, 왜: `종료코드 ${code}`, 보인것: 첫줄 });
245
+ }
246
+ 마치기({ ...읽기(나온것, { 수명: 설정.수명 }), 나온바이트: 나온것.length });
247
+ });
248
+ });
249
+
250
+ const ms = Date.now() - t0;
251
+ 알림?.({ type: '끝', ok: !!결과.ok, ms });
252
+ return { ...결과, ms };
253
+ }
254
+
255
+ /**
256
+ * 쓸 열쇠를 내놓는다. 들고 있는 것이 아직 살아 있으면 그것, 아니면 받아 온다.
257
+ *
258
+ * @param {object} o
259
+ * @param {object} o.설정 받기설정() 이 준 것
260
+ * @param {boolean} [o.다시] 들고 있는 것을 버리고 새로 받는다 (401 을 맞았을 때)
261
+ * @param {Function} [o.물어보기] async () => boolean. 정책이 준 명령이면 안 부른다.
262
+ */
263
+ export async function 열쇠(설정, { 다시 = false, 물어보기 = null, signal = null, 알림 = null, 기다림 = 기본기다림 } = {}) {
264
+ if (!설정) return { ok: false, 왜: '열쇠받기가 설정되어 있지 않습니다' };
265
+
266
+ if (다시) 받은것 = null;
267
+ if (받은것 && 받은것.만료 - 미리 > Date.now()) {
268
+ return { ok: true, token: 받은것.token, headers: 받은것.headers, 만료: 받은것.만료, 그대로: true };
269
+ }
270
+
271
+ /*
272
+ * 물어보기.
273
+ *
274
+ * 정책이 준 명령은 안 묻는다 — 회사가 정한 것을 개인이 승인하는 모양은
275
+ * 뜻이 안 맞고, 어차피 아니라고 답할 수도 없다.
276
+ *
277
+ * 한 판에 한 번만 묻는다. 토큰이 한 시간짜리면 세 시간 일하는 동안 세 번
278
+ * 받아 오는데, 세 번 다 물으면 사람은 그냥 손이 가는 대로 누른다.
279
+ */
280
+ if (설정.곳 !== '정책' && 물어보기) {
281
+ if (물어본것 === false) return { ok: false, 왜: '이 판에서는 안 부르기로 했습니다' };
282
+ if (물어본것 === null) {
283
+ 물어본것 = !!(await 물어보기(설정));
284
+ if (!물어본것) return { ok: false, 왜: '이 판에서는 안 부르기로 했습니다' };
285
+ }
286
+ }
287
+
288
+ const r = await 한번받기(설정, { signal, 알림, 기다림 });
289
+ if (!r.ok) return r;
290
+ 받은것 = { token: r.token, headers: r.headers, 만료: r.만료, 언제: Date.now() };
291
+ return { ok: true, token: r.token, headers: r.headers, 만료: r.만료, ms: r.ms, 그대로: false };
292
+ }
293
+
294
+ /**
295
+ * 화면에 낼 글에서 받아 온 토큰을 지운다.
296
+ *
297
+ * 머리말 **값**도 같이 지운다. `X-Tenant: 12345` 는 토큰이 아니지만 사내
298
+ * 식별자이고, 그걸 화면 사진이나 심사서에 그대로 남길 까닭이 없다.
299
+ */
300
+ export function 가림(글) {
301
+ const 것들 = [];
302
+ if (받은것?.token) 것들.push(받은것.token);
303
+ for (const v of Object.values(받은것?.headers ?? {})) if (v) 것들.push(String(v));
304
+ return 가리기(String(글 ?? ''), { 열쇠들: 것들 }).글;
305
+ }
@@ -235,3 +235,56 @@ export function 보관방식(값 = null) {
235
235
  if (process.platform === 'darwin') return '맥 키체인 · 저장된 열쇠 없음';
236
236
  return '파일 권한 0600 · 저장된 열쇠 없음';
237
237
  }
238
+
239
+ /**
240
+ * 잠금장치에서 열쇠를 지운다 (`deel reset model` 이 부른다).
241
+ *
242
+ * ── 왜 따로 있어야 하나 ────────────────────────────────────────────────
243
+ *
244
+ * 설정 파일만 지우면 **잠금장치에는 열쇠가 그대로 남는다.** 화면에는
245
+ * 「초기화했습니다」 가 뜨는데 열쇠는 살아 있는 상태다. 「초기화」 라고
246
+ * 말하려면 그것까지 지워야 하고, 못 지웠으면 못 지웠다고 말해야 한다.
247
+ *
248
+ * 두 방식이 다르게 생겼다:
249
+ *
250
+ * 윈도우(DPAPI) 잠근 덩이가 설정 파일 **안에** 있다. 파일을 지우면 같이
251
+ * 사라진다 — 잠금장치에 따로 남는 것이 없다. 그래서 여기서
252
+ * 할 일이 없고, 없다고 정직하게 답한다. 「지웠다」 고 하면
253
+ * 안 한 일을 했다고 말하는 것이다.
254
+ * 맥(키체인) 설정에는 이름표만 있고 실물은 로그인 키체인에 남는다.
255
+ * 이건 우리가 지워야 한다. 안 지우면 설정을 지운 뒤에도
256
+ * 키체인 앱에 `deel-gateway-key` 가 그대로 보인다.
257
+ *
258
+ * 열쇠는 여기서도 명령줄에 안 올린다 — 지울 때 넘기는 것은 이름뿐이다.
259
+ *
260
+ * @param {string|null} 값 설정에 적혀 있던 잠긴 값. 없으면 이 PC 방식으로 판단한다.
261
+ * @returns {{지움: boolean, 방식: 'dpapi'|'keychain'|'없음', 왜: string}}
262
+ */
263
+ export function 잠금지우기(값 = null) {
264
+ const m = 꼴.exec(String(값 ?? ''));
265
+ const 갈래 = m ? m[1] : (process.platform === 'darwin' ? 'keychain' : null);
266
+
267
+ if (갈래 === 'dpapi' || (!갈래 && process.platform === 'win32')) {
268
+ return { 지움: false, 방식: 'dpapi', 왜: '설정 파일 안에 있어서 파일과 같이 지워집니다' };
269
+ }
270
+ if (갈래 !== 'keychain') {
271
+ return { 지움: false, 방식: '없음', 왜: '잠금장치에 따로 둔 것이 없습니다' };
272
+ }
273
+ if (process.platform !== 'darwin') {
274
+ return { 지움: false, 방식: 'keychain', 왜: '맥에서 넣은 열쇠라 여기서는 못 지웁니다 — 그 맥에서 지우세요' };
275
+ }
276
+
277
+ const 계정 = userInfo().username;
278
+ const 인자 = ['delete-generic-password', '-a', 계정, '-s', 키체인이름];
279
+ 마지막인자 = ['security', ...인자];
280
+ const r = spawnSync('security', 인자, { encoding: 'utf8', timeout: 20000 });
281
+ if (r.error) return { 지움: false, 방식: 'keychain', 왜: r.error.message };
282
+ // 없는 것을 지우라고 해도 실패로 온다. 그건 탈이 아니라 이미 없는 것이다.
283
+ if (r.status !== 0) {
284
+ const 말 = (r.stderr ?? '').trim();
285
+ return /could not be found|SecKeychainSearchCopyNext/i.test(말)
286
+ ? { 지움: false, 방식: 'keychain', 왜: '키체인에 이미 없습니다' }
287
+ : { 지움: false, 방식: 'keychain', 왜: 말 || 'security 가 실패했습니다' };
288
+ }
289
+ return { 지움: true, 방식: 'keychain', 왜: '' };
290
+ }
@@ -0,0 +1,123 @@
1
+ /*
2
+ * 실행 모드 — 지금 이 deel 이 어디까지 나갈 수 있나.
3
+ *
4
+ * ── 왜 나누나 ───────────────────────────────────────────────────────────
5
+ *
6
+ * 여태 자물쇠는 `--offline` 하나였고, **기본은 열림**이었다. 설정 파일의
7
+ * baseUrl 한 줄만 바꾸면 그대로 바깥으로 나갔다. 화면에 ↗ 가 뜨긴 했지만
8
+ * 그건 알리는 것이지 막는 것이 아니다.
9
+ *
10
+ * 「완전 로컬」 이 이 도구의 정체성인데, 코드가 지키는 것은 그 절반이었다.
11
+ * 여기서 기본값을 뒤집는다 — **말하지 않으면 안 나간다.**
12
+ *
13
+ * ── 셋으로 나눈다 ───────────────────────────────────────────────────────
14
+ *
15
+ * ⌂ 이 안 (기본) 사람이 대화창에서 쓸 때.
16
+ * 로컬·사내망은 그냥 간다. 바깥은 한 번 묻고 기억한다.
17
+ * ↗ 바깥 --online 스크립트 · CI · run -p. 물어볼 사람이 없는 자리.
18
+ * 묻지 않는다. 그래도 어디로 가는지는 화면에 남긴다.
19
+ * ⛊ 봉인 --offline 심사 · 사내 규정 · 비행기.
20
+ * 기억해 둔 허가까지 무시한다. 제일 센 상태.
21
+ *
22
+ * 이름만 다른 모드는 자물쇠가 아니라 장식이다. 셋이 각각 다른 일을 한다.
23
+ *
24
+ * ── 봉인은 「인터넷 없음」이 아니다 ─────────────────────────────────────
25
+ *
26
+ * 사내망(10.x · 172.16~31.x · 192.168.x)은 봉인에서도 「이 안」이다. 지키려는
27
+ * 경계는 **회사 밖으로 안 나간다**이지 랜선을 뽑는 것이 아니다. 사람들이 제일
28
+ * 자주 오해하는 자리라 화면 글에도 그대로 적는다 (safety/network.js 의
29
+ * isLocalHost 가 그 판단을 갖고 있다 — 여기서 두 벌로 만들지 않는다).
30
+ *
31
+ * ── 표 하나로 둔다 ──────────────────────────────────────────────────────
32
+ *
33
+ * if 를 세 번 쓰지 않는다. 넷째 모드(사내 정책·감사)를 붙일 때 줄 하나만
34
+ * 더하면 되게 열어 둔다.
35
+ */
36
+ import { isLocalHost } from './network.js';
37
+
38
+ export const 모드들 = [
39
+ {
40
+ id: '이안',
41
+ 깃발: null,
42
+ 글자: '⌂',
43
+ 이름: '이 안',
44
+ 영: 'default',
45
+ 한줄: '로컬·사내망은 그냥 갑니다. 바깥은 한 번 물어보고 기억합니다.',
46
+ 물어보나: true,
47
+ 허가무시: false,
48
+ },
49
+ {
50
+ id: '바깥',
51
+ 깃발: 'online',
52
+ 글자: '↗',
53
+ 이름: '바깥',
54
+ 영: 'online',
55
+ 한줄: '묻지 않고 나갑니다. 스크립트·CI 처럼 물어볼 사람이 없는 자리입니다.',
56
+ 물어보나: false,
57
+ 허가무시: false,
58
+ },
59
+ {
60
+ id: '봉인',
61
+ 깃발: 'offline',
62
+ 글자: '⛊',
63
+ 이름: '봉인',
64
+ 영: 'offline',
65
+ 한줄: '기억해 둔 허가까지 무시하고 막습니다. 사내망은 그대로 갑니다.',
66
+ 물어보나: false,
67
+ 허가무시: true,
68
+ },
69
+ ];
70
+
71
+ const 기본 = 모드들[0];
72
+
73
+ /** id 나 깃발 이름으로 모드 하나를 집는다. 모르면 기본이다. */
74
+ export function 모드고르기(무엇) {
75
+ const s = String(무엇 ?? '');
76
+ return 모드들.find((m) => m.id === s || m.깃발 === s || m.영 === s) ?? 기본;
77
+ }
78
+
79
+ /**
80
+ * 지금 어느 모드인가.
81
+ *
82
+ * 봉인이 언제나 이긴다. 「막아 달라」 와 「나가게 해 달라」 가 같이 오면
83
+ * 막는 쪽을 따르는 것이 자물쇠의 기본이다 — 반대로 하면 `--offline` 을
84
+ * 붙였는데 나가는 일이 생기고, 그건 자물쇠가 없느니만 못하다.
85
+ */
86
+ export function 지금모드({ online = false, offline = false } = {}) {
87
+ if (offline) return 모드고르기('offline');
88
+ if (online) return 모드고르기('online');
89
+ return 기본;
90
+ }
91
+
92
+ /**
93
+ * 이 주소가 바깥인가.
94
+ *
95
+ * 못 읽는 주소는 바깥으로 친다. 모르면 조이는 쪽이다 — 읽을 수 없는 주소를
96
+ * 「이 안」 으로 봐 주면, 이상하게 생긴 주소 하나로 자물쇠가 풀린다.
97
+ */
98
+ export function 바깥인가(baseUrl) {
99
+ try { return !isLocalHost(new URL(String(baseUrl)).hostname); } catch { return true; }
100
+ }
101
+
102
+ /**
103
+ * 지금 나가도 되나.
104
+ *
105
+ * @param 모드 지금모드() 가 준 것
106
+ * @param 바깥 바깥인가(conn.base)
107
+ * @param 허가 이 프로필에 「바깥으로 나가도 된다」 가 붙어 있나 (profile.online)
108
+ * @returns {{되나: boolean, 물어볼까?: boolean, 왜?: string}}
109
+ *
110
+ * 주소와 허가를 **뗀 것**이 요점이다. 주소만 바꿔서는 못 나가고, 두 군데를
111
+ * 다 고쳐야 나간다 — 실수로는 안 되고, 마음먹어야 된다.
112
+ */
113
+ export function 나갈수있나(모드, { 바깥 = false, 허가 = false } = {}) {
114
+ if (!바깥) return { 되나: true, 왜: '이안' };
115
+ if (모드.허가무시) return { 되나: false, 왜: '봉인' };
116
+ if (모드.물어보나 && !허가) return { 되나: false, 물어볼까: true, 왜: '허가없음' };
117
+ return { 되나: true, 왜: 허가 ? '허가있음' : '모드가열음' };
118
+ }
119
+
120
+ /** 화면에 붙일 한 조각 — `⌂ 이 안` · `↗ 바깥`. 색은 부르는 쪽이 칠한다. */
121
+ export function 모드글(모드) {
122
+ return `${모드.글자} ${모드.이름}`;
123
+ }
@@ -235,3 +235,64 @@ export function 주소가리기(주소) {
235
235
  * 되돌려 쓸 수 있으므로 절대 안 가린다 — 위 머리말 참고.
236
236
  */
237
237
  export const 가릴도구 = new Set(['Bash', 'Verify', 'Jobs', 'WebFetch']);
238
+
239
+ /**
240
+ * 이번 도구 결과를 가릴까.
241
+ *
242
+ * ── 바깥으로 나가면 저울이 뒤집힌다 ─────────────────────────────────────
243
+ *
244
+ * 위 머리말이 「파일 내용은 안 가린다」 로 정한 것은, 로컬 모델만 쓰던 때의
245
+ * 저울이다. 가려서 잃는 것(사람의 열쇠가 지워짐)이, 얻는 것(어차피 이 컴퓨터
246
+ * 밖으로 안 나가는 글을 가림)보다 컸다.
247
+ *
248
+ * 바깥으로 나가면 얻는 쪽이 훨씬 무거워진다. Read 한 번이면 `.env` 가 통째로
249
+ * 남의 서버 로그에 남고, 그건 되돌릴 수가 없다.
250
+ *
251
+ * 그리고 잃는 쪽은 이제 다른 자리에서 막는다 — Write·Append·Edit 이 가린
252
+ * 표를 파일에 못 쓴다(tools/index.js 의 가린표되돌리나). 그 자물쇠가 먼저
253
+ * 생겼기 때문에 여기서 가릴 수 있게 된 것이다. 순서가 중요하다.
254
+ *
255
+ * @param {string} 도구이름
256
+ * @param {{바깥?: boolean}} o 바깥 = 이 연결이 이 컴퓨터 밖으로 나가나
257
+ */
258
+ export function 가릴까(도구이름, { 바깥 = false } = {}) {
259
+ return 가릴도구.has(도구이름) || 바깥 === true;
260
+ }
261
+
262
+ /*
263
+ * ── 가린 표가 파일로 되돌아가는 것을 막는다 ─────────────────────────────
264
+ *
265
+ * 위 머리말이 걱정한 바로 그 일이, 파일을 안 가려도 일어날 수 있다.
266
+ *
267
+ * 1. 모델이 `cat .env` 를 부른다 → Bash 라서 우리가 가린다
268
+ * 2. 모델은 «가림:환경변수» 를 진짜 값으로 알고
269
+ * 3. Write 로 `.env` 를 다시 쓴다 → **진짜 열쇠가 있던 자리에 표가 적힌다**
270
+ *
271
+ * 가렸다는말() 로 "이건 진짜 값이 아니다" 라고 일러 주고는 있지만, 그건 부탁이지
272
+ * 자물쇠가 아니다. 못 알아들은 모델 하나면 사람의 열쇠가 없어진다. 되돌리기가
273
+ * 있어도 사람은 무엇이 없어졌는지조차 모른다 — 파일은 멀쩡해 보인다.
274
+ *
275
+ * 그래서 쓰는 자리에서 막는다. 이 표는 **우리가 만든 글자**라, 파일에 들어갈
276
+ * 이유가 처음부터 없다.
277
+ */
278
+
279
+ /** 가린 자리에 남는 표. 종류가 붙은 것과 안 붙은 것(주소가리기) 둘 다. */
280
+ export const 표무늬 = /«가림(?::[^»]*)?»/g;
281
+
282
+ /** 이 글에 가린 표가 몇 군데 있나. */
283
+ export function 표몇군데(글) {
284
+ return (String(글 ?? '').match(표무늬) ?? []).length;
285
+ }
286
+
287
+ /**
288
+ * 파일에 쓰려는 글에 표가 섞였을 때 할 말.
289
+ *
290
+ * 무엇을 해야 하는지까지 적는다. "안 됩니다" 만 하면 모델은 같은 것을 다시
291
+ * 보내거나, 표를 지우고 빈 값으로 써서 결국 열쇠를 없앤다.
292
+ */
293
+ export function 표막는말(보인이름, 몇) {
294
+ return `쓰려는 내용에 deel 이 가린 표(«가림:…»)가 ${몇}군데 있습니다: ${보인이름}\n`
295
+ + ' 그건 진짜 값이 아니라 비밀을 가린 자리입니다. 그대로 쓰면 그 자리의 진짜 값이 없어집니다.\n'
296
+ + ' 이 파일을 고쳐야 한다면 Edit 으로 **가린 자리를 건드리지 않는 부분만** 바꾸세요.'
297
+ + ' 그 값 자체가 필요하면 사용자에게 물어보세요.';
298
+ }
@@ -3,6 +3,7 @@
3
3
  import { join, dirname } from 'node:path';
4
4
  import { readFileSync, writeFileSync, existsSync, mkdirSync, rmSync, appendFileSync, statSync } from 'node:fs';
5
5
  import { looksBinary } from '../tools/encoding.js';
6
+ import { 말 } from '../i18n/index.js';
6
7
 
7
8
  // 되돌리기 이력은 파일 내용을 통째로 담는다. 이만큼 커지면 오래된 턴을 버린다.
8
9
  const MAX_BYTES = 32 * 1024 * 1024;
@@ -167,7 +168,7 @@ export class History {
167
168
  continue;
168
169
  }
169
170
  rmSync(path, { force: true });
170
- restored.push({ path, how: '삭제됨(원래 없던 파일)' });
171
+ restored.push({ path, how: 말('undo.wayDeleted') });
171
172
  } else {
172
173
  /*
173
174
  * 담고 있던 폴더가 없어졌을 수 있다 — Move 로 폴더째 옮긴 경우다.
@@ -178,7 +179,7 @@ export class History {
178
179
  mkdirSync(dirname(path), { recursive: true });
179
180
  // enc 가 붙어 있으면 UTF-8 로 담을 수 없던 파일이다 — 바이트를 그대로 되돌린다.
180
181
  writeFileSync(path, rec.enc === 'b64' ? Buffer.from(rec.before, 'base64') : Buffer.from(rec.before, 'utf8'));
181
- restored.push({ path, how: '되돌림' });
182
+ restored.push({ path, how: 말('undo.wayRestored') });
182
183
  }
183
184
  } catch (err) {
184
185
  restored.push({ path, how: `실패: ${err.message}` });