deel-local-cli 1.12.0 → 1.13.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/config.js CHANGED
@@ -1,244 +1,283 @@
1
- // 연결 프로필 저장/읽기. ~/.deel/config.json (프로젝트 폴더의 .deel/config.json 이 우선)
2
- import { homedir } from 'node:os';
3
- import { join, resolve } from 'node:path';
4
- import { readFileSync, writeFileSync, mkdirSync, existsSync, chmodSync } from 'node:fs';
5
- import { 프록시정하기 } from './backend/proxy.js';
6
- import { 셸정하기 } from './tools/shell.js';
7
- import { 애저정하기 } from './backend/azure.js';
8
- import { 정책읽기 } from './safety/policy.js';
9
- import { 잠그기, 풀기, 잠긴것인가, 쓸수있나, 보관방식 } from './safety/keystore.js';
10
-
11
- // 설정이 놓이는 자리.
12
- //
13
- // DEEL_HOME 을 주면 그 폴더를 쓴다. 두 군데서 필요했다 —
14
- // 1) 사내에서 USB·공유폴더에 통째로 넣어 쓰는 휴대용 설치.
15
- // 집 폴더가 로밍 프로필이면 설정이 엉뚱한 데로 따라다닌다.
16
- // 2) 검사. 예전에는 검사가 진짜 설정 파일에 값을 써 버렸다.
17
- // 실제로 /level 검사가 사용자 설정을 바꾼 것을 보고 이걸 넣었다.
18
- //
19
- // 모듈을 읽을 때가 아니라 쓸 때마다 본다. 그래야 부르는 쪽에서 언제 정하든 먹는다.
20
- function userDir() {
21
- return process.env.DEEL_HOME ? resolve(process.env.DEEL_HOME) : join(homedir(), '.deel');
22
- }
23
- function projectDir() {
24
- return join(process.cwd(), '.deel');
25
- }
26
-
27
- /**
28
- * 이 PC 의 설정 폴더 (`~/.deel` 또는 DEEL_HOME).
29
- *
30
- * configPath() 와 다르다 — 저쪽은 프로젝트에 설정이 있으면 그쪽을 준다.
31
- * 여기는 언제나 **이 PC 것**이다. 모델에 대해 알아낸 것처럼 폴더를 옮겨도
32
- * 따라와야 하는 것을 여기 둔다 (agent/evolve.js).
33
- */
34
- export function homeDir() { return userDir(); }
35
-
36
- export function configPath() {
37
- const local = join(projectDir(), 'config.json');
38
- if (existsSync(local)) return local;
39
- return join(userDir(), 'config.json');
40
- }
41
-
42
- const EMPTY = { version: 1, active: null, profiles: [] };
43
-
44
- /*
45
- * 열쇠를 이 PC 의 잠금장치에 옮긴다 (safety/keystore.js).
46
- *
47
- * 예전 판이 평문으로 적어 둔 설정이 그대로 남아 있다. 사람더러 다시 넣으라고
48
- * 하면 대부분 안 한다 — 지금 잘 되고 있으니까. 그래서 **처음 읽을 때 한 번**
49
- * 조용히 옮기고, 옮겼다는 것만 한 줄로 알린다.
50
- *
51
- * 못 잠그면 파일을 아예 안 건드린다. 반쯤 잠근 파일을 남기면 그 뒤로는
52
- * 무엇이 잠긴 것이고 무엇이 평문인지 아무도 모른다.
53
- *
54
- * DEEL_KEYSTORE=off 면 안 한다 — 잠금장치가 정책으로 막힌 곳에서 매번
55
- * 파워셸을 두드리며 느려지는 것을 사람이 끌 수 있어야 한다.
56
- */
57
- let 옮겨봤나 = false;
58
- let 소식 = null;
59
-
60
- /** 열쇠를 옮겼으면 그 한 줄을 준다. 한 번 읽으면 지워진다 (두 번 안 알린다). */
61
- export function 잠금소식() { const s = 소식; 소식 = null; return s; }
62
-
63
- /** 화면·심사서에 그대로 쓰는 '열쇠를 어디에 두나' 한 줄. */
64
- export function 열쇠보관(cfg = null) {
65
- const p = cfg ? activeProfile(cfg) : null;
66
- return 보관방식(p?.apiKey ?? null);
67
- }
68
-
69
- function 잠금옮기기(cfg) {
70
- if (옮겨봤나 || process.env.DEEL_KEYSTORE === 'off') return;
71
- 옮겨봤나 = true;
72
- const 평문 = (cfg.profiles ?? []).filter((p) => p?.apiKey && !잠긴것인가(p.apiKey));
73
- if (!평문.length) return;
74
- if (!쓸수있나().되나) return;
75
-
76
- let 옮김 = 0;
77
- for (const p of 평문) {
78
- const 잠근것 = 잠그기(p.apiKey);
79
- if (잠근것) { p.apiKey = 잠근것; 옮김 += 1; }
80
- }
81
- if (!옮김) return;
82
- try {
83
- // 읽은 자리에 그대로 되쓴다. 프로젝트 설정을 홈으로 옮겨 버리면
84
- // 이 폴더에서만 쓰던 열쇠가 온 컴퓨터의 기본값이 된다.
85
- const 프로젝트것 = configPath() === join(projectDir(), 'config.json');
86
- save(cfg, { toProject: 프로젝트것 });
87
- const 방식 = 쓸수있나().방식 === 'keychain' ? '키체인' : 'DPAPI';
88
- 소식 = 옮김 === 1
89
- ? `게이트웨이 열쇠를 이 PC 계정에서만 풀리게 잠갔습니다 (${방식})`
90
- : `게이트웨이 열쇠 ${옮김}개를 이 PC 계정에서만 풀리게 잠갔습니다 (${방식})`;
91
- } catch { /* 못 쓰면 그냥 둔다 — 메모리에만 잠긴 채로 이번 판을 쓴다 */ }
92
- }
93
-
94
- export function load() {
95
- const cfg = 읽기();
96
- 잠금옮기기(cfg);
97
- // 프록시는 설정을 읽는 자리에서 정한다. run · acp · setup · diagnose 어느 문으로
98
- // 들어와도 한 번은 여기를 지나므로, 여기가 빠뜨리지 않는 유일한 자리다.
99
- 프록시정하기({ config: cfg });
100
- // 명령을 돌릴 셸도 같은 까닭으로 여기서 정한다 (tools/shell.js).
101
- 셸정하기({ config: cfg });
102
- // Azure 판 번호(api-version)도 같은 자리에서 정한다 (backend/azure.js).
103
- 애저정하기({ config: cfg });
104
- // 관리 정책이 걸려 있으면 여기서 덮어쓴다 (safety/policy.js).
105
- 정책덮기(cfg);
106
- return cfg;
107
- }
108
-
109
- /*
110
- * 관리 정책이 정한 것을 설정 위에 덮는다.
111
- *
112
- * 정책은 설정을 **이긴다.** 설정 파일은 쓰는 사람 것이라 지우고 고칠 수 있고,
113
- * 그래서 "이번 배포 동안은 이 게이트웨이만" 같은 것을 적어 둘 자리가 아니다.
114
- *
115
- * 그렇다고 정책이 **풀어 주지는 못한다.** offline 을 켤 수는 있어도 끌 수는
116
- * 없고, 금지를 더할 수는 있어도 사용자가 적어 둔 금지를 지우지는 못한다.
117
- * 정책 파일 한 줄로 안전장치가 헐거워지는 길을 안 낸다.
118
- */
119
- function 정책덮기(cfg) {
120
- const 정책 = 정책읽기().값;
121
- if (!정책 || typeof 정책 !== 'object') return cfg;
122
-
123
- if (typeof 정책.baseUrl === 'string' && 정책.baseUrl.trim()) {
124
- for (const 프로필 of cfg.profiles ?? []) 프로필.baseUrl = 정책.baseUrl.trim();
125
- cfg.정책주소 = 정책.baseUrl.trim();
126
- }
127
- if (정책.offline === true) cfg.offline = true; // 끄지는 못한다 — 켜기만
128
- if (Array.isArray(정책.permissions?.deny)) {
129
- cfg.permissions = cfg.permissions ?? {};
130
- const 있던것 = Array.isArray(cfg.permissions.deny) ? cfg.permissions.deny : [];
131
- cfg.permissions.deny = [...new Set([...있던것, ...정책.permissions.deny])];
132
- }
133
- return cfg;
134
- }
135
-
136
- function 읽기() {
137
- const p = configPath();
138
- if (!existsSync(p)) return structuredClone(EMPTY);
139
- try {
140
- const raw = JSON.parse(readFileSync(p, 'utf8'));
141
- return { ...structuredClone(EMPTY), ...raw };
142
- } catch (err) {
143
- throw new Error(`설정 파일을 읽지 못했습니다: ${p}\n ${err.message}`);
144
- }
145
- }
146
-
147
- export function save(cfg, { toProject = false } = {}) {
148
- const dir = toProject ? projectDir() : userDir();
149
- const p = join(dir, 'config.json');
150
- mkdirSync(dir, { recursive: true });
151
- // 평문 열쇠는 여기서 잠근다. 설정을 쓰는 길이 여기 하나뿐이라, 여기만
152
- // 지키면 평문이 새 파일로 나갈 자리가 없다. 못 잠그면 평문으로 두되
153
- // (아무것도 못 하는 것보다 낫다) 화면과 심사서에 그렇게 적는다.
154
- if (process.env.DEEL_KEYSTORE !== 'off') {
155
- for (const 프로필 of cfg?.profiles ?? []) {
156
- if (!프로필?.apiKey || 잠긴것인가(프로필.apiKey)) continue;
157
- const 잠근것 = 잠그기(프로필.apiKey);
158
- if (잠근것) 프로필.apiKey = 잠근것;
159
- }
160
- }
161
- writeFileSync(p, JSON.stringify(cfg, null, 2) + '\n', 'utf8');
162
- // 키가 들어 있는 파일이므로 가능한 환경에서는 본인만 읽게 잠근다.
163
- try { chmodSync(p, 0o600); } catch {}
164
- return p;
165
- }
166
-
167
- /**
168
- * 설정을 남긴다. 못 남겨도 던지지 않고, **왜 못 남겼는지를 돌려준다.**
169
- *
170
- * 부르는 자리가 아홉 군데인데 전부 `try { save(cfg) } catch {}` 였다. 「못
171
- * 남겨도 이번 세션에는 먹는다」 는 맞는 말이지만, 그 뒤에 화면은 `✓` 를
172
- * 찍는다 — 사람은 정해진 줄 알고 다음에 창을 열었다가 옛 값을 본다. 그리고
173
- * 그때는 무엇 때문인지 알 길이 없다(홈이 읽기 전용인지, 디스크가 찼는지).
174
- *
175
- * 던지는 것은 안 바꾼다. 설정 하나 못 적었다고 대화가 끊기면 본말이 뒤집힌다.
176
- *
177
- * @returns {{ok: true, 자리: string} | {ok: false, 왜: string}}
178
- */
179
- export function 저장시도(cfg, 옵션 = {}) {
180
- try { return { ok: true, 자리: save(cfg, 옵션) }; }
181
- catch (err) { return { ok: false, 왜: err?.message ?? String(err) }; }
182
- }
183
-
184
- export function activeProfile(cfg = load()) {
185
- if (!cfg.profiles.length) return null;
186
- return cfg.profiles.find((x) => x.id === cfg.active) ?? cfg.profiles[0];
187
- }
188
-
189
- // 환경변수가 있으면 파일보다 우선한다 — 사내망에서 키를 파일에 안 남기고 싶을 때 쓴다.
190
- const 푼것 = new Map(); // 잠긴 값 → 푼 글. 한 판에 한 번만 풀면 된다.
191
-
192
- /**
193
- * 잠근 열쇠를 못 푼 까닭. 화면이 한 번 읽어 가면 지워진다.
194
- *
195
- * ── 왜 있어야 하나 ──────────────────────────────────────────────────────
196
- *
197
- * keystore.js 의 풀기() 는 왜 못 풀었는지를 성실하게 만들어 돌려준다 —
198
- * 「이 PC 의 이 계정에서 잠근 것만 풀립니다」 같은 말이다. 그 함수 머리말이
199
- * 그렇게 하는 까닭까지 적어 뒀다: **빈 글자만 돌려주면 사람은 401 만 보고
200
- * 게이트웨이를 의심한다.**
201
- *
202
- * 그런데 부르는 쪽이 그 말을 버리고 있었다. 빈 열쇠가 나가면 http.js 는
203
- * Authorization 머리말을 아예 안 붙이고, 게이트웨이는 401 을 준다. 사람은
204
- * 방화벽·주소·계정을 뒤진다. 진짜 까닭은 「설정 파일을 다른 PC 로 옮겼다」 인데.
205
- */
206
- let 열쇠탈 = null;
207
- export function 열쇠탈소식() { const s = 열쇠탈; 열쇠탈 = null; return s; }
208
-
209
- export function resolveKey(profile) {
210
- const byName = profile?.id ? process.env[`DEEL_KEY_${profile.id.toUpperCase()}`] : null;
211
- const 값 = byName || process.env.DEEL_API_KEY || profile?.apiKey || '';
212
- if (!잠긴것인가(값)) return 값;
213
- // 푸는 데 파워셸을 한 번 부른다(0.2초쯤). 한마디마다 부르면 그게 다 사람이
214
- // 기다리는 시간이라, 판에 번만 풀고 들고 있는다.
215
- if (푼것.has(값)) return 푼것.get(값);
216
- const r = 풀기(값);
217
- if (!r.ok) {
218
- /*
219
- * **실패는 담아 둔다.**
220
- *
221
- * 담아 두면 내내 다시 시도조차 안 한다. 사람이 그 사이에
222
- * `deel setup` 으로 열쇠를 다시 넣어도 이 판에서는 영영 안 풀린다.
223
- * 푸는 값이 0.2초라, 안 되는 것을 아껴서 얻을 것이 없다.
224
- */
225
- 열쇠탈 = r.why || '잠근 열쇠를 풀었습니다';
226
- return '';
227
- }
228
- 푼것.set(값, r.text);
229
- return r.text;
230
- }
231
-
232
- export function upsert(cfg, profile) {
233
- const i = cfg.profiles.findIndex((x) => x.id === profile.id);
234
- if (i >= 0) cfg.profiles[i] = { ...cfg.profiles[i], ...profile };
235
- else cfg.profiles.push(profile);
236
- if (!cfg.active) cfg.active = profile.id;
237
- return cfg;
238
- }
239
-
240
- export function slug(name) {
241
- const base = String(name).trim().toLowerCase()
242
- .replace(/[^a-z0-9가-힣]+/g, '-').replace(/^-+|-+$/g, '');
243
- return base || 'profile';
244
- }
1
+ // 연결 프로필 저장/읽기. ~/.deel/config.json (프로젝트 폴더의 .deel/config.json 이 우선)
2
+ import { homedir } from 'node:os';
3
+ import { join, resolve } from 'node:path';
4
+ import { readFileSync, writeFileSync, mkdirSync, existsSync, chmodSync } from 'node:fs';
5
+ import { 프록시정하기 } from './backend/proxy.js';
6
+ import { 셸정하기 } from './tools/shell.js';
7
+ import { 애저정하기 } from './backend/azure.js';
8
+ import { 정책읽기 } from './safety/policy.js';
9
+ import { 잠그기, 풀기, 잠긴것인가, 쓸수있나, 보관방식 } from './safety/keystore.js';
10
+
11
+ // 설정이 놓이는 자리.
12
+ //
13
+ // DEEL_HOME 을 주면 그 폴더를 쓴다. 두 군데서 필요했다 —
14
+ // 1) 사내에서 USB·공유폴더에 통째로 넣어 쓰는 휴대용 설치.
15
+ // 집 폴더가 로밍 프로필이면 설정이 엉뚱한 데로 따라다닌다.
16
+ // 2) 검사. 예전에는 검사가 진짜 설정 파일에 값을 써 버렸다.
17
+ // 실제로 /level 검사가 사용자 설정을 바꾼 것을 보고 이걸 넣었다.
18
+ //
19
+ // 모듈을 읽을 때가 아니라 쓸 때마다 본다. 그래야 부르는 쪽에서 언제 정하든 먹는다.
20
+ function userDir() {
21
+ return process.env.DEEL_HOME ? resolve(process.env.DEEL_HOME) : join(homedir(), '.deel');
22
+ }
23
+ function projectDir() {
24
+ return join(process.cwd(), '.deel');
25
+ }
26
+
27
+ /**
28
+ * 이 PC 의 설정 폴더 (`~/.deel` 또는 DEEL_HOME).
29
+ *
30
+ * configPath() 와 다르다 — 저쪽은 프로젝트에 설정이 있으면 그쪽을 준다.
31
+ * 여기는 언제나 **이 PC 것**이다. 모델에 대해 알아낸 것처럼 폴더를 옮겨도
32
+ * 따라와야 하는 것을 여기 둔다 (agent/evolve.js).
33
+ */
34
+ export function homeDir() { return userDir(); }
35
+
36
+ export function configPath() {
37
+ const local = join(projectDir(), 'config.json');
38
+ if (existsSync(local)) return local;
39
+ return join(userDir(), 'config.json');
40
+ }
41
+
42
+ const EMPTY = { version: 1, active: null, profiles: [] };
43
+
44
+ /*
45
+ * 열쇠를 이 PC 의 잠금장치에 옮긴다 (safety/keystore.js).
46
+ *
47
+ * 예전 판이 평문으로 적어 둔 설정이 그대로 남아 있다. 사람더러 다시 넣으라고
48
+ * 하면 대부분 안 한다 — 지금 잘 되고 있으니까. 그래서 **처음 읽을 때 한 번**
49
+ * 조용히 옮기고, 옮겼다는 것만 한 줄로 알린다.
50
+ *
51
+ * 못 잠그면 파일을 아예 안 건드린다. 반쯤 잠근 파일을 남기면 그 뒤로는
52
+ * 무엇이 잠긴 것이고 무엇이 평문인지 아무도 모른다.
53
+ *
54
+ * DEEL_KEYSTORE=off 면 안 한다 — 잠금장치가 정책으로 막힌 곳에서 매번
55
+ * 파워셸을 두드리며 느려지는 것을 사람이 끌 수 있어야 한다.
56
+ */
57
+ let 옮겨봤나 = false;
58
+ let 소식 = null;
59
+
60
+ /** 열쇠를 옮겼으면 그 한 줄을 준다. 한 번 읽으면 지워진다 (두 번 안 알린다). */
61
+ export function 잠금소식() { const s = 소식; 소식 = null; return s; }
62
+
63
+ /** 화면·심사서에 그대로 쓰는 '열쇠를 어디에 두나' 한 줄. */
64
+ export function 열쇠보관(cfg = null) {
65
+ const p = cfg ? activeProfile(cfg) : null;
66
+ return 보관방식(p?.apiKey ?? null);
67
+ }
68
+
69
+ function 잠금옮기기(cfg) {
70
+ if (옮겨봤나 || process.env.DEEL_KEYSTORE === 'off') return;
71
+ 옮겨봤나 = true;
72
+ const 평문 = (cfg.profiles ?? []).filter((p) => p?.apiKey && !잠긴것인가(p.apiKey));
73
+ if (!평문.length) return;
74
+ if (!쓸수있나().되나) return;
75
+
76
+ let 옮김 = 0;
77
+ for (const p of 평문) {
78
+ const 잠근것 = 잠그기(p.apiKey);
79
+ if (잠근것) { p.apiKey = 잠근것; 옮김 += 1; }
80
+ }
81
+ if (!옮김) return;
82
+ try {
83
+ // 읽은 자리에 그대로 되쓴다. 프로젝트 설정을 홈으로 옮겨 버리면
84
+ // 이 폴더에서만 쓰던 열쇠가 온 컴퓨터의 기본값이 된다.
85
+ const 프로젝트것 = configPath() === join(projectDir(), 'config.json');
86
+ save(cfg, { toProject: 프로젝트것 });
87
+ const 방식 = 쓸수있나().방식 === 'keychain' ? '키체인' : 'DPAPI';
88
+ 소식 = 옮김 === 1
89
+ ? `게이트웨이 열쇠를 이 PC 계정에서만 풀리게 잠갔습니다 (${방식})`
90
+ : `게이트웨이 열쇠 ${옮김}개를 이 PC 계정에서만 풀리게 잠갔습니다 (${방식})`;
91
+ } catch { /* 못 쓰면 그냥 둔다 — 메모리에만 잠긴 채로 이번 판을 쓴다 */ }
92
+ }
93
+
94
+ export function load() {
95
+ const cfg = 읽기();
96
+ 잠금옮기기(cfg);
97
+ // 프록시는 설정을 읽는 자리에서 정한다. run · acp · setup · diagnose 어느 문으로
98
+ // 들어와도 한 번은 여기를 지나므로, 여기가 빠뜨리지 않는 유일한 자리다.
99
+ 프록시정하기({ config: cfg });
100
+ // 명령을 돌릴 셸도 같은 까닭으로 여기서 정한다 (tools/shell.js).
101
+ 셸정하기({ config: cfg });
102
+ // Azure 판 번호(api-version)도 같은 자리에서 정한다 (backend/azure.js).
103
+ 애저정하기({ config: cfg });
104
+ // 관리 정책이 걸려 있으면 여기서 덮어쓴다 (safety/policy.js).
105
+ 정책덮기(cfg);
106
+ return cfg;
107
+ }
108
+
109
+ /*
110
+ * 관리 정책이 정한 것을 설정 위에 덮는다.
111
+ *
112
+ * 정책은 설정을 **이긴다.** 설정 파일은 쓰는 사람 것이라 지우고 고칠 수 있고,
113
+ * 그래서 "이번 배포 동안은 이 게이트웨이만" 같은 것을 적어 둘 자리가 아니다.
114
+ *
115
+ * 그렇다고 정책이 **풀어 주지는 못한다.** offline 을 켤 수는 있어도 끌 수는
116
+ * 없고, 금지를 더할 수는 있어도 사용자가 적어 둔 금지를 지우지는 못한다.
117
+ * 정책 파일 한 줄로 안전장치가 헐거워지는 길을 안 낸다.
118
+ */
119
+ function 정책덮기(cfg) {
120
+ const 정책 = 정책읽기().값;
121
+ if (!정책 || typeof 정책 !== 'object') return cfg;
122
+
123
+ if (typeof 정책.baseUrl === 'string' && 정책.baseUrl.trim()) {
124
+ for (const 프로필 of cfg.profiles ?? []) 프로필.baseUrl = 정책.baseUrl.trim();
125
+ cfg.정책주소 = 정책.baseUrl.trim();
126
+ }
127
+ if (정책.offline === true) cfg.offline = true; // 끄지는 못한다 — 켜기만
128
+ if (Array.isArray(정책.permissions?.deny)) {
129
+ cfg.permissions = cfg.permissions ?? {};
130
+ const 있던것 = Array.isArray(cfg.permissions.deny) ? cfg.permissions.deny : [];
131
+ cfg.permissions.deny = [...new Set([...있던것, ...정책.permissions.deny])];
132
+ }
133
+ return cfg;
134
+ }
135
+
136
+ function 읽기() {
137
+ const p = configPath();
138
+ if (!existsSync(p)) return structuredClone(EMPTY);
139
+ try {
140
+ const raw = JSON.parse(readFileSync(p, 'utf8'));
141
+ return { ...structuredClone(EMPTY), ...raw };
142
+ } catch (err) {
143
+ throw new Error(`설정 파일을 읽지 못했습니다: ${p}\n ${err.message}`);
144
+ }
145
+ }
146
+
147
+ export function save(cfg, { toProject = false } = {}) {
148
+ const dir = toProject ? projectDir() : userDir();
149
+ const p = join(dir, 'config.json');
150
+ mkdirSync(dir, { recursive: true });
151
+ // 평문 열쇠는 여기서 잠근다. 설정을 쓰는 길이 여기 하나뿐이라, 여기만
152
+ // 지키면 평문이 새 파일로 나갈 자리가 없다. 못 잠그면 평문으로 두되
153
+ // (아무것도 못 하는 것보다 낫다) 화면과 심사서에 그렇게 적는다.
154
+ if (process.env.DEEL_KEYSTORE !== 'off') {
155
+ for (const 프로필 of cfg?.profiles ?? []) {
156
+ if (!프로필?.apiKey || 잠긴것인가(프로필.apiKey)) continue;
157
+ const 잠근것 = 잠그기(프로필.apiKey);
158
+ if (잠근것) 프로필.apiKey = 잠근것;
159
+ }
160
+ }
161
+ writeFileSync(p, JSON.stringify(cfg, null, 2) + '\n', 'utf8');
162
+ // 키가 들어 있는 파일이므로 가능한 환경에서는 본인만 읽게 잠근다.
163
+ try { chmodSync(p, 0o600); } catch {}
164
+ return p;
165
+ }
166
+
167
+ /**
168
+ * 설정을 남긴다. 못 남겨도 던지지 않고, **왜 못 남겼는지를 돌려준다.**
169
+ *
170
+ * 부르는 자리가 아홉 군데인데 전부 `try { save(cfg) } catch {}` 였다. 「못
171
+ * 남겨도 이번 세션에는 먹는다」 는 맞는 말이지만, 그 뒤에 화면은 `✓` 를
172
+ * 찍는다 — 사람은 정해진 줄 알고 다음에 창을 열었다가 옛 값을 본다. 그리고
173
+ * 그때는 무엇 때문인지 알 길이 없다(홈이 읽기 전용인지, 디스크가 찼는지).
174
+ *
175
+ * 던지는 것은 안 바꾼다. 설정 하나 못 적었다고 대화가 끊기면 본말이 뒤집힌다.
176
+ *
177
+ * @returns {{ok: true, 자리: string} | {ok: false, 왜: string}}
178
+ */
179
+ export function 저장시도(cfg, 옵션 = {}) {
180
+ try { return { ok: true, 자리: save(cfg, 옵션) }; }
181
+ catch (err) { return { ok: false, 왜: err?.message ?? String(err) }; }
182
+ }
183
+
184
+ export function activeProfile(cfg = load()) {
185
+ if (!cfg.profiles.length) return null;
186
+ return cfg.profiles.find((x) => x.id === cfg.active) ?? cfg.profiles[0];
187
+ }
188
+
189
+ // 환경변수가 있으면 파일보다 우선한다 — 사내망에서 키를 파일에 안 남기고 싶을 때 쓴다.
190
+ const 푼것 = new Map(); // 잠긴 값 → 푼 글. 한 판에 한 번만 풀면 된다.
191
+
192
+ /**
193
+ * 잠근 열쇠를 못 푼 까닭. 화면이 한 번 읽어 가면 지워진다.
194
+ *
195
+ * ── 왜 있어야 하나 ──────────────────────────────────────────────────────
196
+ *
197
+ * keystore.js 의 풀기() 는 왜 못 풀었는지를 성실하게 만들어 돌려준다 —
198
+ * 「이 PC 의 이 계정에서 잠근 것만 풀립니다」 같은 말이다. 그 함수 머리말이
199
+ * 그렇게 하는 까닭까지 적어 뒀다: **빈 글자만 돌려주면 사람은 401 만 보고
200
+ * 게이트웨이를 의심한다.**
201
+ *
202
+ * 그런데 부르는 쪽이 그 말을 버리고 있었다. 빈 열쇠가 나가면 http.js 는
203
+ * Authorization 머리말을 아예 안 붙이고, 게이트웨이는 401 을 준다. 사람은
204
+ * 방화벽·주소·계정을 뒤진다. 진짜 까닭은 「설정 파일을 다른 PC 로 옮겼다」 인데.
205
+ */
206
+ let 열쇠탈 = null;
207
+ export function 열쇠탈소식() { const s = 열쇠탈; 열쇠탈 = null; return s; }
208
+
209
+ /**
210
+ * 이름의 환경변수는 **열쇠를 담나.**
211
+ *
212
+ * ── 왜 이 자가 있어야 하나 ────────────────────────────────────────────
213
+ *
214
+ * 열쇠는 이름으로 들어온다 `DEEL_API_KEY` 하나, 그리고 프로필마다
215
+ * `DEEL_KEY_<프로필>`. 아래 resolveKey 가 그 둘을 다 읽는다.
216
+ *
217
+ * 그런데 **막는 쪽은 하나만 알고 있었다.** 자식에게 넘길 환경을 씻는 자
218
+ * (backend/mcp.js 의 열쇠뺀환경)도, 도구 출력에서 값을 가리는 자
219
+ * (agent/loop.js)도 `DEEL_API_KEY` 지웠다. 그래서 프로필 열쇠를 쓰는
220
+ * 사람은 `Bash({command:'env'})` 한 줄로 열쇠가 화면에 찍히고, 그 화면이
221
+ * 대화에 실려 **그 열쇠의 주인인 게이트웨이로** 나가고 `.deel/sessions/*.jsonl`
222
+ * 남는다.
223
+ *
224
+ * 하필 그 방법을 우리가 권한다 — pack/sbom.js 와 pack/sheet.en.js 의 사내
225
+ * 심사용 명세가 「환경변수를 쓰면 파일에 아예 안 남습니다」 라고 적어 뒀다.
226
+ * 권한 대로 한 사람만 샌 셈이다.
227
+ *
228
+ * 읽는 자리와 막는 자리가 **같은 자에게 물어야** 이런 일이 안 난다. 이름이
229
+ * 하나 늘면 여기만 고친다.
230
+ *
231
+ * 윈도우는 환경변수 이름의 대소문자를 안 가린다(`deel_api_key` 도 같은 값이다).
232
+ * 그래서 견줄 때 대문자로 올린다.
233
+ */
234
+ export function 열쇠환경인가(이름) {
235
+ const n = String(이름 ?? '').toUpperCase();
236
+ return n === 'DEEL_API_KEY' || n.startsWith('DEEL_KEY_');
237
+ }
238
+
239
+ /** 환경에 실제로 들어 있는 **열쇠 값들.** 가릴 때 쓴다. */
240
+ export function 환경속열쇠들(env = process.env) {
241
+ const out = [];
242
+ for (const [k, v] of Object.entries(env ?? {})) {
243
+ if (열쇠환경인가(k) && typeof v === 'string' && v) out.push(v);
244
+ }
245
+ return out;
246
+ }
247
+
248
+ export function resolveKey(profile) {
249
+ const byName = profile?.id ? process.env[`DEEL_KEY_${profile.id.toUpperCase()}`] : null;
250
+ const 값 = byName || process.env.DEEL_API_KEY || profile?.apiKey || '';
251
+ if (!잠긴것인가(값)) return 값;
252
+ // 푸는 데 파워셸을 한 번 부른다(0.2초쯤). 한마디마다 부르면 그게 다 사람이
253
+ // 기다리는 시간이라, 판에 한 번만 풀고 들고 있는다.
254
+ if (푼것.has(값)) return 푼것.get(값);
255
+ const r = 풀기(값);
256
+ if (!r.ok) {
257
+ /*
258
+ * **실패는 안 담아 둔다.**
259
+ *
260
+ * 담아 두면 그 판 내내 다시 시도조차 안 한다. 사람이 그 사이에
261
+ * `deel setup` 으로 열쇠를 다시 넣어도 이 판에서는 영영 안 풀린다.
262
+ * 푸는 값이 0.2초라, 안 되는 것을 아껴서 얻을 것이 없다.
263
+ */
264
+ 열쇠탈 = r.why || '잠근 열쇠를 못 풀었습니다';
265
+ return '';
266
+ }
267
+ 푼것.set(값, r.text);
268
+ return r.text;
269
+ }
270
+
271
+ export function upsert(cfg, profile) {
272
+ const i = cfg.profiles.findIndex((x) => x.id === profile.id);
273
+ if (i >= 0) cfg.profiles[i] = { ...cfg.profiles[i], ...profile };
274
+ else cfg.profiles.push(profile);
275
+ if (!cfg.active) cfg.active = profile.id;
276
+ return cfg;
277
+ }
278
+
279
+ export function slug(name) {
280
+ const base = String(name).trim().toLowerCase()
281
+ .replace(/[^a-z0-9가-힣]+/g, '-').replace(/^-+|-+$/g, '');
282
+ return base || 'profile';
283
+ }
package/src/i18n/en.js CHANGED
@@ -499,6 +499,7 @@ export const en = {
499
499
  'think.autoOff': 'Effort stays exactly where you set it',
500
500
  'think.wire': 'wire',
501
501
  'think.wireSends': 'sends {값}',
502
+ 'think.wireNoOff': 'this endpoint never told us its off value — the field is omitted, so the server default applies',
502
503
  'loop.wire': 'The server does not take that field — retrying with it adjusted ({무엇})',
503
504
  'loop.quotaAhead': 'Quota is empty — waiting {초}s before sending',
504
505
  'cost.quota': 'Server quota',
package/src/i18n/ja.js CHANGED
@@ -456,6 +456,7 @@ export const ja = {
456
456
  'think.autoOff': '設定した強度をそのまま使います',
457
457
  'think.wire': '配線',
458
458
  'think.wireSends': '実際に送る値 {값}',
459
+ 'think.wireNoOff': 'この窓口は切る値を教えてくれませんでした — 項目を送らないので、サーバー既定値で動きます',
459
460
  'loop.wire': 'サーバーが受け取らない項目があるため、直して呼び直します ({무엇})',
460
461
  'loop.quotaAhead': '残量が尽きたので {초} 秒待ってから送ります',
461
462
  'cost.quota': 'サーバー残量',
package/src/i18n/ko.js CHANGED
@@ -546,6 +546,7 @@ export const ko = {
546
546
  'think.autoOff': '정한 강도를 그대로 씁니다',
547
547
  'think.wire': '전선',
548
548
  'think.wireSends': '지금 나가는 값 {값}',
549
+ 'think.wireNoOff': '이 창구는 끄는 말을 안 알려 줬습니다 — 칸을 안 싣고, 서버 기본값으로 돕니다',
549
550
  'loop.wire': '서버가 안 받는 칸이 있어 맞춰 다시 부릅니다 ({무엇})',
550
551
  'loop.quotaAhead': '할당량이 바닥나서 {초}초 기다렸다 보냅니다',
551
552
  'cost.quota': '서버 할당량',
package/src/i18n/zh.js CHANGED
@@ -456,6 +456,7 @@ export const zh = {
456
456
  'think.autoOff': '始终使用你设定的强度',
457
457
  'think.wire': '线路',
458
458
  'think.wireSends': '实际发送 {값}',
459
+ 'think.wireNoOff': '该端点未告知关闭值 — 不发送该字段,因此按服务器默认值运行',
459
460
  'loop.wire': '服务端不接受该字段,已调整后重试 ({무엇})',
460
461
  'loop.quotaAhead': '额度用尽,等待 {초} 秒后再发送',
461
462
  'cost.quota': '服务器额度',
package/src/oneshot.js CHANGED
@@ -32,6 +32,7 @@ import { probeCtx, 기본값 as CTX_DEFAULT } from './backend/ctxsize.js';
32
32
  import { route } from './agent/route.js';
33
33
  import { get as getWork } from './agent/modes.js';
34
34
  import { 모두끝내기 as 일감모두끝내기, 일감인자 } from './tools/jobs.js';
35
+ import { 첫이름 } from './tools/label.js';
35
36
 
36
37
  /**
37
38
  * 종료코드.
@@ -86,7 +87,8 @@ async function 표준입력읽기() {
86
87
  // 도구 한 줄 요약. 화면 그림이 아니라 로그에 남을 글이라 색을 아낀다.
87
88
  function 도구줄(name, args) {
88
89
  const a = args ?? {};
89
- const = a.file_path ?? a.pattern ?? a.path ?? a.url ?? a.name ?? a.purpose ?? a.목적 ??
90
+ // 이름 차례는 tools/label.js 곳에서 온다 (다섯 벌이던 것을 모았다).
91
+ const 첫 = 첫이름(a) ??
90
92
  (a.command ? String(a.command).replace(/\s+/g, ' ') : null) ??
91
93
  // 뒤에서 도는 명령. 번호가 곧 그 일감의 이름이라, 이게 없으면 기록에
92
94
  // `Jobs()` 만 여러 줄 남아 나중에 무엇을 본 것인지 알 수 없다.
@@ -611,6 +613,9 @@ export async function runOnce(opts = {}) {
611
613
  cacheRead: session.usage.cacheRead ?? 0,
612
614
  cacheWrite: session.usage.cacheWrite ?? 0,
613
615
  calls: session.usage.calls, ms: session.usage.ms,
616
+ // 창구가 usage 를 안 준 부름 수. 0 이 아니면 위 숫자는 **덜 센 값**이다.
617
+ // 이 칸이 없으면 받는 쪽이 0 을 실측으로 읽는다 (backend/adapter.js 의 잰것).
618
+ 못잰것: session.usage.못잰것 ?? 0,
614
619
  retries: session.usage.retries ?? 0,
615
620
  },
616
621
  model: conn.model,