deel-local-cli 2.1.3 → 2.1.5

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/README.ko.md CHANGED
@@ -323,7 +323,7 @@ deel --offline
323
323
  무엇이 어디로 갈 수 있는지는 켤 때 화면 맨 위에 늘 적혀 있습니다.
324
324
 
325
325
  ```
326
- deel 2.1.3 ⌂ 이 안
326
+ deel 2.1.5 ⌂ 이 안
327
327
  보냄 이 컴퓨터 안 127.0.0.1:11434 ← 여기 말고는 어디로도 안 갑니다
328
328
  ```
329
329
 
@@ -369,10 +369,12 @@ deel setup
369
369
  ```
370
370
  1. 열쇠만 있습니다 — 어디 것인지 찾아 드립니다 빈칸 1개
371
371
  2. 주소를 직접 넣기 (사내 게이트웨이 · 로컬) 빈칸 2개
372
- 3. OpenAI (GPT) 빈칸 1개
373
- 4. Anthropic (Claude) 빈칸 1개
374
- 5. Google (Gemini) 빈칸 1개
375
- 6. AWS Bedrock 빈칸 2개
372
+ 3. 브라우저로 로그인 — OpenRouter 빈칸 0개
373
+ 4. OpenAI (GPT) 빈칸 1개
374
+ 5. Anthropic (Claude) 빈칸 1개
375
+ 6. Google (Gemini) 빈칸 1개
376
+ 7. OpenRouter (Claude · GPT · Grok · Kimi …) 빈칸 1개
377
+ 8. AWS Bedrock 빈칸 2개
376
378
  ```
377
379
 
378
380
  1번이 핵심입니다. 열쇠 앞머리로 어디 것인지 짚어서 **한 곳만** 묻습니다.
@@ -387,6 +389,12 @@ deel setup
387
389
  서버로 갑니다. 401 이 오고 끝이지만 **열쇠는 이미 갔습니다.** 그래서 모르는
388
390
  열쇠는 짐작하지 않고 사람에게 묻습니다.
389
391
 
392
+ **3번은 열쇠를 안 넣습니다** (2.1.4). 브라우저가 열리고 OpenRouter 에 로그인하면 그 계정에
393
+ `deel` 이름의 열쇠가 생기고, deel 이 그것을 받아 잠가 둡니다. 열쇠 하나로 Claude · GPT · Grok · Kimi
394
+ 를 다 쓰고 쓴 만큼 그 계정에서 빠집니다. SSH 처럼 브라우저가 다른 기기에 있으면
395
+ `deel setup --no-browser` — 링크를 아무 기기에서 열고 뜬 코드를 붙여 넣습니다. Claude · Codex 구독
396
+ 로그인을 빌려 쓰지는 않습니다 — 그 로그인은 그 회사 도구용이라 남의 도구가 쓰면 어느 날 막힙니다.
397
+
390
398
  Bedrock 은 리전을 고릅니다 — 서울(`ap-northeast-2`) 포함 다섯 곳과 「직접 입력」.
391
399
  Claude 는 몸통 규격이 달라서 여섯 자리를 따로 흡수했습니다
