deel-local-cli 1.6.3 → 1.7.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,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
+ }
package/src/setup.js CHANGED
@@ -1,15 +1,18 @@
1
1
  // 첫 실행 마법사 + 진단 실행.
2
2
  import { c, say, rule, mark } from './ui/ansi.js';
3
3
  import { 주소가리기 } from './safety/secrets.js';
4
- import { ask, pick } from './ui/prompt.js';
4
+ import { ask, pick, confirm } from './ui/prompt.js';
5
5
  import { spin } from './ui/spinner.js';
6
6
  import { detect } from './backend/detect.js';
7
+ import { 규격이름 } from './backend/adapter.js';
7
8
  import { probe } from './backend/probe.js';
8
9
  import { renderHeader, renderLine, verdict, renderVerdict, plainReport } from './report.js';
9
10
  import { load, save, upsert, slug, resolveKey, activeProfile, configPath } from './config.js';
10
11
  import { 보관방식 } from './safety/keystore.js';
11
12
  import { 애저풀기, 애저base } from './backend/azure.js';
12
13
  import { allowEndpoint } from './safety/network.js';
14
+ import { 바깥인가 } from './safety/runmode.js';
15
+ import { 제공자들, 제공자고르기, 어디것일까, 주소후보, 막힌까닭 } from './providers/index.js';
13
16
  import { writeFileSync } from 'node:fs';
14
17
 
