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.
@@ -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
+ }
@@ -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}` });
@@ -25,8 +25,12 @@
25
25
  * Bash “끝나지 않는 것은 background” · 시간 초과로 죽는 것을 막는 자리
26
26
  * Verify“확인 못 한 것은 못 했다고” · 이 프로그램이 거짓말을 안 하게 하는 자리
27
27
  *
28
- * 도구 이름과 인자 이름은 **안 옮긴다.** 그건 식별자다. Task 목적·할일처럼
29
- * 한글로 인자 이름도 그대로 둔다 이름을 바꾸면 도구가 아예 안 불린다.
28
+ * 도구 이름과 인자 이름은 **안 옮긴다.** 그건 식별자다 이름을 바꾸면 그 도구가
29
+ * 아예 불린다. 여기 적힌 이름은 tools/*.js 설명서와 **글자까지 같아야**
30
+ * 한다. 다르면 그 인자만 영어 설명이 조용히 빠진다.
31
+ *
32
+ * 인자 이름은 전부 영문이다. 우리 취향이 아니라 서버가 검사한다 — Bedrock 은
33
+ * `^[a-zA-Z0-9_.-]{1,64}$` 가 아니면 첫 한마디를 통째로 400 으로 돌려보낸다.
30
34
  */
31
35
  export const 도구설명EN = {
32
36
  Read: {
@@ -134,14 +138,14 @@ export const 도구설명EN = {
134
138
  },
135
139
  },
136
140
  Ask: {
137
- desc: 'Ask the person one question at a genuine fork. **First fill in `이해` with what you'
141
+ desc: 'Ask the person one question at a genuine fork. **First fill in `understanding` with what you'
138
142
  + ' understood this request to be**, and only ask if something is genuinely left to decide.'
139
143
  + ' Never write "let me know" and stop; asking in prose ends the turn and throws away'
140
144
  + ' everything you have looked at. Give 2-4 options and they answer with a single number.'
141
145
  + ' **Never ask back something the user already said.** "Tidy up the files" is the answer —'
142
146
  + ' go and do it. Deciding how is your job, not theirs.',
143
147
  params: {
144
- 이해: 'one line: what you understood this request to be. The person reads this line to judge'
148
+ understanding: 'one line: what you understood this request to be. The person reads this line to judge'
145
149
  + ' whether you actually read them. "I understand your request" is not an understanding —'
146
150
  + ' name the actual work',
147
151
  question: 'one sentence. Make it clear what has to be decided',
@@ -197,14 +201,14 @@ export const 도구설명EN = {
197
201
  + ' not pile up in your window. That is why work that creates or edits several files has to be'
198
202
  + ' divided this way to get to the end. One chunk must be finishable on its own (e.g. "create'
199
203
  + ' index.html and style.css"). The subtask cannot see your conversation — put everything it'
200
- + ' needs into 할일. Do not use this for one short job. Doing it yourself is faster.',
204
+ + ' needs into `task`. Do not use this for one short job. Doing it yourself is faster.',
201
205
  params: {
202
- 목적: 'this chunk in one line (e.g. "build the dashboard page skeleton")',
203
- 할일: 'everything the subtask has to do. It cannot see this conversation, so put the background,'
206
+ purpose: 'this chunk in one line (e.g. "build the dashboard page skeleton")',
207
+ task: 'everything the subtask has to do. It cannot see this conversation, so put the background,'
204
208
  + ' the decisions, and the file paths here. Say what counts as done, too.',
205
- 모드: 'how the subtask works: code (builds and edits) · debug (finds causes) · ask (reads and'
209
+ mode: 'how the subtask works: code (builds and edits) · debug (finds causes) · ask (reads and'
206
210
  + ' answers only). Defaults to code.',
207
- 모델: 'hand this chunk to a **different model**. Only profile names the user has configured'
211
+ model: 'hand this chunk to a **different model**. Only profile names the user has configured'
208
212
  + ' work (do not invent an address — it will not be accepted). Left out, it stays on the model'
209
213
  + ' you are using. Handing routine work (formatting, repetitive edits, short summaries) to a'
210
214
  + ' small model keeps your window from filling. Do the work that needs judgement yourself.',
@@ -236,12 +240,13 @@ export const 도구설명EN = {
236
240
  },
237
241
  },
238
242
  Jobs: {
239
- desc: 'List, read, and end background commands (Bash with background). Called with no number,'
240
- + ' you get the list. Given a number, you get whatever output arrived since last time.'
241
- + ' If you started a server, you must end it when the job is done.',
243
+ desc: 'List, read, and end background commands (Bash with background). Called with no `job`,'
244
+ + ' you get the list. Given a `job`, you get whatever output arrived since last time.'
245
+ + ' If you started a server, you must end it with `stop` when the job is done.',
242
246
  params: {
243
- 번호: 'job number to look at. Left out, the list',
244
- 끝내기: 'true to end that job (stop)',
247
+ job: 'job number to look at. Left out, the list',
248
+ stop: 'true to end that job',
249
+ from_start: 'true to read from the beginning again',
245
250
  },
246
251
  },
247
252
  };