392
400
  ([1.7.0 릴리스 노트](docs/ko/releases/1.x.md#170)).
@@ -1333,7 +1341,9 @@ zip 은 진짜 `unzip` 으로, tar 는 진짜 `tar` 가 만든 것을 읽혀 교
1333
1341
 
1334
1342
  | 판 | 무엇이 바뀌었나 |
1335
1343
  |---|---|
1336
- | **[2.1.3](docs/ko/releases/2.1.md#213)** | 제품 기능 — 도구를 글로 부르는 모델도 알아듣고, `deel run --continue`·`--resume`·`--events` 와 결과의 바뀐 파일(`files`)이 생겼고, git 저장소면 스크립트가 바꾼 것도 `/undo` 로 돌아가고, 언어 서버 없이도 문법을 보고, Outline 이 많이 불리는 파일부터 싣고, MCP 가 주소로 붙어 자료 · 프롬프트까지 씁니다 |
1344
+ | **[2.1.5](docs/ko/releases/2.1.md#215)** | 급한 보안 수정 — 셸 명령이 작업 폴더 밖으로 새던 자리를 막았습니다. 빗금 없는 `..`(`find .. -delete` · `rd /s /q ..`) · `$PWD` · `%CD%` · `$OLDPWD` · 먼저 옮긴 적 없는 `cd -` · `C:..` · 파워셸의 다른 이름(`ri` · `del` …) |
1345
+ | [2.1.4](docs/ko/releases/2.1.md#214) | 브라우저로 로그인 — `deel setup` 3번이 열쇠 없이 OpenRouter 계정 로그인(OAuth PKCE)으로 붙습니다. 열쇠 하나로 Claude · GPT · Grok · Kimi. SSH 는 `--no-browser`, `sk-or-` 열쇠를 알아보고, 긴 모델 목록은 이름으로 좁힙니다 |
1346
+ | [2.1.3](docs/ko/releases/2.1.md#213) | 제품 기능 — 도구를 글로 부르는 모델도 알아듣고, `deel run --continue`·`--resume`·`--events` 와 결과의 바뀐 파일(`files`)이 생겼고, git 저장소면 스크립트가 바꾼 것도 `/undo` 로 돌아가고, 언어 서버 없이도 문법을 보고, Outline 이 많이 불리는 파일부터 싣고, MCP 가 주소로 붙어 자료 · 프롬프트까지 씁니다 |
1337
1347
  | [2.1.2](docs/ko/releases/2.1.md#212) | 정책 · 작은 공백 · 속도 — confirm 이 읽기만 하지 않는 MCP 도구를 묻고, 에디터(ACP) 세션은 16개를 넘으면 내려놓았다가 다음 말에 되살리고, `--check-rounds`·`--check-timeout` 과 과제별 검사 설정이 `deel eval` 에 생겼고, 시작 때 읽는 파일이 164개에서 18개로 줄었습니다 |
1338
1348
  | [2.1.1](docs/ko/releases/2.1.md#211) | 전체 검수에서 나온 것 — 한 칸 위를 물으면 `config explain` 이 열쇠를 보였고, 웹훅 주소·Sentry DSN 이 셸 환경 거르기와 가리기를 둘 다 지났고, `kill` 로 strict 를 지나 Jobs 서버를 껐고, Def·Refs 가 EUC-KR 을 못 읽었습니다 · `deel eval` 고침 |
1339
1349
  | [2.1.0](docs/ko/releases/2.1.md#210) | 맡기면 멈추지 않고, 묻게 하면 diff 를 보여 줍니다 — 「다 됐다」 는 검사가 정합니다(`check`) · 얼마나 해내나는 골든셋으로 잽니다(`deel eval`) · 관리 정책 `"approval"` 로 회사가 승인 바닥을 겁니다 |
package/README.md CHANGED
@@ -396,7 +396,7 @@ deel --offline
396
396
  The destination is printed at the top of every session:
397
397
 
398
398
  ```
399
- deel 2.1.3 ⌂ inside
399
+ deel 2.1.5 ⌂ inside
400
400
  Sends to this machine 127.0.0.1:11434 ← nowhere else
401
401
  ```
402
402
 
@@ -443,10 +443,12 @@ Instead of asking for a URL, it asks **where you're connecting**.
443
443
  ```
444
444
  1. I only have a key — I'll figure out where it goes 1 blank
445
445
  2. Enter an address (corporate gateway · local) 2 blanks
446
- 3. OpenAI (GPT) 1 blank
447
- 4. Anthropic (Claude) 1 blank
448
- 5. Google (Gemini) 1 blank
449
- 6. AWS Bedrock 2 blanks
446
+ 3. Log in with a browser — OpenRouter 0 blanks
447
+ 4. OpenAI (GPT) 1 blank
448
+ 5. Anthropic (Claude) 1 blank
449
+ 6. Google (Gemini) 1 blank
450
+ 7. OpenRouter (Claude · GPT · Grok · Kimi …) 1 blank
451
+ 8. AWS Bedrock 2 blanks
450
452
  ```
451
453
 
452
454
  Option 1 is the point — the key prefix decides which single vendor is asked.
@@ -461,6 +463,13 @@ Probing vendors one by one would send an Anthropic key to OpenAI's server and
461
463
  then to Google's. You get a 401 and stop — but **the key has already left.**
462
464
  So an unrecognized key is never guessed at; you are asked.
463
465
 
466
+ **Option 3 takes no key at all** (2.1.4). A browser opens; log in to OpenRouter and a key named
467
+ `deel` is created on that account, which deel receives and locks away. One key covers Claude, GPT,
468
+ Grok and Kimi, billed to that account as you use it. If the browser is on another machine (SSH),
469
+ use `deel setup --no-browser` — open the link anywhere and paste the code it shows. deel does not
470
+ borrow a Claude or Codex subscription login: those belong to the vendors' own tools, and a
471
+ third-party tool riding on them gets cut off one day.
472
+
464
473
  Bedrock asks for a region — five including Seoul (`ap-northeast-2`), plus
465
474
  "enter it yourself." Claude has a different wire shape, absorbed in six places
466
475
  ([1.7.0 release notes](docs/en/releases/1.x.md#170)).
@@ -1415,7 +1424,9 @@ so one run tells you everything.
1415
1424
 
1416
1425
  | Version | What changed |
1417
1426
  |---|---|
1418
- | **[2.1.3](docs/en/releases/2.1.md#213)** | Product features — models that write tool calls as text are understood, `deel run --continue`/`--resume`/`--events` and changed `files` in the result, `/undo` brings back what a script changed in a git repository, a syntax check without a language server, Outline loads the most imported files first, and MCP attaches by URL with resources and prompts |
1427
+ | **[2.1.5](docs/en/releases/2.1.md#215)** | Urgent security fix — shell commands could reach outside the working folder. Now blocked: a bare `..` (`find .. -delete`, `rd /s /q ..`), `$PWD`, `%CD%`, `$OLDPWD`, a `cd -` with no earlier move, `C:..`, and PowerShell aliases (`ri`, `del` …) |
1428
+ | [2.1.4](docs/en/releases/2.1.md#214) | Log in with a browser — `deel setup` option 3 connects to OpenRouter by logging in (OAuth PKCE), no key to paste; one key for Claude, GPT, Grok and Kimi. `--no-browser` for SSH, `sk-or-` keys recognized, and long model lists narrow by name |
1429
+ | [2.1.3](docs/en/releases/2.1.md#213) | Product features — models that write tool calls as text are understood, `deel run --continue`/`--resume`/`--events` and changed `files` in the result, `/undo` brings back what a script changed in a git repository, a syntax check without a language server, Outline loads the most imported files first, and MCP attaches by URL with resources and prompts |
1419
1430
  | [2.1.2](docs/en/releases/2.1.md#212) | Policy, small gaps and speed — confirm asks before MCP tools that are not read-only, editor (ACP) sessions past 16 are let go and brought back on the next message, `--check-rounds`/`--check-timeout` and per-task check settings in `deel eval`, and startup reads 18 files instead of 164 |
1420
1431
  | [2.1.1](docs/en/releases/2.1.md#211) | From a full review — `config explain` one level up showed keys, webhook URLs and Sentry DSNs passed both the shell-env filter and masking, `kill` stopped a Jobs server past strict, Def/Refs could not read EUC-KR, and `deel eval` fixes |
1421
1432
  | [2.1.0](docs/en/releases/2.1.md#210) | Hand it off and it keeps going; make it ask and it shows the diff — a check decides when it is done (`check`), a golden set measures how much gets done (`deel eval`), and `"approval"` in the managed policy lets a company set an approval floor |
package/bin/deel.js CHANGED
@@ -619,6 +619,8 @@ async function runSbom(flags) {
619
619
  const BOOL = new Set([
620
620
  'help', 'version', 'offline', 'online', 'continue', 'json', 'quiet', 'yes',
621
621
  'no-tui', 'tui', 'all', 'no-hooks',
622
+ // setup --no-browser — 브라우저로 로그인할 때 콜백 대신 코드를 붙여 넣는다 (2.1.4 · providers/login.js).
623
+ 'no-browser',
622
624
  /*
623
625
  * `hard` 가 여기 빠져 있었다. 그래서 `deel reset --hard all --yes` 는
624
626
  * `--hard` 가 뒤의 `all` 을 제 값으로 삼켰다 — 지울 갈래가 사라지니 화면은
@@ -789,7 +791,11 @@ function help() {
789
791
  say(` ${c.cyan('deel run "<시킬 말>"')} 한 번만 돌고 끝내기 (스크립트·배치용)`);
790
792
  say(` ${c.cyan('deel scan')} 이 PC 에 떠 있는 로컬 서버 전부 찾기`);
791
793
  say(` ${c.cyan('deel sessions')} 이 폴더에 남아 있는 대화 목록`);
792
- say(` ${c.cyan('deel setup')} 연결 설정 (주소·키·모델)`);
794
+ say(` ${c.cyan('deel setup')} 연결 설정 (주소·키·모델 · 브라우저로 로그인)`);
795
+ // 새 줄은 두 말로 적는다 — 영어 도움말에 한국어 줄을 더 늘리지 않는다 (아래 영어 대목 머리말).
796
+ say(언어() === 'ko'
797
+ ? ` ${c.cyan('deel setup --no-browser')} 로그인 코드를 붙여 넣기 ${c.gray('(SSH · 브라우저가 다른 기기에 있을 때)')}`
798
+ : ` ${c.cyan('deel setup --no-browser')} Paste the login code instead ${c.gray('(SSH · browser on another machine)')}`);
793
799
  say(` ${c.cyan('deel status')} 연결 상태 보기`);
794
800
  say(` ${c.cyan('deel diagnose')} 저장된 연결로 진단 다시 돌리기`);
795
801
  say(` ${c.cyan('deel reset')} 설정·기억·기록 지우기 ${c.gray('(그냥 치면 보여만 줍니다)')}`);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "deel-local-cli",
3
- "version": "2.1.3",
3
+ "version": "2.1.5",
4
4
  "description": "A coding-agent CLI that runs on local models and private gateways. Zero dependencies, Node 20+.",
5
5
  "type": "module",
6
6
  "license": "MIT",
package/src/completion.js CHANGED
@@ -108,6 +108,7 @@ export const 깃발들 = [
108
108
  { 이름: '--tui', 뜻: '입력 상자를 켠다', en: 'force the input box on', 값: false },
109
109
  { 이름: '--no-tui', 뜻: '입력 상자 없이 줄 화면으로', en: 'plain line output, no input box', 값: false },
110
110
  { 이름: '--no-hooks', 뜻: '이번 판만 훅을 끄고 돌린다', en: 'run this once with hooks off', 값: false },
111
+ { 이름: '--no-browser', 뜻: 'setup: 로그인 코드를 붙여 넣는다 (SSH)', en: 'setup: paste the login code instead (SSH)', 값: false },
111
112
  { 이름: '--continue', 뜻: '지난 대화 이어서', en: 'continue the last conversation', 값: null },
112
113
  { 이름: '--resume', 뜻: '고른 대화 이어서', en: 'resume a chosen conversation', 값: null },
113
114
  { 이름: '--version', 뜻: '판 번호', en: 'version number', 값: false },
package/src/login.js ADDED
@@ -0,0 +1,268 @@
1
+ /*
2
+ * 브라우저로 로그인 — 열쇠를 붙여 넣지 않고 계정으로 받아 온다 (2.1.4).
3
+ *
4
+ * 제공자 데이터에 `링크로그인: { 여는곳, 바꾸는곳, 열쇠이름 }` 이 있으면 이 흐름을 탄다
5
+ * (지금은 openrouter.js 하나). OAuth PKCE 한 바퀴다:
6
+ *
7
+ * 1. 검증자(무작위 32바이트)를 짓고, 그 sha256 을 도전으로 싣은 로그인 주소를 브라우저로 연다.
8
+ * 2. 로그인이 끝나면 로그인 쪽이 코드를 준다 — 이 PC 의 콜백 문으로, 또는 화면에 (원격).
9
+ * 3. 코드 + 검증자를 바꾸는곳에 POST 하면 열쇠가 온다.
10
+ *
11
+ * ── 열쇠는 우리만 받을 수 있다 ─────────────────────────────────────────
12
+ *
13
+ * 코드만으로는 열쇠가 안 나온다 — 검증자가 있어야 한다. 검증자는 브라우저·콜백 길로는 한 번도
14
+ * 안 지나고(그 길로 가는 것은 해시인 도전뿐), 교환 창구에 HTTPS 로 한 번 직접 간다. 다른
15
+ * 프로그램이나 웹 페이지가 콜백에 제 코드를 끼워 넣어도, 그 코드를 낸 도전은 우리 것이 아니라
16
+ * 교환에서 403 이다. 콜백 길도 판마다 새로 지은 16바이트라 밖에서 맞혀 부를 수가 없다.
17
+ *
18
+ * ── 나가는 문 ───────────────────────────────────────────────────────────
19
+ *
20
+ * 우리가 직접 나가는 것은 **교환 POST 한 번**이다. http.js 의 req() — 나가는 문 하나 — 로 가고,
21
+ * 자물쇠는 그 동안만 바꾸는곳 하나를 연다(allowTemporarily). 로그인 화면은 사람의 브라우저가
22
+ * 연다. 봉인(offline)이면 둘 다 안 한다 — 브라우저도 안 열고 묻지도 않는다.
23
+ */
24
+ import { createServer } from 'node:http';
25
+ import { randomBytes, createHash } from 'node:crypto';
26
+ import { req, serverMessage } from './backend/http.js';
27
+ import { allowTemporarily } from './safety/network.js';
28
+ import { 바깥인가 } from './safety/runmode.js';
29
+ import { 브라우저로 } from './ui/browser.js';
30
+ import { ask } from './ui/prompt.js';
31
+ import { say, c, clip } from './ui/ansi.js';
32
+
33
+ /** 코드는 10분 뒤 만료된다(OpenRouter). 그 절반을 기다린다. */
34
+ export const 기다림기본 = 5 * 60_000;
35
+
36
+ /** PKCE 한 쌍. 검증자는 RFC 7636 글자(base64url) 43자, 도전은 그 sha256 의 base64url. */
37
+ export function PKCE만들기() {
38
+ const 검증자 = randomBytes(32).toString('base64url');
39
+ const 도전 = createHash('sha256').update(검증자).digest('base64url');
40
+ return { 검증자, 도전 };
41
+ }
42
+
43
+ /**
44
+ * 브라우저가 이 PC 에 없을 것 같은가.
45
+ *
46
+ * SSH 로 들어왔거나, 리눅스인데 화면(X · Wayland)이 없으면 그렇다고 본다 — 컨테이너·원격 서버.
47
+ * 그때는 콜백 문을 열어 봐야 브라우저가 못 닿으므로, 로그인 쪽이 코드를 화면에 띄우게 하고
48
+ * 사람이 붙여 넣는다. WSL 은 윈도 브라우저가 localhost 로 닿으므로 이 자리로 친다.
49
+ *
50
+ * 틀려도 갇히지 않는다: 이 자리로 잘못 봤으면 `deel setup --no-browser` 로 붙여넣기 길을 간다.
51
+ * 원격으로 잘못 봤으면 붙여넣기 길이 어디서나 되므로 코드만 옮기면 된다.
52
+ */
53
+ export function 원격인가(env = process.env, platform = process.platform) {
54
+ if (env.SSH_CONNECTION || env.SSH_CLIENT || env.SSH_TTY) return true;
55
+ if (platform === 'linux' && !env.DISPLAY && !env.WAYLAND_DISPLAY && !env.WSL_DISTRO_NAME) return true;
56
+ return false;
57
+ }
58
+
59
+ /**
60
+ * 사람이 붙여 넣은 것에서 코드를 꺼낸다. 못 꺼내면 null.
61
+ *
62
+ * 코드만 붙여도, 주소창의 주소를 통째로(`…?code=…`) 붙여도 된다. 주소 속 코드는 퍼센트
63
+ * 인코딩돼 있으므로 풀어서 준다 — 안 풀면 `%2B` 가 그대로 가서 403 이다.
64
+ */
65
+ export function 코드꺼내기(글) {
66
+ const s = String(글 ?? '').trim();
67
+ if (!s) return null;
68
+ // 조각(#) 뒤에 실려 오는 꼴도 받는다.
69
+ const 걸림 = /(?:^|[?&#\s])code=([^&#\s]+)/.exec(s);
70
+ if (걸림) {
71
+ let 코드;
72
+ try { 코드 = decodeURIComponent(걸림[1]); } catch { return null; }
73
+ return 코드 && !/[\s\x00-\x1f]/.test(코드) ? 코드 : null;
74
+ }
75
+ // 코드 없이 온 주소·문장은 코드가 아니다(로그인 쪽 주소를 잘못 붙였거나, 거절하고 돌아온 주소).
76
+ // `=` 는 받는다 — base64 패딩이 붙은 코드가 있다.
77
+ return /^[^\s/:?&#]+$/.test(s) ? s : null;
78
+ }
79
+
80
+ /*
81
+ * ── 콜백 문: 127.0.0.1 과 ::1 에 같은 포트로 ────────────────────────────
82
+ *
83
+ * 콜백 주소는 `localhost` 다(OpenRouter 가 아무 포트나 받는 이름). 브라우저는 그것을 127.0.0.1
84
+ * 로도 ::1 로도 풀 수 있어서 둘 다에 듣는다. 같은 포트의 ::1 을 **남이 쥐고 있으면** 그 포트는
85
+ * 버리고 새로 고른다 — 안 그러면 ::1 로 푼 브라우저가 코드를 그 남에게 넘기고, 우리는 5분을
86
+ * 기다리다 끝난다(2.1.4 Gemini 검토). IPv6 가 아예 없는 기계는 127.0.0.1 하나로 간다.
87
+ */
88
+ function 듣기(서버, 포트, 호스트) {
89
+ return new Promise((풀기, 버리기) => {
90
+ const 탈 = (e) => { 서버.off('listening', 됨); 버리기(e); };
91
+ // 듣기 시작한 뒤의 서버 오류(받기 중 EMFILE 따위)는 삼킨다 — 받이가 없으면 프로세스가 죽는다.
92
+ // 로그인은 기다림이 끝나면 저절로 실패로 끝난다.
93
+ const 됨 = () => { 서버.off('error', 탈); 서버.on('error', () => {}); 풀기(); };
94
+ 서버.once('error', 탈);
95
+ 서버.once('listening', 됨);
96
+ 서버.listen(포트, 호스트);
97
+ });
98
+ }
99
+ /*
100
+ * 닫는다. 쉬는 연결(브라우저가 미리 열어 둔 것)은 바로 닫고, 아직 답을 보내는 연결은 조금 기다렸다가
101
+ * 끊는다 — 답이 나가기 전에 끊으면 브라우저에는 「로그인됐습니다」 대신 연결 끊김이 뜬다.
102
+ */
103
+ function 다닫기(서버들) {
104
+ return Promise.all(서버들.map((s) => new Promise((풀기) => {
105
+ try {
106
+ s.close(() => 풀기());
107
+ s.closeIdleConnections?.();
108
+ setTimeout(() => s.closeAllConnections?.(), 1000).unref();
109
+ } catch { 풀기(); }
110
+ })));
111
+ }
112
+
113
+ /** @returns {Promise<{포트: number, 귀들: number, 닫기: () => Promise<void>}>} */
114
+ export async function 콜백귀열기(처리, { 첫포트 = 0, 시도 = 8 } = {}) {
115
+ let 포트 = 첫포트;
116
+ for (let n = 0; n < 시도; n++) {
117
+ const 넷 = createServer(처리);
118
+ try { await 듣기(넷, 포트, '127.0.0.1'); } catch { 포트 = 0; continue; }
119
+ const p = 넷.address().port;
120
+ const 여섯 = createServer(처리);
121
+ let 탈 = null;
122
+ try { await 듣기(여섯, p, '::1'); } catch (e) { 탈 = e; }
123
+ if (!탈) return { 포트: p, 귀들: 2, 닫기: () => 다닫기([넷, 여섯]) };
124
+ if (탈.code === 'EADDRINUSE') { await 다닫기([넷]); 포트 = 0; continue; }
125
+ return { 포트: p, 귀들: 1, 닫기: () => 다닫기([넷]) };
126
+ }
127
+ const 넷 = createServer(처리);
128
+ await 듣기(넷, 0, '127.0.0.1');
129
+ return { 포트: 넷.address().port, 귀들: 1, 닫기: () => 다닫기([넷]) };
130
+ }
131
+
132
+ // 브라우저에 돌려줄 한 쪽. 코드는 안 싣는다. 이 쪽 주소에 코드가 있으므로 남에게 안 넘기고
133
+ // (no-referrer) 어디에도 안 남긴다(no-store). 밖의 것을 하나도 안 불러온다.
134
+ function 쪽보내기(res, 됐나) {
135
+ const 몸 = `<!doctype html><html lang="ko"><meta charset="utf-8"><title>deel</title>`
136
+ + `<h1>${됐나 ? '로그인됐습니다' : '로그인이 안 됐습니다'}</h1>`
137
+ + `<p>${됐나 ? '터미널로 돌아가세요. 이 창은 닫아도 됩니다.' : '터미널로 돌아가 다시 해 보세요.'}</p></html>`;
138
+ const buf = Buffer.from(몸, 'utf8');
139
+ res.writeHead(됐나 ? 200 : 400, {
140
+ 'content-type': 'text/html; charset=utf-8',
141
+ 'content-length': buf.length,
142
+ 'cache-control': 'no-store',
143
+ 'referrer-policy': 'no-referrer',
144
+ 'content-security-policy': "default-src 'none'",
145
+ connection: 'close',
146
+ });
147
+ res.end(buf);
148
+ }
149
+
150
+ const 이름만 = (제공자) => String(제공자?.이름 ?? '').replace(/\s*\(.*\)\s*$/, '') || '로그인';
151
+
152
+ /**
153
+ * 로그인해서 열쇠를 받아 온다. 던지지 않는다.
154
+ *
155
+ * @param 제공자 `링크로그인` 칸이 있는 제공자
156
+ * @param o.봉인 offline 이 켜져 있나. 켜져 있고 로그인 창구가 바깥이면 아무것도 안 한다.
157
+ * @param o.원격 브라우저가 이 PC 에 없나 — 그러면 콜백 대신 코드를 붙여 넣게 한다.
158
+ * @param o.열기 주소를 브라우저로 여는 것 (검사가 브라우저 흉내로 갈아 끼운다)
159
+ * @param o.받기 한 줄 묻기 (붙여넣기 길)
160
+ * @returns {Promise<{ok: true, 열쇠: string} | {ok: false, 까닭: string}>}
161
+ */
162
+ export async function 링크로그인(제공자, {
163
+ 봉인 = false, 원격 = 원격인가(), 열기 = 브라우저로, 받기 = (물음) => ask(물음), 기다림 = 기다림기본, 말 = say,
164
+ } = {}) {
165
+ const 곳 = 제공자?.링크로그인;
166
+ if (!곳?.여는곳 || !곳?.바꾸는곳) return { ok: false, 까닭: '이 제공자는 브라우저 로그인을 열지 않습니다.' };
167
+ if (봉인 && (바깥인가(곳.여는곳) || 바깥인가(곳.바꾸는곳))) {
168
+ return { ok: false, 까닭: '봉인되어 있어 로그인하러 나가지 않습니다 — 관리 정책이나 설정에 offline 이 켜져 있습니다.' };
169
+ }
170
+
171
+ const { 검증자, 도전 } = PKCE만들기();
172
+ const 로그인주소 = (콜백) => {
173
+ const u = new URL(곳.여는곳);
174
+ if (콜백) u.searchParams.set('callback_url', 콜백);
175
+ u.searchParams.set('code_challenge', 도전);
176
+ u.searchParams.set('code_challenge_method', 'S256');
177
+ if (곳.열쇠이름) u.searchParams.set('key_label', 곳.열쇠이름);
178
+ return u.href;
179
+ };
180
+ const 누구 = 이름만(제공자);
181
+
182
+ let 코드;
183
+ if (원격) {
184
+ // 콜백 없이 가면 로그인 쪽이 코드를 화면에 띄운다. 어느 기기의 브라우저로 열어도 된다.
185
+ 말('');
186
+ 말(` ${c.gray(`아래 주소를 아무 기기의 브라우저로 여세요. ${누구} 에 로그인하면 코드가 뜹니다.`)}`);
187
+ 말(` ${로그인주소(null)}`);
188
+ 말('');
189
+ const 받은것 = await 받기('로그인 뒤 뜬 코드 (주소째 붙여도 됩니다)');
190
+ 코드 = 코드꺼내기(받은것);
191
+ if (!코드) {
192
+ return {
193
+ ok: false,
194
+ 까닭: String(받은것 ?? '').trim()
195
+ ? '붙여 넣은 것에서 코드를 못 찾았습니다 — 로그인 뒤 화면에 뜬 코드만, 또는 code= 가 든 주소를 넣으세요.'
196
+ : '코드가 비었습니다 — 아무것도 안 바꿨습니다.',
197
+ };
198
+ }
199
+ } else {
200
+ const 비밀길 = `/deel-${randomBytes(16).toString('hex')}`;
201
+ let 알리기 = null;
202
+ const 돌아옴 = new Promise((풀기) => { 알리기 = 풀기; });
203
+ const 처리 = (rq, rs) => {
204
+ let u = null;
205
+ try { u = new URL(rq.url, 'http://localhost'); } catch { /* 아래에서 404 */ }
206
+ if (rq.method !== 'GET' || u?.pathname !== 비밀길) {
207
+ rs.writeHead(404, { 'content-type': 'text/plain; charset=utf-8', connection: 'close' });
208
+ rs.end('not found');
209
+ return;
210
+ }
211
+ const 받은코드 = u.searchParams.get('code');
212
+ // 쪽이 다 나간 뒤에 받는다 — 받자마자 문을 닫으면 쪽이 끊긴다.
213
+ // 처음 온 것 하나만 받는다 — 새로고침·미리 불러오기로 두 번 와도 약속은 한 번만 풀린다.
214
+ rs.once('finish', () => 알리기(받은코드 ? { 코드: 받은코드 } : { 오류: u.searchParams.get('error') || '코드 없이 돌아왔습니다' }));
215
+ 쪽보내기(rs, !!받은코드);
216
+ };
217
+ let 귀;
218
+ try { 귀 = await 콜백귀열기(처리); }
219
+ catch (e) { return { ok: false, 까닭: `로그인을 받을 문을 못 열었습니다 — ${e?.message ?? e}. deel setup --no-browser 로 해 보세요.` }; }
220
+ let 타이머 = null;
221
+ try {
222
+ const 주소 = 로그인주소(`http://localhost:${귀.포트}${비밀길}`);
223
+ 말('');
224
+ 말(` ${c.gray(`브라우저에서 ${누구} 로그인을 엽니다. 안 열리면 이 주소를 여세요:`)}`);
225
+ 말(` ${주소}`);
226
+ 말(` ${c.gray(`로그인을 기다립니다 (${Math.max(1, Math.round(기다림 / 60000))}분 · 그만두려면 Ctrl+C).`)}`);
227
+ 말(` ${c.gray('브라우저가 다른 기기에 있으면 Ctrl+C 뒤')} ${c.cyan('deel setup --no-browser')}`);
228
+ try { 열기(주소); } catch { /* 주소는 화면에 있다 */ }
229
+ const 결과 = await Promise.race([
230
+ 돌아옴,
231
+ new Promise((풀기) => { 타이머 = setTimeout(() => 풀기({ 시간: true }), 기다림); }),
232
+ ]);
233
+ if (결과.시간) {
234
+ return { ok: false, 까닭: '로그인이 안 돌아와 기다리는 시간이 지났습니다 — 아무것도 안 바꿨습니다. 다시 하려면 deel setup' };
235
+ }
236
+ if (!결과.코드) return { ok: false, 까닭: `로그인하고 돌아오지 않았습니다 — ${clip(String(결과.오류), 120)}` };
237
+ 코드 = 결과.코드;
238
+ } finally {
239
+ clearTimeout(타이머);
240
+ await 귀.닫기();
241
+ }
242
+ }
243
+
244
+ // ── 코드 → 열쇠 ────────────────────────────────────────────────────────
245
+ let r;
246
+ const 닫기 = allowTemporarily(곳.바꾸는곳);
247
+ try {
248
+ r = await req(곳.바꾸는곳, {
249
+ method: 'POST',
250
+ headers: { 'content-type': 'application/json' },
251
+ body: { code: 코드, code_verifier: 검증자, code_challenge_method: 'S256' },
252
+ timeout: 20000,
253
+ });
254
+ } catch (e) {
255
+ // 자물쇠(NetBlocked)·끊음만 던진다. 통신 실패는 r.ok=false 로 온다.
256
+ return { ok: false, 까닭: `열쇠로 못 바꿨습니다 — ${e?.message ?? e}` };
257
+ } finally {
258
+ 닫기();
259
+ }
260
+ if (!r.ok) {
261
+ return { ok: false, 까닭: `열쇠로 못 바꿨습니다 (${r.status ? `HTTP ${r.status}` : '안 닿음'}) — ${clip(serverMessage(r).replace(/\s+/g, ' '), 160)}` };
262
+ }
263
+ const 열쇠 = r.json?.key;
264
+ if (typeof 열쇠 !== 'string' || !열쇠 || /\s/.test(열쇠)) {
265
+ return { ok: false, 까닭: `로그인은 됐는데 열쇠가 안 왔습니다 — ${누구} 쪽 답이 뜻밖의 꼴입니다. 열쇠를 직접 붙여 넣어 주세요.` };
266
+ }
267
+ return { ok: true, 열쇠 };
268
+ }
@@ -27,7 +27,6 @@
27
27
  import { createServer } from 'node:http';
28
28
  import { createReadStream, statSync, existsSync, readdirSync, watch, realpathSync, openSync, readSync, closeSync } from 'node:fs';
29
29
  import { join, extname, relative, sep, isAbsolute, basename } from 'node:path';
30
- import { spawn } from 'node:child_process';
31
30
  import { 내부살림 } from '../tools/fsutil.js';
32
31
  import { detect } from '../tools/encoding.js';
33
32
 
@@ -541,33 +540,5 @@ function 목록주기(abs, 뿌리, res) {
541
540
  res.end(buf);
542
541
  }
543
542
 
544
- /**
545
- * 기본 브라우저로 연다.
546
- *
547
- * 실패해도 조용히 넘어간다 — 주소는 이미 화면에 찍혀 있으므로 손으로 열면 된다.
548
- * 여기서 오류를 띄우면 '띄우기는 됐는데 실패한 것처럼' 보인다.
549
- */
550
- export function 브라우저로(url) {
551
- /*
552
- * 열면 안 되는 자리가 있다.
553
- *
554
- * 검사가 돌 때마다 진짜 브라우저 창이 뜨면 사람 화면이 난장판이 된다.
555
- * 파이프로 넘길 때(로그·캡처)도 열 이유가 없다 — 볼 사람이 없다.
556
- * 상자 검사는 isTTY 를 거짓말하게 만들어 자식을 띄우므로, TTY 만 봐서는
557
- * 못 막는다. 그래서 env 로도 막을 수 있게 뒀고 검사 돌리개가 그걸 켠다.
558
- */
559
- if (process.env.DEEL_NO_OPEN) return false;
560
- if (!process.stdout.isTTY) return false;
561
- try {
562
- const [cmd, args] = process.platform === 'win32'
563
- // start 는 cmd 안엣말이다. 첫 따옴표 한 쌍은 창 제목 자리라 비워 둬야 한다.
564
- ? ['cmd', ['/d', '/s', '/c', 'start', '', url]]
565
- : process.platform === 'darwin' ? ['open', [url]] : ['xdg-open', [url]];
566
- const kid = spawn(cmd, args, { stdio: 'ignore', detached: process.platform !== 'win32', windowsHide: true });
567
- kid.on('error', () => { /* 없으면 그만 */ });
568
- kid.unref();
569
- return true;
570
- } catch {
571
- return false;
572
- }
573
- }
543
+ // 기본 브라우저로 여는 것은 ui/browser.js 로 옮겼다 — 로그인(2.1.4)도 같은 문을 쓴다.
544
+ export { 브라우저로 } from '../ui/browser.js';
@@ -3,7 +3,7 @@
3
3
  *
4
4
  * ── 이게 목록의 주인공이다 ─────────────────────────────────────────────
5
5
  *
6
- * 앞의 넷은 「못 외우는 주소를 대신 적어 둔 메모」 다. 이 다섯째가 진짜로
6
+ * 앞의 다섯은 「못 외우는 주소를 대신 적어 둔 메모」 다. 이 여섯째가 진짜로
7
7
  * 하는 일이고, 화면에서도 위쪽에 둔다.
8
8
  *
9
9
  * 회사마다 규격도 도메인도 다르다. 명단에 없는 회사가 안 되는 것처럼 보이면
@@ -39,11 +39,12 @@
39
39
  import { 제공자 as openai } from './openai.js';
40
40
  import { 제공자 as anthropic } from './anthropic.js';
41
41
  import { 제공자 as gemini } from './gemini.js';
42
+ import { 제공자 as openrouter } from './openrouter.js';
42
43
  import { 제공자 as bedrock } from './bedrock.js';
43
44
  import { 제공자 as 직접 } from './custom.js';
44
45
 
45
46
  /** 화면에 보일 차례. 「직접 넣기」 는 언제나 목록에 있고, 화면에서는 위에 둔다. */
46
- export const 제공자들 = [openai, anthropic, gemini, bedrock, 직접];
47
+ export const 제공자들 = [openai, anthropic, gemini, openrouter, bedrock, 직접];
47
48
 
48
49
  /** id 로 하나 집는다. 모르면 null — 지어내지 않는다. */
49
50
  export function 제공자고르기(id) {
@@ -0,0 +1,62 @@
1
+ /*
2
+ * OpenRouter — 열쇠 하나로 여러 회사 모델 (Claude · GPT · Grok · Kimi …) (2.1.4).
3
+ *
4
+ * 규격은 OpenAI 호환이라 여기도 데이터 한 장이다. 다른 제공자와 다른 칸은 하나 —
5
+ * **`링크로그인`** 이다.
6
+ *
7
+ * ── 열쇠를 안 넣고 계정으로 받는다 ─────────────────────────────────────
8
+ *
9
+ * 「API 키 말고, 링크 타고 로그인하면 바로 쓰게」 가 사람들이 오래 바란 것이다. Claude·Codex 의
10
+ * 구독 로그인을 가져다 쓰는 길은 **안 간다** — 그 로그인은 그 회사 도구용이고, 남의 도구가 쓰면
11
+ * 약관에 걸리거나 어느 날 막힌다. 그러면 사람은 이유도 모르고 못 쓰게 된다.
12
+ *
13
+ * OpenRouter 는 제 계정으로 **열쇠를 내주는 로그인**(OAuth PKCE)을 공식으로 연다
14
+ * (https://openrouter.ai/docs/use-cases/oauth-pkce). 브라우저에서 로그인하면 그 계정에 열쇠가
15
+ * 하나 생기고, 우리는 그것을 받아 사람이 붙여 넣은 열쇠와 똑같이 잠가 둔다. 쓴 만큼 그 계정에서
16
+ * 빠진다. 받은 열쇠는 https://openrouter.ai/keys 에 「deel」 이름으로 보이고 거기서 지울 수 있다.
17
+ *
18
+ * 로그인 흐름은 src/login.js 가 한다. 여기에는 **주소만** 적는다 — 이 칸이 있는 제공자는
19
+ * 누구든 같은 흐름을 탄다. 코드 분기가 아니라 데이터다(providers/index.js 머리말).
20
+ */
21
+ export const 제공자 = {
22
+ id: 'openrouter',
23
+ 이름: 'OpenRouter (Claude · GPT · Grok · Kimi …)',
24
+ 한줄: '열쇠 하나로 여러 회사 모델. 브라우저로 로그인하면 열쇠를 안 넣어도 됩니다.',
25
+
26
+ 규격: 'openai',
27
+ 인증: 'bearer',
28
+ 주소들: () => ['https://openrouter.ai/api/v1'],
29
+ 리전들: null,
30
+
31
+ 규격됐나: true,
32
+ 빈칸: ['열쇠 또는 로그인'],
33
+ 열쇠받는곳: 'https://openrouter.ai/keys',
34
+
35
+ /*
36
+ * `sk-or-v1-…`. openai 의 `sk-` 보다 길어서 이긴다(index.js 의 어디것일까) — 짧은 쪽이 먼저
37
+ * 집으면 OpenRouter 열쇠가 api.openai.com 으로 간다.
38
+ */
39
+ 키앞머리: [/^sk-or-v1-/, /^sk-or-/],
40
+
41
+ // 로그인 창구. 1단계는 브라우저가 열고, 2단계(코드 → 열쇠)는 우리가 한 번 POST 한다.
42
+ 링크로그인: {
43
+ 여는곳: 'https://openrouter.ai/auth',
44
+ 바꾸는곳: 'https://openrouter.ai/api/v1/auth/keys',
45
+ 열쇠이름: 'deel',
46
+ },
47
+
48
+ // 단가는 모델마다 다르고 자주 바뀐다. 아는 척하지 않는다(openai.js 의 요금 머리말).
49
+ 요금: {},
50
+ 요금기준: null,
51
+ 요금표주소: 'https://openrouter.ai/models',
52
+
53
+ 오류읽기({ status, 서버말 }) {
54
+ if (status === 402) {
55
+ return '이 계정에 크레딧이 없습니다 — https://openrouter.ai/settings/credits 에서 채우거나, 이름이 :free 로 끝나는 모델을 고르세요.';
56
+ }
57
+ if (status === 404 && /no endpoints|not a valid model|model/i.test(서버말)) {
58
+ return '그 모델을 지금 받아 주는 곳이 없습니다 — 이름이 틀렸거나 내려간 모델입니다. https://openrouter.ai/models 에서 보세요.';
59
+ }
60
+ return null;
61
+ },
62
+ };
@@ -351,7 +351,13 @@ const BLOCKED = [
351
351
  * 우리는 「빗금으로 **끝나는** 것」 만 뿌리로 봤다. 위 rm 규칙은 이미
352
352
  * 뿌리목표 로 별을 받고 있었다. 한쪽만 받으면 그건 규칙이 아니라 우연이다.
353
353
  */
354
- re: new RegExp(String.raw`Remove-Item\b(?=[^|;&]*\s-r[a-z]*\b)[^|;&]*\s${윈뿌리목표}(?=\s|$)`, 'i'),
354
+ /*
355
+ * 파워셸의 **다른 이름**도 같은 명령이다 (2.1.5 · 2.0.0 울타리 역공격에서 남김).
356
+ * `ri` · `rd` · `rmdir` · `del` · `erase` 는 파워셸에서 전부 Remove-Item 이다. 위 rd · del 규칙은
357
+ * cmd 의 `/s` · `/q` 만 보고, 여기는 긴 이름만 봐서 `ri -Recurse -Force C:\` 가 이 목록을 지나갔다
358
+ * (checkPaths 가 범위 밖으로 막아 줘서 여태 새지는 않았다 — 이 목록은 그와 따로 서는 겹이다).
359
+ */
360
+ re: new RegExp(String.raw`(?<![\w-])(?:Remove-Item|ri|rd|rmdir|del|erase)\b(?=[^|;&]*\s-r[a-z]*\b)[^|;&]*\s${윈뿌리목표}(?=\s|$)`, 'i'),
355
361
  why: '드라이브나 집 폴더를 통째로 지웁니다',
356
362
  },
357
363
  /*
@@ -560,7 +566,11 @@ export function checkPaths(cmd, scope) {
560
566
  if (봐주는자리(t)) continue;
561
567
  // cd 가 가리킨 자리는 위에서 **옮겨 간 자리 기준**으로 이미 봤다. 여기서
562
568
  // 또 보면 뿌리 기준으로 풀려서 `cd src/a && cd ../..` 가 밖으로 읽힌다.
563
- if (옮긴자리.has(t)) continue;
569
+ //
570
+ // 본 **횟수만큼만** 건너뛴다 (2.1.5). 글자로 건너뛰었더니, 빗금 없는 `..` 를 자리로 치자마자
571
+ // `cd src; cd ..; find .. -delete` 의 뒤 `..` 가 앞 cd 의 `..` 덕에 검사를 비켰다.
572
+ const 남은번 = 옮긴자리.get(t) ?? 0;
573
+ if (남은번 > 0) { 옮긴자리.set(t, 남은번 - 1); continue; }
564
574
  // 어디인지 모르는 이전 폴더는 밖으로 본다 (경로낱말 의 `~-` 머리말).
565
575
  if (/^~-(?=[/\\]|$)/.test(t)) {
566
576
  throw new ScopeError(`\`~-\` 는 셸의 이전 폴더(OLDPWD)라 어디인지 알 수 없어 작업 범위 밖으로 봅니다: ${t}`
@@ -620,6 +630,25 @@ function 자리표풀기(s) {
620
630
  * 따로 본다 — 어디인지 모르는 자리라 풀 값이 없다.)
621
631
  */
622
632
  .replace(/^~\+(?=[/\\]|$)/, '.')
633
+ /*
634
+ * `$PWD` · `%CD%` 도 **지금 폴더**다 — `~+` 와 같은 대접 (2.1.5).
635
+ *
636
+ * 자리표가 든 낱말은 아래 경로낱말() 이 「못 푼 것」 으로 보고 자리 검사를 건너뛴다. 이 둘은
637
+ * 값을 아는데도(셸은 작업 폴더에서 뜬다) 모르는 것으로 쳐서 그대로 새었다 —
638
+ *
639
+ * rm -rf ../옆프로젝트 막힘
640
+ * rm -rf $PWD/../옆프로젝트 통과 ← 같은 폴더
641
+ * rd /s /q %CD%\.. 통과 ← 작업 폴더의 부모 통째로
642
+ *
643
+ * 파워셸은 이름의 크기를 안 가려서(`$pwd`) 크기를 안 본다. 따옴표에 싼 `$(pwd)` 도 같은 값이다
644
+ * (안 싼 것은 괄호에서 잘려 `/../x` 가 절대경로로 걸린다). 파워셸의 `$pwd.Path` · `$pwd.ProviderPath`,
645
+ * 링크를 푼 `$(pwd -P)`, 옛 꼴 `` `pwd` `` 도 같은 자리다(Gemini 리뷰 — 넷 다 새고 있었다).
646
+ */
647
+ .replace(/^(?:\$\{PWD\}|\$PWD(?:\.(?:Provider)?Path)?|%CD%|\$\(pwd(?:\s+-[LP])?\)|`pwd(?:\s+-[LP])?`)(?=[/\\]|$)/i, '.')
648
+ /*
649
+ * `$OLDPWD` 는 `~-` 와 같은 자리(이전 폴더)다 — 어디인지 모르니 `~-` 로 바꿔 같은 길로 막는다.
650
+ */
651
+ .replace(/^(?:\$\{OLDPWD\}|\$OLDPWD)(?=[/\\]|$)/i, '~-')
623
652
  /*
624
653
  * `~이름` 은 **그 사람의 집**이다 (사냥5 R5-M1).
625
654
  *
@@ -938,6 +967,29 @@ export function 봐주는자리(경로) {
938
967
  // (`git commit --message=../notes` · `git log --format=%h/%s`). curl 의 `--data=@파일` 은 파일을 읽으니 안 뺀다.
939
968
  const 글받는옵션 = /^(?:message|format|pretty|title|body|subject|description|grep|author|committer|label|comment|notes?)$/i;
940
969
 
970
+ /*
971
+ * ── 빗금 없는 `..` 도 자리다 (2.1.5) ────────────────────────────────────
972
+ *
973
+ * 자리로 치는 것은 빗금이 든 낱말과 드라이브 한 칸(`C:`)뿐이었다. 그래서 **부모 폴더 그 자체**가
974
+ * 후보에도 못 들었다. 기본(auto) 모드에서는 셸 명령을 안 묻고 이 울타리가 마지막 문인데 —
975
+ *
976
+ * rm -rf ../* 막힘
977
+ * find .. -delete 통과 ← 작업 폴더의 부모가 통째로 비었다 (재 봤다)
978
+ * rd /s /q .. 통과 ← cmd
979
+ * Remove-Item -Recurse .. 통과 ← 파워셸
980
+ * git -C .. clean -fdx 통과 ←
981
+ * tar czf o.tgz .. 통과 ← 작업 폴더가 집 바로 밑이면 ~/.ssh 까지 묶여 읽힌다
982
+ *
983
+ * GNU rm 은 `..` 를 스스로 거절해서(`rm -rf ..`) 여태 안 보였을 뿐이다. find · cmd · 파워셸 · git 은
984
+ * 안 거절한다. 드라이브의 **지금 폴더 기준** 꼴(`C:..` · `C:.`)도 같다 — 윈도에서 `C:..` 는 그
985
+ * 드라이브의 지금 폴더의 부모다.
986
+ *
987
+ * `..` 가 **가운데** 든 것(`HEAD..main` · `{1..5}`)은 부모가 아니다. 낱말 **전체**가 `..` 일 때만.
988
+ */
989
+ function 자리꼴인가(s) {
990
+ return s.includes('/') || s.includes('\\') || s === '..' || /^[a-zA-Z]:(?:\.{1,2})?$/.test(s);
991
+ }
992
+
941
993
  /** 명령줄에서 경로처럼 보이는 낱말만 골라낸다. */
942
994
  export function 경로낱말(cmd) {
943
995
  const out = [];
@@ -1034,7 +1086,7 @@ export function 경로낱말(cmd) {
1034
1086
  */
1035
1087
  if (/^~-(?=[/\\]|$)/.test(풀린)) { out.push(풀린); continue; }
1036
1088
  const 살림이름 = 풀린 === '.deel' || 풀린.toLowerCase() === '.deel';
1037
- const 경로같나 = 풀린.includes('/') || 풀린.includes('\\') || /^[a-zA-Z]:$/.test(풀린) || 살림이름;
1089
+ const 경로같나 = 자리꼴인가(풀린) || 살림이름;
1038
1090
  if (!경로같나) continue;
1039
1091
  /*
1040
1092
  * 남은 자리표가 있으면(우리가 못 푼 것) **어디로 갈지 모른다.** 울타리
@@ -1083,7 +1135,7 @@ export function 경로낱말(cmd) {
1083
1135
  if (!/["'`]/.test(날것)) continue; // 따옴표가 없으면 위에서 이미 봤다
1084
1136
  const 뗀것 = 자리표풀기(날것.replace(/["'`]/g, ''));
1085
1137
  if (!뗀것 || 뗀것.startsWith('-')) continue;
1086
- if (!뗀것.includes('/') && !뗀것.includes('\\')) continue;
1138
+ if (!자리꼴인가(뗀것)) continue;
1087
1139
  if (/[$%]/.test(뗀것)) { if (내부살림(뗀것)) out.push(뗀것); continue; }
1088
1140
  if (코드조각인가(뗀것)) continue;
1089
1141
  if (!out.includes(뗀것)) out.push(뗀것);
@@ -1159,7 +1211,8 @@ function 낱말들(마디) {
1159
1211
  function 갈자리(뒤) {
1160
1212
  for (const w of 뒤) {
1161
1213
  if (!w.글) continue;
1162
- if (w.글.startsWith('-')) continue;
1214
+ // `-` 하나는 옵션이 아니라 이전 폴더다 (2.1.5). 옵션으로 건너뛰어 `cd - && rm -rf *` 가 안 보였다.
1215
+ if (w.글.startsWith('-') && w.글 !== '-') continue;
1163
1216
  if (/^\/d$/i.test(w.글)) continue;
1164
1217
  return w.글;
1165
1218
  }
@@ -1169,15 +1222,23 @@ function 갈자리(뒤) {
1169
1222
  /**
1170
1223
  * 폴더를 옮기는 명령을 따라가며, 옮겨 간 자리가 울타리 안인지 본다.
1171
1224
  *
1172
- * @returns {Set<string>} 여기서 이미 본 cd 목적지들. 부르는 쪽은 이걸 두 번
1173
- * 보지 않는다 — 뿌리 기준으로 다시 풀면 `cd src/a && cd ../..` 가 밖이 된다.
1225
+ * @returns {Map<string, number>} 여기서 이미 본 cd 목적지와 그 횟수. 부르는 쪽은 그만큼은
1226
+ * 두 번 보지 않는다 — 뿌리 기준으로 다시 풀면 `cd src/a && cd ../..` 가 밖이 된다.
1174
1227
  */
1175
- function 폴더옮김검사(cmd, scope, 시작 = null, 깊이 = 0, 본것 = new Set()) {
1228
+ function 폴더옮김검사(cmd, scope, 시작 = null, 깊이 = 0, 본것 = new Map()) {
1176
1229
  let 여기 = 시작 ?? scope.root;
1177
1230
  const 쌓은것 = [];
1231
+ // 이 명령 안에서 cd 로 떠나온 자리. `cd -` 가 가는 곳이다 — 없으면 셸이 물려받은 OLDPWD 라 모른다.
1232
+ let 이전 = null;
1233
+ // 어디인지 모르는 자리로 옮겼으면 그 적은 글자(`-` · `~-` · `$OLDPWD`). 뒤에 명령이 오면 막는다.
1234
+ let 모르는자리 = null;
1178
1235
  for (const 마디 of 마디들(cmd)) {
1179
1236
  const 낱말 = 낱말들(마디);
1180
1237
  if (!낱말.length) continue;
1238
+ if (모르는자리 != null) {
1239
+ throw new ScopeError(`\`cd ${모르는자리}\` 는 셸의 이전 폴더(OLDPWD)로 가는데 어디인지 알 수 없어, 그 뒤 명령을 작업 범위 밖에서 도는 것으로 봅니다`
1240
+ + `\n 이 명령 안에 있습니다: ${cmd.slice(0, 120)}`);
1241
+ }
1181
1242
  /*
1182
1243
  * ── cmd 에서 `cd..` 는 `cd ..` 다 (사냥8) ────────────────────────────
1183
1244
  *
@@ -1214,25 +1275,53 @@ function 폴더옮김검사(cmd, scope, 시작 = null, 깊이 = 0, 본것 = new
1214
1275
  for (const w of 낱말.slice(1)) if (w.쌌나) 폴더옮김검사(w.글, scope, 여기, 깊이 + 1, 본것);
1215
1276
  continue;
1216
1277
  }
1217
- if (되돌리기.test(첫)) { 여기 = 쌓은것.pop() ?? 여기; continue; }
1278
+ if (되돌리기.test(첫)) {
1279
+ const 꺼낸 = 쌓은것.pop();
1280
+ if (꺼낸 != null) { 이전 = 여기; 여기 = 꺼낸; }
1281
+ continue;
1282
+ }
1218
1283
  if (!폴더옮김.test(첫)) continue;
1219
1284
 
1220
1285
  const 목적 = 붙은목적 ?? 갈자리(낱말.slice(1));
1221
- // 인자 없는 cd(집으로) · `cd -`(직전 자리) 는 어디로 가는지 여기서 모른다.
1222
- // 모르는 것을 막으면 멀쩡한 명령이 죽는다.
1223
- if (목적 == null || 목적 === '-') continue;
1286
+ // 인자 없는 cd 는 셸마다 뜻이 다르다(bash 는 집 · cmd 는 지금 자리를 찍기만). 모르는 것을
1287
+ // 막으면 멀쩡한 명령이 죽는다.
1288
+ if (목적 == null) continue;
1224
1289
  const 풀린 = 자리표풀기(목적);
1290
+ /*
1291
+ * ── `cd -` · `cd ~-` · `cd $OLDPWD` 는 이전 폴더로 간다 (2.1.5) ─────────────
1292
+ *
1293
+ * 이 명령 안에서 먼저 옮겼으면 그 떠나온 자리다 — `cd src && make && cd -` 는 뿌리로 돌아온다.
1294
+ * 먼저 옮긴 적이 없으면 셸이 물려받은 OLDPWD 라 어디인지 모른다. 여태 `cd -` 를 건너뛰어
1295
+ * `cd - && rm -rf *` 가 그냥 지나갔다. 그 **뒤에 오는 명령**을 밖에서 도는 것으로 본다 —
1296
+ * `cd -` 하나만 있는 것은 옮기고 끝이라 그대로 둔다(아래 모르는자리).
1297
+ *
1298
+ * 그 **아래**(`cd ~-/sub` · `cd $OLDPWD/sub`)도 같은 자리에서 시작한다 — `^~-$` 로 딱 그 꼴만 봤더니
1299
+ * 아래로 붙인 것은 작업 폴더 밑의 `./~-/sub` 라는 이름으로 읽혀 지나갔다(Gemini 리뷰).
1300
+ */
1301
+ const 이전아래 = /^~-(?=[/\\]|$)/.test(풀린);
1302
+ if (목적 === '-' || 이전아래) {
1303
+ // 경로낱말 은 `~-`(· `$OLDPWD`)를 밖으로 올린다. 여기서 풀어 준 것은 checkPaths 가 한 번 비킨다.
1304
+ 본것.set(풀린, (본것.get(풀린) ?? 0) + 1);
1305
+ if (이전 == null) { 모르는자리 = 목적; continue; }
1306
+ if (목적 === '-' || 풀린 === '~-') { [여기, 이전] = [이전, 여기]; continue; }
1307
+ // 이전 폴더 아래로 — 아래의 범위 검사를 그대로 받는다.
1308
+ }
1225
1309
  // 못 푼 자리표가 남아 있으면(`$BUILD_DIR`) 역시 어디로 갈지 모른다.
1226
1310
  if (/[$%]/.test(풀린)) continue;
1227
1311
 
1228
- const 열린 = MSYS풀기(풀린);
1229
- const abs = isAbsolute(열린) ? resolve(열린) : resolve(여기, 열린);
1230
- 본것.add(풀린);
1312
+ const 열린 = MSYS풀기(이전아래 ? 풀린.replace(/^~-[/\\]*/, '') : 풀린);
1313
+ const abs = isAbsolute(열린) ? resolve(열린) : resolve(이전아래 ? 이전 : 여기, 열린);
1314
+ /*
1315
+ * 비킬 낱말로 적는 것은 **따로 선 목적지**뿐이다. `cd..` 처럼 붙여 쓴 것은 경로낱말 에 `..` 로 안
1316
+ * 나오므로, 적어 두면 남은 한 번이 뒤의 딴 `..` 를 비켜 준다 — `cd src && cd.. && find .. -delete`
1317
+ * (Gemini 리뷰). 이전아래 는 위에서 이미 적었다.
1318
+ */
1319
+ if (!붙은목적 && !이전아래) 본것.set(풀린, (본것.get(풀린) ?? 0) + 1);
1231
1320
 
1232
1321
  // 프로그램이 있는 자리로 들어가는 것은 자료를 만지는 일이 아니다.
1233
1322
  // 푼 값이 아니라 **적힌 글자**로 본다 — 윈도우에서 resolve('/usr/bin') 은
1234
1323
  // `C:\usr\bin` 이 되어 목록에 안 걸린다 (checkPaths 도 낱말로 본다).
1235
- if (봐주는자리(풀린)) { 여기 = abs; continue; }
1324
+ if (봐주는자리(풀린)) { 이전 = 여기; 여기 = abs; continue; }
1236
1325
  try { scope.resolve(abs); }
1237
1326
  catch (e) {
1238
1327
  if (e instanceof ScopeError) {
@@ -1251,6 +1340,7 @@ function 폴더옮김검사(cmd, scope, 시작 = null, 깊이 = 0, 본것 = new
1251
1340
  if (이유) throw new BlockedError(`${이유}\n 이 명령 안에 있습니다: ${cmd.slice(0, 120)}`);
1252
1341
 
1253
1342
  if (/^(pushd|push-location)$/i.test(첫)) 쌓은것.push(여기);
1343
+ 이전 = 여기;
1254
1344
  여기 = abs;
1255
1345
  }
1256
1346
  return 본것;
package/src/setup.js CHANGED
@@ -14,6 +14,7 @@ import { allowEndpoint } from './safety/network.js';
14
14
  import { 인증서설정, 인증서등록 } from './backend/clientcert.js';
15
15
  import { 바깥인가, 봉인됐나 } from './safety/runmode.js';
16
16
  import { 제공자들, 제공자고르기, 어디것일까, 주소후보, 막힌까닭, 열쇠다듬기 } from './providers/index.js';
17
+ import { 링크로그인, 원격인가 } from './login.js';
17
18
  import { writeFileSync } from 'node:fs';
18
19
 
19
20
  // 머리 한 줄은 ui/banner.js 에 있다 — 진입점이 이 파일(40개를 끌고 온다) 없이 쓰게 (2.1.2).
@@ -126,14 +127,38 @@ async function connect(url, key, { 조용히 = false, 제공자: 어디 = null }
126
127
  return found;
127
128
  }
128
129
 
130
+ // 한 화면에 보이는 모델 수. 넘으면 이름으로 좁히고, 목록 끝에 「직접 입력」 을 둔다.
131
+ const 모델한화면 = 40;
132
+
129
133
  async function chooseModel(found) {
130
134
  if (!found.models.length) {
131
135
  say('');
132
136
  say(` ${mark.warn} ${c.yellow('모델 목록을 못 받았습니다.')} ${c.gray('이름을 직접 넣어 주세요.')}`);
133
137
  return (await ask('모델 이름')).trim();
134
138
  }
135
- const items = found.models.slice(0, 40).map((m) => ({ label: m.id, note: m.note ?? '' }));
139
+ /*
140
+ * ── 목록이 길면 이름으로 좁힌다 (2.1.4) ─────────────────────────────────
141
+ *
142
+ * 앞 40개만 보여 주고 끝이었다. 사내 게이트웨이는 그걸로 됐는데 OpenRouter 는 모델이 수백 개라,
143
+ * 쓰려는 모델이 41번째 뒤에 있으면 **고를 길이 없었다.** 이름 일부로 좁히고, 그래도 없으면
144
+ * 목록 끝의 「직접 입력」 으로 빠진다 — 목록은 낡고, 빠져나갈 구멍은 늘 둔다(리전 고르기와 같다).
145
+ */
146
+ let 후보 = found.models;
147
+ const 길다 = 후보.length > 모델한화면;
148
+ if (길다) {
149
+ say('');
150
+ say(` ${c.gray(`모델이 ${후보.length}개입니다. 이름 일부로 좁힙니다 (예: claude · gpt · llama). 비우면 앞 ${모델한화면}개.`)}`);
151
+ const 찾을말 = (await ask('찾을 이름')).trim().toLowerCase();
152
+ if (찾을말) {
153
+ const 걸린것 = 후보.filter((m) => String(m.id).toLowerCase().includes(찾을말));
154
+ if (걸린것.length) 후보 = 걸린것;
155
+ else say(` ${mark.warn} ${c.yellow(`「${찾을말}」 이 든 모델 이름이 없습니다 — 맞는 모델이 없습니다.`)} ${c.gray(`앞 ${모델한화면}개를 보여 드립니다.`)}`);
156
+ }
157
+ }
158
+ const items = 후보.slice(0, 모델한화면).map((m) => ({ label: m.id, note: m.note ?? '' }));
159
+ if (길다) items.push({ label: '직접 입력', note: '목록에 없는 이름', 직접: true });
136
160
  const i = await pick('사용할 모델', items);
161
+ if (items[i].직접) return (await ask('모델 이름')).trim();
137
162
  return items[i].label;
138
163
  }
139
164
 
@@ -181,12 +206,20 @@ export async function runProbe(conn, { out = null, 봉인 = false } = {}) {
181
206
  * 「직접 넣기」 를 목록 위쪽에 둔다. 목록이 지원 명단처럼 보이면, 거기 없는
182
207
  * 회사는 안 되는 줄 알고 돌아선다 (providers/index.js 머리말).
183
208
  *
209
+ * 넷째 갈래 — **브라우저로 로그인** (2.1.4). 빈칸 0개다. 열쇠를 안 넣고 계정으로 받아 온다
210
+ * (src/login.js). 번호는 셋째에 둔다: 1·2 는 손에 익은 번호라 안 옮긴다.
211
+ *
212
+ * @param o.봉인 offline 이 켜져 있나 — 로그인은 여기서 바깥으로 나가므로 여기서 거른다
213
+ * @param o.원격 브라우저를 이 PC 에서 못 여나 (`--no-browser` · SSH)
184
214
  * @returns {{제공자, 주소들: string[], 열쇠: string, 이름: string} | null}
185
215
  */
186
- async function 붙일곳고르기() {
216
+ async function 붙일곳고르기({ 봉인 = false, 원격 = false } = {}) {
217
+ // 링크로 로그인하는 첫 제공자. 데이터에 그 칸이 있는 곳이면 누구든 같은 흐름을 탄다.
218
+ const 로그인곳 = 제공자들.find((p) => p.링크로그인);
187
219
  const 목록 = [
188
220
  { id: '열쇠먼저', label: '열쇠만 있습니다 — 어디 것인지 알아봐 주세요', note: '빈칸 1개' },
189
221
  { id: 'custom', label: '주소를 직접 넣기', note: '사내 게이트웨이 · 목록에 없는 곳' },
222
+ ...(로그인곳 ? [{ id: '링크로그인', label: `브라우저로 로그인 — ${로그인곳.이름}`, note: '빈칸 0개 · 열쇠를 안 넣습니다' }] : []),
190
223
  ...제공자들.filter((p) => p.id !== 'custom').map((p) => ({
191
224
  id: p.id,
192
225
  label: p.이름,
@@ -217,6 +250,8 @@ async function 붙일곳고르기() {
217
250
  const 나머지 = 제공자들.map((p) => ({ label: p.이름, note: p.한줄 }));
218
251
  제공자 = 제공자들[await pick('어디 열쇠인가요?', 나머지, { def: 0 })];
219
252
  }
253
+ } else if (고른.id === '링크로그인') {
254
+ 제공자 = 로그인곳;
220
255
  } else {
221
256
  제공자 = 제공자고르기(고른.id);
222
257
  }
@@ -281,9 +316,23 @@ async function 붙일곳고르기() {
281
316
  }
282
317
 
283
318
  // ── 열쇠 ─────────────────────────────────────────────────────────────
284
- if (!열쇠) {
319
+ //
320
+ // 로그인이 되는 곳은 열쇠를 비워 두면 로그인으로 간다 — 벤더 목록에서 골랐어도 같다.
321
+ if (!열쇠 && 고른.id !== '링크로그인') {
285
322
  if (제공자.열쇠받는곳) say(` ${c.gray('열쇠 받는 곳')} ${c.cyan(제공자.열쇠받는곳)}`);
286
- 열쇠 = 열쇠다듬기(await ask('API 키', { mask: true }));
323
+ 열쇠 = 열쇠다듬기(await ask('API 키' + (제공자.링크로그인 ? ' (비우면 브라우저로 로그인)' : ''), { mask: true }));
324
+ }
325
+ if (!열쇠 && 제공자.링크로그인) {
326
+ const 받은것 = await 링크로그인(제공자, { 봉인, 원격 });
327
+ if (!받은것.ok) {
328
+ say('');
329
+ say(` ${mark.no} ${c.yellow(받은것.까닭)}`);
330
+ say('');
331
+ return null;
332
+ }
333
+ 열쇠 = 받은것.열쇠;
334
+ // 받은 열쇠는 화면에 안 적는다. 어디에 어떻게 잠갔는지는 저장한 뒤에 말한다.
335
+ say(` ${mark.ok} ${c.green('로그인했습니다 — 열쇠를 받았습니다.')} ${c.gray('화면에 안 적고, 저장할 때 잠가 둡니다.')}`);
287
336
  }
288
337
 
289
338
  const 기본이름 = 제공자.id === 'custom' ? '사내게이트웨이' : 제공자.이름;
@@ -343,7 +392,14 @@ async function 설정받기(flags = {}) {
343
392
  // 주소를 손으로 치게 만드는 것은 우리 잘못이다.
344
393
  say(` ${c.gray('이 PC 에 로컬 모델이 떠 있다면')} ${c.cyan('deel scan --save')} ${c.gray('가 빈칸 없이 찾아 줍니다.')}`);
345
394
 
346
- const 고른것 = await 붙일곳고르기();
395
+ /*
396
+ * 봉인 판정은 **붙일 곳을 고르기 전에** 한다 (2.1.4). 브라우저로 로그인하는 갈래는 고르는
397
+ * 자리 안에서 바깥으로 나가기 때문이다 — 뒤에서 재면 이미 나간 뒤다. 판정은 깃발과 처음 읽은
398
+ * 설정만 보므로 앞으로 옮겨도 값이 같다.
399
+ */
400
+ const 봉인 = 봉인됐나({ 깃발: flags?.offline, prof: null, cfg: 첫설정 });
401
+
402
+ const 고른것 = await 붙일곳고르기({ 봉인, 원격: flags?.['no-browser'] === true || 원격인가() });
347
403
  if (!고른것) return 1;
348
404
  const { 제공자: 붙일곳, 주소들, 열쇠: key, 이름: name } = 고른것;
349
405
 
@@ -363,7 +419,7 @@ async function 설정받기(flags = {}) {
363
419
  // 적어 둔 봉인만 걸리고, 사람이 그 자리에서 친 `deel setup --offline` 은 안 걸렸다.
364
420
  // 깃발을 믿고 사내 게이트웨이 주소를 넣은 사람은 방금 적은 열쇠까지 실어 보낸 셈이 된다.
365
421
  // (diagnose 는 같은 판에서 `깃발: flags.offline` 을 넘긴다 — 두 문이 갈려 있었다.)
366
- const 봉인 = 봉인됐나({ 깃발: flags?.offline, prof: null, cfg: 첫설정 });
422
+ // 판정 자체는 위에서 했다 — 로그인 갈래가 고르는 자리에서 나가기 때문이다.
367
423
  const 두드릴것 = 봉인
368
424
  ? 주소들.filter((u) => !바깥인가(/^https?:\/\//i.test(u) ? u : `http://${u}`))
369
425
  : 주소들;
@@ -0,0 +1,81 @@
1
+ /*
2
+ * 기본 브라우저로 주소 하나를 연다.
3
+ *
4
+ * 미리보기(preview/serve.js)만 쓰던 것을 여기로 뺐다 — 2.1.4 의 「브라우저로 로그인」 도 같은
5
+ * 문을 쓴다. 미리보기 서버를 통째로 끌고 오지 않게 따로 둔다.
6
+ */
7
+ import { spawn } from 'node:child_process';
8
+
9
+ /**
10
+ * 이 판에서 브라우저를 여는 명령. 못 넘길 주소면 null.
11
+ *
12
+ * @returns {[string, string[], object] | null} [명령, 인자, spawn 에 더할 설정]
13
+ *
14
+ * ── 윈도는 주소를 따옴표로 싼다 (2.1.4) ─────────────────────────────────
15
+ *
16
+ * `cmd /c start "" <url>` 에서 cmd 는 따옴표 밖의 `&` 를 **명령 구분자**로 먹는다. 미리보기 주소
17
+ * (`http://127.0.0.1:포트/`)에는 `&` 가 없어 여태 몰랐다. 로그인 주소에는 셋이 있어서 브라우저에는
18
+ * `?callback_url=…` 까지만 가고, 나머지는 명령으로 돌려진다. Node 는 빈칸 없는 인자를 안 싸므로
19
+ * 우리가 싸고, 그 따옴표를 Node 가 `\"` 로 다시 망치지 않게 그대로 넘긴다(windowsVerbatimArguments).
20
+ * 따옴표가 든 주소는 그 울타리를 스스로 깨므로 **안 넘긴다** — URL 이 만든 주소에는 따옴표가
21
+ * `%22` 로 들어 있어 이 경우는 사람이 지어낸 글뿐이다.
22
+ *
23
+ * `rundll32 url.dll,FileProtocolHandler` 로 cmd 를 비켜 가는 길도 있지만 사내 보안 정책(AppLocker ·
24
+ * EDR)이 그 호출을 막는 일이 흔하다 — 2.1.4 Gemini 검토.
25
+ */
26
+ export function 여는명령(url, platform = process.platform, env = process.env) {
27
+ const 주소 = String(url ?? '');
28
+ if (platform === 'win32') {
29
+ if (/["\r\n]/.test(주소)) return null;
30
+ if (cmd가펼치나(주소, env)) return null;
31
+ // start 는 cmd 안엣말이다. 첫 따옴표 한 쌍은 창 제목 자리라 비워 둬야 한다.
32
+ return ['cmd', ['/d', '/s', '/c', 'start', '""', `"${주소}"`], { windowsVerbatimArguments: true }];
33
+ }
34
+ return [platform === 'darwin' ? 'open' : 'xdg-open', [주소], {}];
35
+ }
36
+
37
+ /*
38
+ * cmd 는 **따옴표 안에서도** `%이름%` 을 그 값으로 바꾼다. 따옴표로 `&` 는 막았지만 이것은 못 막고,
39
+ * 명령 줄에서는 `%` 를 가릴 방법도 없다. 그래서 펼쳐질 자리가 있으면 안 넘긴다 — 주소는 화면에
40
+ * 찍혀 있으니 손으로 열면 된다. 환경에 없는 cmd 의 동적 변수(`%cd%` · `%random%` …)도 센다
41
+ * (2.1.4 Gemini 리뷰). 퍼센트 인코딩 사이(`%3A%2F`)는 그런 이름이 없어서 그대로 간다.
42
+ */
43
+ const cmd동적 = ['cd', 'date', 'time', 'random', 'errorlevel', 'cmdextversion', 'cmdcmdline', 'highestnumanodenumber', '__appdir__', '__cd__'];
44
+ function cmd가펼치나(주소, env) {
45
+ const 이름들 = new Set([...Object.keys(env ?? {}).map((k) => k.toLowerCase()), ...cmd동적]);
46
+ // 겹쳐서 본다 — `%a%b%` 는 a 도 b 도 이름 자리다.
47
+ for (const m of 주소.matchAll(/%(?=([^%]+)%)/g)) {
48
+ if (이름들.has(m[1].toLowerCase())) return true;
49
+ }
50
+ return false;
51
+ }
52
+
53
+ /**
54
+ * 기본 브라우저로 연다. 열러 갔으면 true.
55
+ *
56
+ * 실패해도 조용히 넘어간다 — 주소는 이미 화면에 찍혀 있으므로 손으로 열면 된다.
57
+ * 여기서 오류를 띄우면 '띄우기는 됐는데 실패한 것처럼' 보인다.
58
+ */
59
+ export function 브라우저로(url) {
60
+ /*
61
+ * 열면 안 되는 자리가 있다.
62
+ *
63
+ * 검사가 돌 때마다 진짜 브라우저 창이 뜨면 사람 화면이 난장판이 된다.
64
+ * 파이프로 넘길 때(로그·캡처)도 열 이유가 없다 — 볼 사람이 없다.
65
+ * 상자 검사는 isTTY 를 거짓말하게 만들어 자식을 띄우므로, TTY 만 봐서는
66
+ * 못 막는다. 그래서 env 로도 막을 수 있게 뒀고 검사 돌리개가 그걸 켠다.
67
+ */
68
+ if (process.env.DEEL_NO_OPEN) return false;
69
+ if (!process.stdout.isTTY) return false;
70
+ const 명령 = 여는명령(url);
71
+ if (!명령) return false;
72
+ try {
73
+ const [cmd, args, 덤] = 명령;
74
+ const kid = spawn(cmd, args, { stdio: 'ignore', detached: process.platform !== 'win32', windowsHide: true, ...덤 });
75
+ kid.on('error', () => { /* 없으면 그만 */ });
76
+ kid.unref();
77
+ return true;
78
+ } catch {
79
+ return false;
80
+ }
81
+ }