15
18
  export function banner() {
@@ -19,7 +22,11 @@ export function banner() {
19
22
  }
20
23
 
21
24
  // 주소와 키를 받아 연결을 찾아낸다. 실패하면 null.
22
- async function connect(url, key) {
25
+ //
26
+ // 조용히 = 후보를 여럿 훑는 중이라, 실패해도 아직 화면에 안 적는다.
27
+ // 세 개를 줄줄이 시도하면서 실패 안내를 세 번 찍으면, 마지막에 성공해도
28
+ // 사람 눈에는 「뭔가 잔뜩 실패했다」 로 남는다.
29
+ async function connect(url, key, { 조용히 = false, 제공자: 어디 = null } = {}) {
23
30
  const s = spin(`${url} 확인 중...`);
24
31
  /*
25
32
  * 사용자가 방금 적어 넣은 주소다. 확인하는 동안만 문을 연다.
@@ -31,19 +38,54 @@ async function connect(url, key) {
31
38
  * 그 호스트 하나뿐이라 넓어지는 것이 아니다.
32
39
  */
33
40
  allowEndpoint(/^https?:\/\//i.test(url) ? url : [`http://${url}`, `https://${url}`]);
34
- const found = await detect(url, key);
41
+ /*
42
+ * 자물쇠가 막으면 **던져서 끝나지 않게** 받아 둔다.
43
+ *
44
+ * 봉인(offline)이 켜져 있으면 checkUrl 이 예외를 던진다. 예전에는 그것이
45
+ * 여기를 그냥 지나쳐 `deel setup` 전체를 죽였다 — 화면에는 스택 자국이
46
+ * 뜨고, 사람은 자기가 켜 둔 봉인 때문이라는 것을 알 길이 없다.
47
+ *
48
+ * 못 붙은 것으로 치고 그 말을 아래 「연결 실패」 자리에 그대로 싣는다.
49
+ */
50
+ let found;
51
+ try { found = await detect(url, key); }
52
+ catch (err) { found = { kind: null, tried: [url], status: 0, why: String(err?.message ?? err) }; }
35
53
  if (!found.kind) {
54
+ if (조용히) { s.stop(''); return null; }
36
55
  s.stop(` ${mark.no} ${c.red('연결 실패')}`);
37
56
  say('');
57
+ /*
58
+ * 무엇이 막았는지를 먼저 말한다 (providers/index.js 의 막힌까닭).
59
+ *
60
+ * 여태는 401 도 404 도 403 도 전부 「연결 실패」 한 마디였다. 그런데 이
61
+ * 셋은 고칠 자리가 완전히 다르다 — 401 은 닿은 것이라 주소를 의심하면
62
+ * 안 되고, 404 는 주소를 봐야 하고, Bedrock 의 AccessDenied 는 콘솔에서
63
+ * 신청 버튼 한 번 누르면 되는 일이다. 뭉쳐 놓으면 사람은 셋 다 주소
64
+ * 문제로 알고 엉뚱한 데를 판다.
65
+ *
66
+ * 모르는 것은 지어내지 않는다 — 그때는 아래 「확인할 것」 이 그대로 남는다.
67
+ */
68
+ const 까닭 = 막힌까닭(어디, { status: found.status ?? 0, 서버말: found.why ?? '' });
69
+ /*
70
+ * 옮길 말이 없으면 **받은 말을 그대로** 보여 준다.
71
+ *
72
+ * 사람 말로 옮기는 표는 HTTP 상태코드가 있을 때만 쓸모가 있다. 닿지도
73
+ * 못했을 때(status 0)가 오히려 원인이 또렷한 경우가 많다 — 봉인에 막힘 ·
74
+ * 포트 닫힘 · 인증서 · 프록시. 그 한 줄을 버리면 화면에는 「연결 실패」
75
+ * 네 글자만 남고, 정작 답이 적힌 문장을 우리가 지운 셈이 된다.
76
+ */
77
+ if (까닭) { say(` ${mark.warn} ${c.yellow(까닭)}`); say(''); }
78
+ else if (found.why) { say(` ${mark.warn} ${c.yellow(found.why)}`); say(''); }
38
79
  say(` 시도한 주소:`);
39
80
  for (const t of found.tried) say(` ${c.gray(주소가리기(t.includes('?') ? t : `${t}/models`))}`);
40
81
  say('');
41
82
  say(` ${c.gray('확인할 것 — 주소·포트가 맞는지, 프록시가 필요한지,')}`);
42
83
  say(` ${c.gray('사내 인증서라면 NODE_EXTRA_CA_CERTS 환경변수가 필요합니다.')}`);
84
+ if (어디?.열쇠받는곳) say(` ${c.gray('열쇠 받는 곳')} ${c.cyan(어디.열쇠받는곳)}`);
43
85
  say('');
44
86
  return null;
45
87
  }
46
- const kindName = found.kind === 'ollama' ? `Ollama ${found.version ?? ''}`.trim() : 'OpenAI 호환';
88
+ const kindName = found.kind === 'ollama' ? `Ollama ${found.version ?? ''}`.trim() : 규격이름(found.kind);
47
89
  s.stop(` ${mark.ok} ${c.green('연결됨')} ${c.gray(`${kindName} · ${found.ms}ms`)}`);
48
90
  // 물음표 뒤에 열쇠를 싣는 앞단이 있다 (safety/secrets.js 의 주소가리기).
49
91
  say(` ${c.gray('주소')} ${주소가리기(found.base)}`);
@@ -80,22 +122,143 @@ export async function runProbe(conn, { out = null } = {}) {
80
122
  return { facts, results, v };
81
123
  }
82
124
 
125
+ /*
126
+ * ── 어디에 붙일까 ───────────────────────────────────────────────────────
127
+ *
128
+ * 세 갈래가 다 같은 문으로 들어간다. 다른 것은 **사람이 채울 빈칸 개수**뿐이다.
129
+ *
130
+ * 열쇠만 있을 때 빈칸 1개 — 앞머리로 어디 것인지 알아본다
131
+ * 아는 곳에서 고르기 빈칸 1~2개 — 주소를 대신 채워 준다
132
+ * 주소를 직접 넣기 빈칸 2개 — 규격·인증은 스캐너가 알아낸다
133
+ *
134
+ * 「직접 넣기」 를 목록 위쪽에 둔다. 목록이 지원 명단처럼 보이면, 거기 없는
135
+ * 회사는 안 되는 줄 알고 돌아선다 (providers/index.js 머리말).
136
+ *
137
+ * @returns {{제공자, 주소들: string[], 열쇠: string, 이름: string} | null}
138
+ */
139
+ async function 붙일곳고르기() {
140
+ const 목록 = [
141
+ { id: '열쇠먼저', label: '열쇠만 있습니다 — 어디 것인지 알아봐 주세요', note: '빈칸 1개' },
142
+ { id: 'custom', label: '주소를 직접 넣기', note: '사내 게이트웨이 · 목록에 없는 곳' },
143
+ ...제공자들.filter((p) => p.id !== 'custom').map((p) => ({
144
+ id: p.id,
145
+ label: p.이름,
146
+ note: `빈칸 ${p.빈칸.length}개 (${p.빈칸.join(' + ')})`
147
+ + (p.규격됐나 === false ? ' · 규격 붙이는 중' : ''),
148
+ })),
149
+ ];
150
+ const i = await pick('어디에 붙일까요?', 목록, { def: 1 });
151
+ const 고른 = 목록[i];
152
+
153
+ // ── 열쇠 먼저 ────────────────────────────────────────────────────────
154
+ //
155
+ // 열쇠를 여러 곳에 던져 보고 200 이 오는 데를 찾지 **않는다.** 그러면 남의
156
+ // 서버에 내 열쇠가 남는다. 앞머리로 짐작해 한 곳만 묻고, 모르면 물어본다.
157
+ let 제공자 = null;
158
+ let 열쇠 = '';
159
+ if (고른.id === '열쇠먼저') {
160
+ 열쇠 = (await ask('API 키', { mask: true })).trim();
161
+ if (!열쇠) { say(` ${mark.no} 열쇠가 비었습니다.`); return null; }
162
+ const 짚은것 = 어디것일까(열쇠);
163
+ if (짚은것) {
164
+ 제공자 = 짚은것.제공자;
165
+ say(` ${mark.ok} ${c.bold(제공자.이름)} 열쇠로 보입니다. ${c.gray(`(${짚은것.왜})`)}`);
166
+ say(` ${c.gray('여기 말고 다른 데는 안 물어봅니다 — 열쇠를 여기저기 던지지 않습니다.')}`);
167
+ } else {
168
+ say(` ${mark.warn} ${c.yellow('어디 열쇠인지 모르겠습니다.')}`);
169
+ say(` ${c.gray('짐작으로 여기저기 보내지 않습니다. 어디 것인지 골라 주세요.')}`);
170
+ const 나머지 = 제공자들.map((p) => ({ label: p.이름, note: p.한줄 }));
171
+ 제공자 = 제공자들[await pick('어디 열쇠인가요?', 나머지, { def: 0 })];
172
+ }
173
+ } else {
174
+ 제공자 = 제공자고르기(고른.id);
175
+ }
176
+ if (!제공자) return null;
177
+
178
+ /*
179
+ * 아직 말할 줄 모르는 규격은 여기서 멈춘다.
180
+ *
181
+ * 열쇠를 받고, 연결도 되고, 저장까지 해 놓고 첫 한마디에서 400 이 나면
182
+ * 그 화면으로는 무엇이 잘못됐는지 알 길이 없다. 못 하는 것은 못 한다고
183
+ * 지금 말하는 편이 언제나 싸다.
184
+ */
185
+ if (제공자.규격됐나 === false) {
186
+ say('');
187
+ say(` ${mark.no} ${c.yellow(`${제공자.이름} 는 규격을 아직 다 못 붙였습니다.`)}`);
188
+ say(` ${c.gray('주소와 열쇠는 아는데 말을 아직 못 합니다. 붙는 대로 열립니다.')}`);
189
+ say(` ${c.gray('지금 쓰시려면 OpenAI 호환 창구가 있는 곳(Gemini · Bedrock)을 골라 주세요.')}`);
190
+ say('');
191
+ return null;
192
+ }
193
+
194
+ // ── 리전 ─────────────────────────────────────────────────────────────
195
+ //
196
+ // 리전을 고르면 그 자리에서 진짜로 물어본다. 표에 「서울에 무슨 모델이
197
+ // 있다」 를 박아 두지 않는다 — 그 표는 반드시 낡는다.
198
+ let 리전 = null;
199
+ if (제공자.리전들) {
200
+ const 것들 = [...제공자.리전들.map((r) => ({ label: `${r.id} ${r.어디}`, note: r.왜 })),
201
+ { label: '직접 입력', note: '목록은 낡습니다 — 빠져나갈 구멍을 늘 둡니다' }];
202
+ const j = await pick('어느 리전인가요?', 것들, { def: 0 });
203
+ 리전 = j < 제공자.리전들.length
204
+ ? 제공자.리전들[j].id
205
+ : (await ask('리전 (예: ap-northeast-2)')).trim();
206
+ }
207
+
208
+ // ── 주소 ─────────────────────────────────────────────────────────────
209
+ let 주소들 = 주소후보(제공자, { 리전 });
210
+ if (!주소들.length) {
211
+ if (제공자.리전들 && 리전) {
212
+ say(` ${mark.no} ${c.yellow(`리전 이름이 이상합니다: ${리전}`)} ${c.gray('(예: us-east-1 · ap-northeast-2)')}`);
213
+ return null;
214
+ }
215
+ const url = (await ask('주소 (Base URL)')).trim();
216
+ if (!url) { say(` ${mark.no} 주소가 비었습니다.`); return null; }
217
+ 주소들 = [url];
218
+ } else {
219
+ say(` ${c.gray('주소')} ${주소들.map((u) => 주소가리기(u)).join(c.gray(' · '))}`);
220
+ }
221
+
222
+ // ── 열쇠 ─────────────────────────────────────────────────────────────
223
+ if (!열쇠) {
224
+ if (제공자.열쇠받는곳) say(` ${c.gray('열쇠 받는 곳')} ${c.cyan(제공자.열쇠받는곳)}`);
225
+ 열쇠 = (await ask('API 키', { mask: true })).trim();
226
+ }
227
+
228
+ const 기본이름 = 제공자.id === 'custom' ? '사내게이트웨이' : 제공자.이름;
229
+ const 이름 = (await ask('이름', { def: 기본이름 })).trim() || 기본이름;
230
+ return { 제공자, 주소들, 열쇠, 이름 };
231
+ }
232
+
83
233
  export async function runSetup() {
84
234
  banner();
85
235
  // 설정에 적어 둔 api-version 을 **붙기 전에** 읽는다. load() 가 애저정하기()를
86
236
  // 부른다. 이걸 뒤에서 하면 사내에서 판을 고정해 둔 곳이 확인만 GA판으로 하고,
87
237
  // 문서에 적어 둔 대로 안 도는 셈이 된다.
88
238
  load();
89
- say(` ${c.gray('모델 연결을 설정합니다. 주소와 키만 있으면 됩니다.')}`);
90
- say('');
239
+ say(` ${c.gray('모델 연결을 설정합니다.')}`);
240
+ // 빈칸 0개짜리 길을 제일 먼저 알려 준다. 이 PC 에 이미 모델이 떠 있는데
241
+ // 주소를 손으로 치게 만드는 것은 우리 잘못이다.
242
+ say(` ${c.gray('이 PC 에 로컬 모델이 떠 있다면')} ${c.cyan('deel scan --save')} ${c.gray('가 빈칸 없이 찾아 줍니다.')}`);
91
243
 
92
- const name = (await ask('이름', { def: '사내게이트웨이' })).trim();
93
- const url = (await ask('주소 (Base URL)')).trim();
94
- if (!url) { say(` ${mark.no} 주소가 비었습니다.`); return 1; }
95
- const key = (await ask('API 키', { mask: true })).trim();
244
+ const 고른것 = await 붙일곳고르기();
245
+ if (!고른것) return 1;
246
+ const { 제공자: 붙일곳, 주소들, 열쇠: key, 이름: name } = 고른것;
96
247
 
97
248
  say('');
98
- const found = await connect(url, key);
249
+ /*
250
+ * 후보를 앞에서부터 두드린다. 하나라도 붙으면 그것으로 간다.
251
+ *
252
+ * Bedrock 처럼 창구가 여럿인 곳이 있고, 리전·계정마다 열린 창구가 다르다.
253
+ * 표를 믿고 하나만 쓰면 그 표가 낡은 날 「연결 실패」 만 남는다 —
254
+ * 물어보면 되는 것을 짐작으로 정하지 않는다.
255
+ */
256
+ let found = null;
257
+ for (const [n, url] of 주소들.entries()) {
258
+ const 마지막 = n === 주소들.length - 1;
259
+ found = await connect(url, key, { 조용히: !마지막, 제공자: 붙일곳 });
260
+ if (found) break;
261
+ }
99
262
  if (!found) return 1;
100
263
 
101
264
  const model = await chooseModel(found);
@@ -116,6 +279,29 @@ export async function runSetup() {
116
279
  const conn = { kind: found.kind, base: found.base, auth: found.auth, key, model };
117
280
  const { facts } = await runProbe(conn);
118
281
 
282
+ /*
283
+ * ── 「나가도 된다」 를 여기서 명시적으로 받는다 ───────────────────────
284
+ *
285
+ * 주소와 허가를 뗀 것이 이번 자물쇠의 요점이다(safety/runmode.js). 주소는
286
+ * 설정 파일에 그대로 있고 손으로도 고칠 수 있지만, 허가는 **사람이 고른
287
+ * 자리에서만** 붙는다. 그래서 둘 다 있어야 나간다 — 설정 파일 한 줄을
288
+ * 고쳐서는 못 나간다.
289
+ *
290
+ * 여기서 안 받아도 대화 화면이 첫 켤 때 다시 묻는다. 그러니 이건 막는
291
+ * 자리가 아니라, **지금 사실을 알려 주는** 자리다. 주소를 방금 친 사람이
292
+ * 그 주소가 바깥이라는 것을 제일 잘 알아들을 자리이기도 하다.
293
+ */
294
+ let 나가도되나 = false;
295
+ if (바깥인가(found.base)) {
296
+ say('');
297
+ say(` ${c.yellow('↗')} ${c.bold(주소가리기(found.base))} ${c.gray('는 이 컴퓨터 밖입니다.')}`);
298
+ say(` ${c.gray('쓰면 시킨 말과, 모델이 읽은 파일의 내용이 그리로 갑니다.')}`);
299
+ 나가도되나 = await confirm('이 연결로 바깥에 나가도 될까요?', true);
300
+ if (!나가도되나) {
301
+ say(` ${c.gray('허락 없이 저장합니다 — 이 연결로 처음 켤 때 다시 물어봅니다.')}`);
302
+ }
303
+ }
304
+
119
305
  const id = slug(name);
120
306
  const cfg = load();
121
307
  upsert(cfg, {
@@ -125,6 +311,12 @@ export async function runSetup() {
125
311
  auth: found.auth,
126
312
  apiKey: key,
127
313
  model,
314
+ // 어디 것인지 적어 둔다. 요금표 주소·열쇠 받는 곳처럼 「이 회사에만 해당되는
315
+ // 안내」를 나중에 꺼내 쓸 때 이 한 칸이 없으면 주소를 보고 다시 짐작해야 한다.
316
+ 제공자: 붙일곳.id,
317
+ // 로컬·사내망이면 이 칸 자체가 없다. 있는 것만으로도 「바깥이다」 라는 뜻이라,
318
+ // 설정 파일을 열어 본 사람이 그것만 보고도 안다.
319
+ ...(나가도되나 ? { online: true } : {}),
128
320
  ctx: facts.ctx ?? null,
129
321
  streaming: facts.streaming ?? false,
130
322
  tools: facts.tools ?? false,
@@ -200,7 +392,18 @@ export async function showStatus() {
200
392
  p.think ? c.green('추론') : c.gray('추론'),
201
393
  ].join(c.gray('·'));
202
394
  say(` ${c.bold(p.name)} ${c.gray(p.model)} ${caps}${here}`);
203
- say(` ${c.gray(주소가리기(p.baseUrl))}`);
395
+ /*
396
+ * 바깥으로 나가는 연결인지, 그리고 허락이 붙어 있는지를 같이 적는다.
397
+ *
398
+ * 주소만 보고는 모른다 — `gw.회사.com` 이 사내인지 바깥인지는 사람이
399
+ * 헷갈리는 자리다. 그리고 허락이 없으면 켤 때 물어본다는 사실도 여기서
400
+ * 미리 알려 준다. 안 그러면 "왜 갑자기 물어보지" 가 된다.
401
+ */
402
+ const 바깥 = 바깥인가(p.baseUrl);
403
+ const 표 = 바깥
404
+ ? `${c.yellow('↗ 바깥')}${p.online ? c.gray(' · 나가도 됨') : c.gray(' · 켤 때 물어봄')}`
405
+ : c.green('⌂ 이 안');
406
+ say(` ${c.gray(주소가리기(p.baseUrl))} ${표}`);
204
407
  }
205
408
  say('');
206
409
  say(` ${c.gray('설정 파일')} ${configPath()}`);
@@ -32,6 +32,7 @@ import { 도구설명EN } from './desc.en.js';
32
32
  import { 그림인가, 그림읽기, 크기말, 기본한도 } from '../backend/vision.js';
33
33
  import { 빠르게찾기, 엔진말, 안볼정규식 } from './fastgrep.js';
34
34
  import { 지시말 } from '../i18n/index.js';
35
+ import { 표몇군데, 표막는말 } from '../safety/secrets.js';
35
36
 
36
37
  /*
37
38
  * 한 번에 돌려줄 양은 **모델에 맞춰** 정한다 (agent/budget.js).
@@ -190,6 +191,23 @@ function 그림잃나(abs, 새내용) {
190
191
  * 것이 반드시 나온다. 내용을 보고 정하면 목록을 관리할 일이 없다.
191
192
  * @returns {string|null} 막을 이유. 써도 되면 null.
192
193
  */
194
+ /*
195
+ * 우리가 가린 표를 파일에 되돌려 쓰려 하나.
196
+ *
197
+ * 명령 출력의 비밀은 이미 가리고 있다(safety/secrets.js). 그런데 모델은 그
198
+ * «가림:…» 을 **진짜 값으로 알고** 파일에 그대로 옮겨 쓸 수 있다. 그러면
199
+ * 진짜 열쇠가 있던 자리에 표가 적힌다 — 비밀을 지키려다 비밀을 지우는 셈이고,
200
+ * 파일은 멀쩡해 보여서 사람은 무엇이 없어졌는지조차 모른다.
201
+ *
202
+ * "이건 진짜 값이 아닙니다" 라고 일러 주고는 있지만 그건 부탁이지 자물쇠가
203
+ * 아니다. 못 알아들은 모델 하나면 그것으로 끝이다. 그림잃나() 와 같은 자리,
204
+ * 같은 까닭이다 — **우리가 안 보여 준 것을 모델이 지우게 두지 않는다.**
205
+ */
206
+ function 가린표되돌리나(보인이름, 새내용) {
207
+ const 몇 = 표몇군데(새내용);
208
+ return 몇 ? 표막는말(보인이름, 몇) : null;
209
+ }
210
+
193
211
  function 바이너리인가(abs) {
194
212
  if (!existsSync(abs)) return null;
195
213
  let buf;
@@ -645,6 +663,9 @@ function 한파일쓰기(args, ctx) {
645
663
  // 우리가 안 보여 준 그림을 모델이 지우게 두지 않는다 (그림잃나 머리말).
646
664
  const 그림막기 = 그림잃나(abs, args.content);
647
665
  if (그림막기) return { error: 그림막기 };
666
+ // 우리가 가린 비밀도 마찬가지다 (가린표되돌리나 머리말).
667
+ const 표막기 = 가린표되돌리나(ctx.scope.show(abs), args.content);
668
+ if (표막기) return { error: 표막기 };
648
669
  ctx.history.snapshot(abs, 'Write');
649
670
  const existed = existsSync(abs);
650
671
  // 덮어쓰기 전 내용. 바뀐 자리를 보여주려면 지금 떠 놔야 한다.
@@ -741,6 +762,10 @@ function 한군데고치기(args, ctx) {
741
762
  if (isPdfPath(abs)) return { error: pdf는못고침(args.file_path) };
742
763
  if (!ctx.seen.has(abs)) return { error: `먼저 Read 로 읽어야 합니다: ${args.file_path}` };
743
764
  if (args.old_string === args.new_string) return { error: 'old_string 과 new_string 이 같습니다' };
765
+ // 새로 넣을 글에만 본다. old_string 쪽은 **찾는 말**이라 표가 들어 있어도
766
+ // 파일이 안 바뀐다 — 못 찾고 끝날 뿐이다 (가린표되돌리나 머리말).
767
+ const 표막기 = 가린표되돌리나(ctx.scope.show(abs), args.new_string);
768
+ if (표막기) return { error: 표막기 };
744
769
 
745
770
  const 읽음 = readTextFull(abs);
746
771
  const text = 읽음.text;
@@ -1079,6 +1104,10 @@ export const TOOLS = {
1079
1104
  const existed = existsSync(abs);
1080
1105
  if (existed && statSync(abs).isDirectory()) return { error: `폴더입니다: ${args.file_path}` };
1081
1106
 
1107
+ // 이어 붙이는 조각에도 가린 표가 섞이면 안 된다 (가린표되돌리나 머리말).
1108
+ const 표막기 = 가린표되돌리나(ctx.scope.show(abs), args.content);
1109
+ if (표막기) return { error: 표막기 };
1110
+
1082
1111
  // Append 는 한 턴에 여러 번 불리는 것이 정상이다. 그래도 되돌리기 이력에
1083
1112
  // 사본이 쌓이지 않는다 — History.snapshot 이 턴마다 한 번만 뜬다(undo.js).
1084
1113
  ctx.history.snapshot(abs, 'Append');
@@ -0,0 +1,67 @@
1
+ /*
2
+ * 위/아래로 지난 입력을 뒤질 때, 치던 글을 붙잡아 둔다.
3
+ *
4
+ * ── 무슨 일이 났었나 ────────────────────────────────────────────────────
5
+ *
6
+ * 「채팅치다가 윗키 누르면 기존에 쓰던 내용 사라지고, 아랫키 눌러도 복구
7
+ * 안 되는 오류」
8
+ *
9
+ * 반쯤 쓰던 글이 통째로 없어진다. 되돌릴 길도 없다. 긴 요청을 몇 줄 쓰다가
10
+ * 아까 뭐라고 보냈더라 하고 위를 한 번 눌렀을 뿐인데 처음부터 다시 써야 한다.
11
+ *
12
+ * ── 왜 이걸 우리가 갖나 ─────────────────────────────────────────────────
13
+ *
14
+ * 위/아래 화살표는 readline 이 처리한다. 그런데 그 동작이 **Node 판마다
15
+ * 다르다.**
16
+ *
17
+ * 새 판: 치던 글을 앞글자로 삼아 이력을 찾는다. 아래로 다 내려오면 그
18
+ * 앞글자를 도로 넣어 준다 — 치던 글이 살아 있다.
19
+ * 옛 판: 그냥 이력으로 갈아끼운다. 아래로 다 내려오면 **빈 줄**을 준다 —
20
+ * 치던 글은 그대로 사라진다.
21
+ *
22
+ * deel 은 Node 20 부터 돈다. 쓰는 사람의 Node 가 몇 판이냐에 따라 글이
23
+ * 날아가기도 하고 안 날아가기도 하는 것은, 우리가 고칠 수 있는 결함이다.
24
+ * 그래서 이 동작만큼은 readline 에 안 맡기고 우리가 갖는다.
25
+ *
26
+ * ── 어떻게 ──────────────────────────────────────────────────────────────
27
+ *
28
+ * 이력을 안 뒤지는 동안(이력자리 -1)의 줄을 계속 적어 둔다. 화살표를 눌러
29
+ * 다시 이력 밖(-1)으로 나오면 적어 둔 것을 돌려준다.
30
+ *
31
+ * 이미 제대로 하는 판에서는 돌려주는 값이 readline 이 넣어 둔 것과 **같다.**
32
+ * 그래서 덧씌워도 화면은 그대로다. 안 하는 판에서만 글이 되살아난다. 판을
33
+ * 알아내서 갈라지지 않는다 — 알아낼 필요가 없게 만든 것이 요점이다.
34
+ */
35
+
36
+ export function 이력지킴이() {
37
+ let 치던것 = '';
38
+
39
+ return {
40
+ /*
41
+ * 이력을 안 뒤지는 동안의 줄을 적어 둔다.
42
+ *
43
+ * 뒤지는 중(이력자리 0 이상)에는 안 적는다. 적으면 위로 올라간 이력이
44
+ * 「치던 글」로 둔갑해서, 아래로 내려왔을 때 이력이 되돌아온다.
45
+ */
46
+ 적기(줄, 이력자리) {
47
+ if ((이력자리 ?? -1) === -1) 치던것 = String(줄 ?? '');
48
+ },
49
+
50
+ /*
51
+ * 화살표가 이력 밖으로 나왔으면 돌려줄 글, 아니면 null.
52
+ *
53
+ * 화살표일 때만이다. 아무 키에나 돌려주면 치고 있는 글을 계속 덮어쓴다.
54
+ */
55
+ 되돌릴것(키이름, 이력자리) {
56
+ if (키이름 !== 'up' && 키이름 !== 'down') return null;
57
+ if ((이력자리 ?? -1) !== -1) return null;
58
+ return 치던것;
59
+ },
60
+
61
+ /** 줄을 보냈다 — 붙잡고 있던 것을 놓는다. */
62
+ 비우기() { 치던것 = ''; },
63
+
64
+ /** 지금 붙잡고 있는 글 (검사용). */
65
+ 지금() { return 치던것; },
66
+ };
67
+ }