deel-local-cli 2.1.5 → 2.1.7

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.5 ⌂ 이 안
326
+ deel 2.1.7 ⌂ 이 안
327
327
  보냄 이 컴퓨터 안 127.0.0.1:11434 ← 여기 말고는 어디로도 안 갑니다
328
328
  ```
329
329
 
@@ -1341,7 +1341,9 @@ zip 은 진짜 `unzip` 으로, tar 는 진짜 `tar` 가 만든 것을 읽혀 교
1341
1341
 
1342
1342
  | 판 | 무엇이 바뀌었나 |
1343
1343
  |---|---|
1344
- | **[2.1.5](docs/ko/releases/2.1.md#215)** | 급한 보안 수정 — 셸 명령이 작업 폴더 밖으로 새던 자리를 막았습니다. 빗금 없는 `..`(`find .. -delete` · `rd /s /q ..`) · `$PWD` · `%CD%` · `$OLDPWD` · 먼저 옮긴 적 없는 `cd -` · `C:..` · 파워셸의 다른 이름(`ri` · `del` …) |
1344
+ | **[2.1.7](docs/ko/releases/2.1.md#217)** | 맥 기본 셸 zsh 에서 파일 이름 Tab 이 됩니다(`bad substitution` 오류였음) · 12MB 넘는 클립보드 그림은 osascript 탓이 아니라 「너무 큽니다」 · 자모로 풀린(NFD) 한글 프로필 이름이 키체인에 주인 없는 항목을 안 남김 |
1345
+ | [2.1.6](docs/ko/releases/2.1.md#216) | 맥에서 제대로 돌게 — 길거나 손으로 바꾼 키체인 열쇠 · 잠긴 키체인, `/tmp` · `/var`(`/private` 링크) 아래 믿은 폴더를 알아봄, 파이프가 늦게 읽어도 64KB 넘는 출력이 안 잘림(맥 · 리눅스). 관문 CI 에 맥이 들어갔습니다 |
1346
+ | [2.1.5](docs/ko/releases/2.1.md#215) | 급한 보안 수정 — 셸 명령이 작업 폴더 밖으로 새던 자리를 막았습니다. 빗금 없는 `..`(`find .. -delete` · `rd /s /q ..`) · `$PWD` · `%CD%` · `$OLDPWD` · 먼저 옮긴 적 없는 `cd -` · `C:..` · 파워셸의 다른 이름(`ri` · `del` …) |
1345
1347
  | [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
1348
  | [2.1.3](docs/ko/releases/2.1.md#213) | 제품 기능 — 도구를 글로 부르는 모델도 알아듣고, `deel run --continue`·`--resume`·`--events` 와 결과의 바뀐 파일(`files`)이 생겼고, git 저장소면 스크립트가 바꾼 것도 `/undo` 로 돌아가고, 언어 서버 없이도 문법을 보고, Outline 이 많이 불리는 파일부터 싣고, MCP 가 주소로 붙어 자료 · 프롬프트까지 씁니다 |
1347
1349
  | [2.1.2](docs/ko/releases/2.1.md#212) | 정책 · 작은 공백 · 속도 — confirm 이 읽기만 하지 않는 MCP 도구를 묻고, 에디터(ACP) 세션은 16개를 넘으면 내려놓았다가 다음 말에 되살리고, `--check-rounds`·`--check-timeout` 과 과제별 검사 설정이 `deel eval` 에 생겼고, 시작 때 읽는 파일이 164개에서 18개로 줄었습니다 |
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.5 ⌂ inside
399
+ deel 2.1.7 ⌂ inside
400
400
  Sends to this machine 127.0.0.1:11434 ← nowhere else
401
401
  ```
402
402
 
@@ -1424,7 +1424,9 @@ so one run tells you everything.
1424
1424
 
1425
1425
  | Version | What changed |
1426
1426
  |---|---|
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` …) |
1427
+ | **[2.1.7](docs/en/releases/2.1.md#217)** | Tab completion for file names works in zsh, the default shell on macOS (it printed `bad substitution`); a clipboard image over 12 MB says it is too big instead of blaming osascript; a decomposed (NFD) Korean profile name no longer leaves orphaned keychain items |
1428
+ | [2.1.6](docs/en/releases/2.1.md#216) | Works properly on macOS — long or hand-edited keychain keys, a locked keychain, folders under `/tmp` · `/var` (links to `/private`) recognized as trusted, and output over 64 KB no longer cut off when a pipe reads slowly (macOS and Linux). macOS joins the gate CI |
1429
+ | [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
1430
  | [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
1431
  | [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 |
1430
1432
  | [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 |
package/bin/deel.js CHANGED
@@ -1292,10 +1292,33 @@ async function main() {
1292
1292
  async function 끝내기(code) {
1293
1293
  process.exitCode = code;
1294
1294
  await closeConnections();
1295
+ await 다나가기(5000);
1295
1296
  const 마지막수단 = setTimeout(() => process.exit(code), 400);
1296
1297
  마지막수단.unref();
1297
1298
  }
1298
1299
 
1300
+ /*
1301
+ * ── 마지막수단이 덜 나간 출력을 버렸다 (40회차 · 맥) ─────────────────────
1302
+ *
1303
+ * 맥·리눅스에서 파이프 쓰기는 비동기다(Node 문서 「A note on process I/O」). 받는 쪽이 늦게
1304
+ * 읽으면 글이 이벤트 루프에 남아 루프가 안 비고, 400ms 뒤 위 마지막수단의 process.exit 이
1305
+ * **남은 글을 버린다** — `deel sbom | 느린것` · `deel run --json | ssh …` 이 반쪽 JSON 을 받는다.
1306
+ * 같은 꼴의 고장이 맥 검사에서 셋(convert · hooks · mutate) 나왔다.
1307
+ *
1308
+ * 그래서 표준출력·표준오류가 **다 나간 뒤에** 마지막수단을 건다. 끝없이 기다리지는 않는다 —
1309
+ * 받는 쪽이 아예 안 읽으면 정한 시간 뒤 예전대로 간다. 빈 글 쓰기의 콜백은 앞의 글이 다
1310
+ * 나간 뒤에 불린다(쓰기는 차례대로 나간다). 닫힌 파이프(EPIPE)면 콜백이 오류와 함께 곧 온다.
1311
+ */
1312
+ function 다나가기(최대) {
1313
+ const 하나 = (흐름) => new Promise((된) => {
1314
+ try { 흐름.write('', () => 된()); } catch { 된(); }
1315
+ });
1316
+ return Promise.race([
1317
+ Promise.all([하나(process.stdout), 하나(process.stderr)]),
1318
+ new Promise((된) => setTimeout(된, 최대).unref()),
1319
+ ]);
1320
+ }
1321
+
1299
1322
  main()
1300
1323
  .then((code) => 끝내기(code ?? 0))
1301
1324
  .catch(async (err) => {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "deel-local-cli",
3
- "version": "2.1.5",
3
+ "version": "2.1.7",
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
@@ -179,8 +179,11 @@ _deel_paths() {
179
179
  if type compopt >/dev/null 2>&1; then
180
180
  compopt -o filenames 2>/dev/null
181
181
  elif [[ -z "$_deel_q" ]]; then
182
- local _deel_i
183
- for _deel_i in "\${!COMPREPLY[@]}"; do COMPREPLY[$_deel_i]=$(printf '%q' "\${COMPREPLY[$_deel_i]}"); done
182
+ local _deel_x
183
+ local -a _deel_t
184
+ _deel_t=()
185
+ for _deel_x in "\${COMPREPLY[@]}"; do _deel_t+=("$(printf '%q' "$_deel_x")"); done
186
+ COMPREPLY=("\${_deel_t[@]}")
184
187
  fi
185
188
  return 0
186
189
  }
package/src/config.js CHANGED
@@ -1,8 +1,8 @@
1
1
  // 연결 프로필 저장/읽기. ~/.deel/config.json 위에 프로젝트의 .deel/config.json 을 겹친다
2
2
  // (믿는 폴더일 때만 — safety/trust.js).
3
3
  import { homedir } from 'node:os';
4
- import { join, resolve } from 'node:path';
5
- import { readFileSync, writeFileSync, mkdirSync, existsSync, chmodSync, renameSync, rmSync } from 'node:fs';
4
+ import { join, resolve, dirname, basename } from 'node:path';
5
+ import { readFileSync, writeFileSync, mkdirSync, existsSync, chmodSync, renameSync, rmSync, realpathSync } from 'node:fs';
6
6
  import { 프록시정하기 } from './backend/proxy.js';
7
7
  import { 셸정하기 } from './tools/shell.js';
8
8
  import { 애저정하기 } from './backend/azure.js';
@@ -87,11 +87,24 @@ export function configPath(root = process.cwd()) {
87
87
  * · 믿고 나면 제 설정의 permissions.allow 를 「저장소가 적은 것이라 걷어냈다」 고
88
88
  * 거짓말했다. 실제로는 이 PC 파일로 읽혀 그대로 걸려 있었다.
89
89
  *
90
- * reset.js 의 같은자리와 같은 기준이다 — 윈도우는 대소문자를 안 가린다.
90
+ * reset.js 의 같은자리와 같은 기준이다 — 윈도우는 대소문자를 안 가린다. 그리고 글자 말고
91
+ * **폴더를 링크 너머 실제 자리로 푼 꼴**로도 견준다. 맥은 `/var` · `/tmp` 가 `/private/…` 을
92
+ * 가리키는 링크라, 집 폴더에서 켜도 DEEL_HOME 과 작업 폴더가 다른 글자로 와서 위 세 가지가
93
+ * 맥에서만 그대로 났다(40회차 맥 실측).
91
94
  */
92
95
  export function 집설정파일인가(파일) {
93
- const [x, y] = [resolve(String(파일 ?? '')), resolve(join(userDir(), 'config.json'))];
94
- return process.platform === 'win32' ? x.toLowerCase() === y.toLowerCase() : x === y;
96
+ const 고름 = (p) => (process.platform === 'win32' ? p.toLowerCase() : p);
97
+ const 꼴들 = (p) => {
98
+ const r = resolve(String(p ?? ''));
99
+ let 진짜 = r;
100
+ try { 진짜 = join(realpathSync.native(dirname(r)), basename(r)); } catch { /* 폴더가 없으면 글자로만 */ }
101
+ // 파일 자체가 집 설정을 가리키는 링크여도 같은 파일이다 (40회차 2차 눈).
102
+ let 끝까지 = 진짜;
103
+ try { 끝까지 = realpathSync.native(r); } catch { /* 파일이 없으면 폴더까지만 */ }
104
+ return [r, 진짜, 끝까지].map(고름);
105
+ };
106
+ const 집것 = 꼴들(join(userDir(), 'config.json'));
107
+ return 꼴들(파일).some((x) => 집것.includes(x));
95
108
  }
96
109
 
97
110
  /**
@@ -803,10 +816,18 @@ export function resolveKey(profile) {
803
816
  const 전체열쇠 = profile?.출처 === '저장소' ? '' : process.env.DEEL_API_KEY;
804
817
  const 값 = byName || 전체열쇠 || profile?.apiKey || '';
805
818
  if (!잠긴것인가(값)) return 값;
819
+ return 잠긴열쇠쓰기(값);
820
+ }
821
+
822
+ /**
823
+ * 잠긴 값을 풀어 쓴다. `풀개` 는 검사가 바꿔 끼우는 자리다 — 키체인이 없는 PC 에서도
824
+ * 「같은 이름표에서 다른 열쇠가 나오면 새 것을 쓴다」 를 잴 수 있게.
825
+ */
826
+ export function 잠긴열쇠쓰기(값, { 풀개 = 풀기 } = {}) {
806
827
  // 푸는 데 파워셸을 한 번 부른다(0.2초쯤). 한마디마다 부르면 그게 다 사람이
807
828
  // 기다리는 시간이라, 판에 한 번만 풀고 들고 있는다.
808
829
  if (푼것.has(값)) return 푼것.get(값);
809
- const r = 풀기(값);
830
+ const r = 풀개(값);
810
831
  if (!r.ok) {
811
832
  /*
812
833
  * **실패는 안 담아 둔다.**
@@ -818,10 +839,24 @@ export function resolveKey(profile) {
818
839
  열쇠탈 = r.why || '잠근 열쇠를 못 풀었습니다';
819
840
  return '';
820
841
  }
821
- 푼것.set(값, r.text);
842
+ if (푼것담나(값)) 푼것.set(값, r.text);
822
843
  return r.text;
823
844
  }
824
845
 
846
+ /*
847
+ * ── 키체인 이름표는 담아 두면 안 된다 (맥 실측 · 39회차) ──────────────────
848
+ *
849
+ * 담아 두기는 「같은 잠긴 값이면 같은 열쇠」 일 때만 맞다. DPAPI 값은 잠근 덩이
850
+ * **그 자체**라 열쇠가 바뀌면 값도 바뀌고, 새 값은 저절로 새로 푼다. 그런데 키체인
851
+ * 값 `keychain:<이름>` 은 자리의 **이름표**다. `deel setup` 으로 같은 프로필에 새
852
+ * 열쇠를 넣으면 `-U` 가 같은 자리를 덮고 이름표는 글자 하나 안 바뀐다. 그래서 떠 있는
853
+ * REPL·ACP 가 /model 이나 새 대화로 연결을 다시 지어도 **옛 열쇠**를 꺼내 보냈다 —
854
+ * 401 이고, 껐다 켜기 전에는 안 풀렸다.
855
+ *
856
+ * 키체인은 그때그때 읽는다. resolveKey 는 연결을 지을 때만 불린다(한마디마다가 아니다).
857
+ */
858
+ export function 푼것담나(값) { return String(값 ?? '').startsWith('dpapi:'); }
859
+
825
860
  export function upsert(cfg, profile) {
826
861
  const i = cfg.profiles.findIndex((x) => x.id === profile.id);
827
862
  if (i >= 0) cfg.profiles[i] = { ...cfg.profiles[i], ...profile };
package/src/reset.js CHANGED
@@ -64,10 +64,20 @@ function 안에것(폴더, 거르기 = () => true) {
64
64
  /** 여러 자리를 센 것을 하나로 모은다. 한 자리라도 못 셌으면 못 센 것이다. */
65
65
  const 셈모으기 = (값들) => (값들.some((n) => n === null) ? null : 값들.reduce((a, n) => a + n, 0));
66
66
 
67
- /** 두 경로가 같은 파일인가. 윈도우는 대소문자를 안 가린다. */
67
+ /**
68
+ * 두 경로가 같은 파일인가. 윈도우는 대소문자를 안 가린다.
69
+ *
70
+ * 글자로만 견주면 맥에서 갈렸다 — `/var` · `/tmp` 는 `/private/…` 을 가리키는 링크라 집
71
+ * 폴더에서 켜도 DEEL_HOME 과 작업 폴더가 서로 다른 글자로 온다(40회차 맥 실측). 그러면 한
72
+ * 파일을 둘로 세고, 연결 칸만 걷어야 할 집 설정을 「저장소 설정」 자리로도 지우러 간다. 윗자리를
73
+ * 진짜 경로로 푼 꼴(윗자리풀기)로도 견준다. 그리고 파일 자체가 링크면(저장소 설정이 집 설정을
74
+ * 가리키는 링크) 끝까지 푼 꼴(실경로)로도 — 같은 파일이면 한 파일로 다뤄야 집 설정의 사람 칸을
75
+ * 지키는 길(연결 칸만 걷기)로 간다(40회차 2차 눈). 지울 때는 여전히 자리 그 자체를 지운다.
76
+ */
68
77
  const 같은자리 = (a, b) => {
69
- const [x, y] = [resolve(a), resolve(b)];
70
- return process.platform === 'win32' ? x.toLowerCase() === y.toLowerCase() : x === y;
78
+ const 꼴 = (p) => [resolve(p), 윗자리풀기(p), 실경로(p)].map((x) => (process.platform === 'win32' ? x.toLowerCase() : x));
79
+ const 저쪽 = 꼴(b);
80
+ return 꼴(a).some((x) => 저쪽.includes(x));
71
81
  };
72
82
 
73
83
  /**
@@ -2,7 +2,7 @@
2
2
  // 승인 프롬프트를 안 쓰는 대신 (1) 작업 범위 밖은 못 건드리고
3
3
  // (2) 되돌릴 수 없는 명령만 막는다. 나머지는 전부 통과시킨다.
4
4
  import { resolve, relative, isAbsolute, sep, dirname, basename, win32, posix } from 'node:path';
5
- import { realpathSync, existsSync, readFileSync } from 'node:fs';
5
+ import { realpathSync, readFileSync, lstatSync } from 'node:fs';
6
6
  import { homedir, userInfo } from 'node:os';
7
7
  import { 내부살림 } from '../tools/fsutil.js';
8
8
 
@@ -727,6 +727,11 @@ function 자리표풀기(s) {
727
727
  * 글로브(`*` `?`)는 일부러 안 넘긴다. `rm /Users/남/*` 은 진짜 경로이고,
728
728
  * 그건 계속 걸려야 한다.
729
729
  */
730
+ /** 그 이름에 무엇이 놓였나. 가리키는 곳이 없는 링크도 놓인 것이다. 못 물어보면 놓였다고 친다. */
731
+ export function 놓였나(경로) {
732
+ try { lstatSync(경로); return true; } catch (e) { return !(e?.code === 'ENOENT' || e?.code === 'ENOTDIR'); }
733
+ }
734
+
730
735
  export function 코드조각인가(낱말) {
731
736
  // 따옴표가 낱말 **안**에 섞였다 = 코드를 가운데서 자른 것이다.
732
737
  // 파일 이름에 따옴표를 쓰는 사람은 없다시피 하고, 있어도 셸에서 못 쓴다.
@@ -775,8 +780,14 @@ export function 코드조각인가(낱말) {
775
780
  * 한 번 막히고, 사람이 보고 다시 친다. 경로를 코드 조각으로 잘못 보면
776
781
  * 울타리가 없는 것과 같고, 그건 아무 표시도 안 난다. 모를 때는 좁은
777
782
  * 쪽으로 간다.
783
+ *
784
+ * 「있다」 는 **그 이름에 무엇이 놓였나**로 잰다(lstat). existsSync 는 링크를
785
+ * 따라가서, 가리키는 곳이 없는 링크를 「없다」 고 했다. 맥 뿌리의
786
+ * `/.VolumeIcon.icns` 가 그 꼴이라 readdir 에는 나오는데 코드 조각으로 넘어갔다
787
+ * (40회차 맥 실측). 없다는 답(ENOENT·ENOTDIR)만 「없다」 고, 못 물어본 것은 위
788
+ * 까닭대로 경로로 친다.
778
789
  */
779
- try { return !existsSync(낱말); } catch { return false; }
790
+ return !놓였나(낱말);
780
791
  }
781
792
  return false;
782
793
  }
@@ -96,7 +96,12 @@ export const 기본키체인이름 = 'deel-gateway-key';
96
96
  export function 키체인이름(프로필id = null) {
97
97
  const 정한것 = process.env.DEEL_KEYCHAIN_NAME;
98
98
  if (정한것) return 정한것;
99
- const 원래 = String(프로필id ?? '');
99
+ /*
100
+ * 글자 모양을 NFC 로 맞춘다. 맥은 파일 이름 따위에서 한글을 자모로 푼 꼴(NFD)로 주기도 해서, 눈에는 같은
101
+ * `회사` 가 한 번은 `deel-gateway-key-회사`, 한 번은 해시 붙은 딴 자리로 갔다 — 다시 잠그면 옛 자리가
102
+ * 주인 없이 남았다(39회차 맥 실측). 읽기는 설정에 적힌 이름표로 하므로 이미 넣어 둔 열쇠는 그대로 풀린다.
103
+ */
104
+ const 원래 = String(프로필id ?? '').normalize('NFC');
100
105
  const id = 원래.trim().toLowerCase().replace(/[^a-z0-9가-힣._-]+/g, '-');
101
106
  if (!id) return 기본키체인이름;
102
107
  if (id === 원래) return `${기본키체인이름}-${id}`;
@@ -159,15 +164,100 @@ $u = [Security.Cryptography.ProtectedData]::Unprotect($b, $null, 'CurrentUser')
159
164
  [Console]::Out.Write([Convert]::ToBase64String($u))
160
165
  `;
161
166
 
167
+ /*
168
+ * ── `security -i` 한 줄에는 끝이 있다 (531 · 2026-09-29 맥 실측) ─────────
169
+ *
170
+ * 열쇠를 명령줄에 안 올리려고 `security -i` 의 stdin 으로 한 줄을 넣는다. 그런데
171
+ * 그 도구는 한 줄을 정해진 크기의 버퍼로 읽고, 넘치면 **앞 토막을 한 명령으로,
172
+ * 뒤 토막을 또 한 명령으로** 읽는다. 잰 것: 2000자 열쇠는 잠기고 풀렸고, 4000자
173
+ * 열쇠는 `unknown command "xFaT…"` 로 못 잠갔다 — 뒤 토막이 딴 명령이 됐다.
174
+ * 앞 토막은 잘린 열쇠를 달고 그대로 돌았을 수 있다.
175
+ *
176
+ * 그래서 줄을 **넣기 전에** 잰다. 넘치면 넣지 않고 못 잠갔다고 말한다 — 이 파일
177
+ * 머리말대로 잠근 척하지 않는다. 한도는 바이트로 잰다(버퍼가 바이트다 · 한글
178
+ * 프로필 이름은 한 글자가 3바이트).
179
+ *
180
+ * 이름·계정도 같은 줄에 들어간다. 빈칸이 있으면 낱말이 갈리고(usage 오류로 못 잠금),
181
+ * 줄바꿈이 있으면 다음 줄이 딴 명령으로 읽힌다 — DEEL_KEYCHAIN_NAME 에 실제로 넣어
182
+ * 쟀다. 따옴표·역빗금은 이 도구가 어떻게 읽는지 우리가 장담 못 하니 같이 거절한다.
183
+ * 조용히 기본 이름으로 바꾸지 않는다 — DEEL_KEYCHAIN_NAME 은 검사가 사람의 진짜
184
+ * 열쇠를 안 덮으려고 주는 자리라, 바꿔 넣으면 그 자리를 덮는다(키체인이름 머리말).
185
+ */
186
+ export const 키체인줄한도 = 4000;
187
+ const 키체인시한 = 20000;
188
+ const 줄을가르는글자 = /[\s"'\\\p{Cc}]/u;
189
+
190
+ /**
191
+ * `security -i` 에 넣을 한 줄을 짓는다. 넣을 수 없으면 왜인지 준다.
192
+ *
193
+ * 짓기만 하고 안 돌린다 — 맥이 없는 자리에서도 검사가 이 판단을 잴 수 있게.
194
+ *
195
+ * @returns {{ok: true, 줄: string} | {ok: false, 왜: string}}
196
+ */
197
+ export function 키체인넣을줄(글, 이름, 계정) {
198
+ // 빈 열쇠는 `-w -U` 가 되어 `-U` 를 열쇠로 읽는다(2차 눈). 잠그기() 가 먼저 거르지만 여기서도 안 짓는다.
199
+ if (!String(글 ?? '')) return { ok: false, 왜: '넣을 열쇠가 비어 있습니다' };
200
+ for (const [무엇, 값] of [['키체인 이름', 이름], ['계정 이름', 계정]]) {
201
+ const s = String(값 ?? '');
202
+ if (!s) return { ok: false, 왜: `${무엇}이 비어 있습니다` };
203
+ if (줄을가르는글자.test(s)) {
204
+ const 어디서 = 무엇 === '키체인 이름' && process.env.DEEL_KEYCHAIN_NAME ? ' (DEEL_KEYCHAIN_NAME)' : '';
205
+ return { ok: false, 왜: `${무엇} ${JSON.stringify(s)}${어디서} 에 빈칸·줄바꿈·따옴표를 쓸 수 없습니다 — security -i 가 그 자리에서 줄을 가릅니다` };
206
+ }
207
+ }
208
+ // 값 자체는 base64 로 넣는다. 키체인 도구가 줄바꿈·따옴표를 만나면 거기서 끊긴다.
209
+ const 값 = Buffer.from(String(글 ?? ''), 'utf8').toString('base64');
210
+ const 줄 = `add-generic-password -a ${계정} -s ${이름} -w ${값} -U\n`;
211
+ const 크기 = Buffer.byteLength(줄, 'utf8');
212
+ if (크기 > 키체인줄한도) {
213
+ return { ok: false, 왜: `열쇠가 너무 길어 키체인에 못 넣습니다 (명령 한 줄 ${크기}바이트 · 한도 ${키체인줄한도}) — security -i 는 넘친 뒤 토막을 딴 명령으로 읽습니다` };
214
+ }
215
+ return { ok: true, 줄 };
216
+ }
217
+
218
+ /*
219
+ * ── 키체인에 든 것이 우리 꼴이 아니어도 「풀었다」 고 했다 (531 실측) ────
220
+ *
221
+ * `Buffer.from(x, 'base64')` 는 base64 가 아닌 글자를 **던지지 않고 건너뛴다.**
222
+ * 그래서 여기 있던 catch 는 한 번도 안 걸렸고, 사람이 키체인 앱에서 값을 손으로
223
+ * 바꿨거나 같은 이름에 다른 프로그램이 넣은 값이면 ok:true 와 함께 깨진 글자가
224
+ * 나갔다. 그 글자가 게이트웨이로 가고 사람은 401 만 본다 — 풀기() 머리말이 막으려던
225
+ * 바로 그 401 이다.
226
+ *
227
+ * 우리가 넣는 꼴은 하나다: UTF-8 글을 표준 base64 로. 다시 지었을 때 글자 하나
228
+ * 안 다르고, 풀린 바이트가 온전한 UTF-8 일 때만 우리 것으로 친다.
229
+ */
230
+ export function 키체인값풀기(찍힌것) {
231
+ const s = String(찍힌것 ?? '').trim();
232
+ const 아님 = { ok: false, text: '', 왜: '키체인의 값이 deel 이 넣은 꼴(base64)이 아닙니다 — 키체인 앱에서 손으로 바꿨다면 deel setup 으로 다시 넣으세요' };
233
+ if (!/^[A-Za-z0-9+/]+={0,2}$/.test(s)) return 아님;
234
+ const 바이트 = Buffer.from(s, 'base64');
235
+ if (바이트.toString('base64') !== s) return 아님;
236
+ try { return { ok: true, text: new TextDecoder('utf-8', { fatal: true }).decode(바이트), 왜: '' }; }
237
+ catch { return 아님; }
238
+ }
239
+
240
+ /*
241
+ * ── 잠긴 키체인은 20초 서 있다가 `spawnSync security ETIMEDOUT` 한 줄 (531 실측) ──
242
+ *
243
+ * SSH 로 들어온 맥처럼 창을 띄울 데가 없는 자리에서 로그인 키체인이 잠겨 있으면,
244
+ * security 는 풀어 달라는 창을 못 띄우고 그대로 기다린다. 우리 시한에 잘리고 사람에게는
245
+ * node 의 오류 이름만 갔다. 무엇이 왜 서 있었는지와 무엇을 하면 되는지를 준다.
246
+ */
247
+ export function 키체인오류말(err) {
248
+ if (err?.code === 'ETIMEDOUT') {
249
+ return `키체인이 ${키체인시한 / 1000}초 안에 답하지 않았습니다 — 잠겨 있을 수 있습니다(SSH 로 들어온 맥에서 흔합니다). security unlock-keychain 으로 연 뒤 다시 하세요`;
250
+ }
251
+ return err?.message ?? String(err);
252
+ }
253
+
162
254
  /** 맥 키체인. 넣을 때도 명령줄에 안 올린다 — `security -i` 는 명령을 stdin 으로 받는다. */
163
255
  function 키체인넣기(글, 이름) {
164
- const 계정 = userInfo().username;
165
- // 값 자체는 base64 로 넣는다. 키체인 도구가 줄바꿈·따옴표를 만나면 거기서 끊긴다.
166
- const 값 = Buffer.from(글, 'utf8').toString('base64');
167
- const 명령 = `add-generic-password -a ${계정} -s ${이름} -w ${값} -U\n`;
256
+ const 지은것 = 키체인넣을줄(글, 이름, userInfo().username);
257
+ if (!지은것.ok) return { ok: false, err: 지은것.왜 };
168
258
  마지막인자 = ['security', '-i'];
169
- const r = spawnSync('security', ['-i'], { input: 명령, encoding: 'utf8', timeout: 20000 });
170
- if (r.error) return { ok: false, err: r.error.message };
259
+ const r = spawnSync('security', ['-i'], { input: 지은것.줄, encoding: 'utf8', timeout: 키체인시한 });
260
+ if (r.error) return { ok: false, err: 키체인오류말(r.error) };
171
261
  return { ok: r.status === 0, err: (r.stderr ?? '').trim() };
172
262
  }
173
263
 
@@ -175,15 +265,12 @@ function 키체인읽기(이름) {
175
265
  const 계정 = userInfo().username;
176
266
  마지막인자 = ['security', 'find-generic-password', '-a', 계정, '-s', 이름, '-w'];
177
267
  const r = spawnSync('security', ['find-generic-password', '-a', 계정, '-s', 이름, '-w'], {
178
- encoding: 'utf8', timeout: 20000,
268
+ encoding: 'utf8', timeout: 키체인시한,
179
269
  });
180
- if (r.error) return { ok: false, text: '', err: r.error.message };
270
+ if (r.error) return { ok: false, text: '', err: 키체인오류말(r.error) };
181
271
  if (r.status !== 0) return { ok: false, text: '', err: (r.stderr ?? '').trim() || '키체인에 없습니다' };
182
- try {
183
- return { ok: true, text: Buffer.from((r.stdout ?? '').trim(), 'base64').toString('utf8') };
184
- } catch (err) {
185
- return { ok: false, text: '', err: err.message };
186
- }
272
+ const 풀림 = 키체인값풀기(r.stdout);
273
+ return 풀림.ok ? { ok: true, text: 풀림.text } : { ok: false, text: '', err: 풀림.왜 };
187
274
  }
188
275
 
189
276
  /**
@@ -475,8 +562,8 @@ export function 잠금지우기(값 = null) {
475
562
  // 그대로 남는다 — 이 함수 머리말이 막으려던 그 상태다.
476
563
  const 인자 = ['delete-generic-password', '-a', 계정, '-s', (m ? m[2] : '') || 키체인이름()];
477
564
  마지막인자 = ['security', ...인자];
478
- const r = spawnSync('security', 인자, { encoding: 'utf8', timeout: 20000 });
479
- if (r.error) return { 지움: false, 방식: 'keychain', 왜: r.error.message };
565
+ const r = spawnSync('security', 인자, { encoding: 'utf8', timeout: 키체인시한 });
566
+ if (r.error) return { 지움: false, 방식: 'keychain', 왜: 키체인오류말(r.error) };
480
567
  // 없는 것을 지우라고 해도 실패로 온다. 그건 탈이 아니라 이미 없는 것이다.
481
568
  if (r.status !== 0) {
482
569
  const 말 = (r.stderr ?? '').trim();
@@ -44,7 +44,7 @@
44
44
  // `~/.deel/trusted.json` — 사용자 자리다. 프로젝트 안에 두면 저장소가 제
45
45
  // 신뢰를 제가 적는 셈이라 아무 뜻이 없다.
46
46
  import { homedir } from 'node:os';
47
- import { join, resolve, dirname, isAbsolute, parse as 경로쪼개기 } from 'node:path';
47
+ import { join, resolve, dirname, basename, isAbsolute, parse as 경로쪼개기 } from 'node:path';
48
48
  import { readFileSync, writeFileSync, mkdirSync, existsSync, renameSync, rmSync, openSync, closeSync, statSync, realpathSync } from 'node:fs';
49
49
  import { c, mark } from '../ui/ansi.js';
50
50
  import { 말 } from '../i18n/index.js';
@@ -146,8 +146,21 @@ export function 믿는목록({ env = process.env } = {}) {
146
146
  */
147
147
  function 실제자리들(p) {
148
148
  const 풀린 = resolve(p);
149
+ /*
150
+ * 없는 자리는 **있는 데까지 올라가** 푼 뒤 나머지 이름을 붙인다. 통째로 글자만 쓰면, 맥에서
151
+ * `/private/tmp/repo` 로 믿어 둔 폴더를 지운 뒤 `/tmp/repo` 로 빼려 할 때 두 글자가 안 맞아
152
+ * 못 뺐다(40회차 2차 눈). reset.js 의 윗자리풀기와 같은 풀기다.
153
+ */
149
154
  let 진짜 = 풀린;
150
- try { 진짜 = realpathSync.native(풀린); } catch { /* 없는 자리 — 글자 그대로 잰다 */ }
155
+ const 꼬리 = [];
156
+ for (let 위 = 풀린; ;) {
157
+ try { 진짜 = join(realpathSync.native(위), ...꼬리); break; } catch {
158
+ const 더위 = dirname(위);
159
+ if (더위 === 위) break;
160
+ 꼬리.unshift(basename(위));
161
+ 위 = 더위;
162
+ }
163
+ }
151
164
  return 진짜 === 풀린 ? [풀린] : [풀린, 진짜];
152
165
  }
153
166
 
@@ -175,17 +188,39 @@ export function 너무넓은자리(폴더, { env = process.env, platform = proce
175
188
  */
176
189
  export function 믿나(폴더, { env = process.env, platform = process.platform } = {}) {
177
190
  if (env.DEEL_TRUST_ALL === '1') return true; // 검사·컨테이너용. 문서에 안 적는다.
178
- const 나 = 고른경로(폴더, platform);
191
+ const 나들 = 견줄꼴들(폴더, platform);
179
192
  for (const 것 of 목록읽기(env)) {
180
193
  // 목록에 적힌 넓은 자리는 읽을 때도 안 믿는다 (위 너무넓은자리 머리말).
181
194
  // 파일은 안 고친다 — 적힌 것을 조용히 지우면 무엇이 왜 빠졌는지가 안 남는다.
182
195
  if (너무넓은자리(것, { env, platform })) continue;
183
- const 그것 = 고른경로(것, platform);
184
- if (나 === 그것 || 나.startsWith(그것 + '/')) return true;
196
+ const 그것들 = 견줄꼴들(것, platform);
197
+ if (나들.some((나) => 그것들.some((그것) => 나 === 그것 || 나.startsWith(그것 + '/')))) return true;
185
198
  }
186
199
  return false;
187
200
  }
188
201
 
202
+ /*
203
+ * ── 같은 폴더를 두 글자로 부른다 (40회차 맥 실측) ──────────────────────
204
+ *
205
+ * 믿나() 는 적힌 글자로만 견줬다. 맥은 `/var` · `/tmp` 가 `/private/…` 을 가리키는
206
+ * 링크라, `/var/…/proj` 를 믿어 놓고 그 안에서 켜면 `process.cwd()` 가 링크를 따라간
207
+ * `/private/var/…/proj` 를 준다 — 같은 폴더를 두고 「안 믿는 폴더」 라며 프로젝트 설정을
208
+ * 통째로 버렸다. `--root /tmp/proj` 로 켜는 사람이 그대로 겪는다.
209
+ *
210
+ * 그래서 적힌 글자와 실제 자리(realpath)를 **양쪽 다** 견준다. 하위 폴더 규칙(`/` 붙여
211
+ * 견주기)은 그대로다 — 링크를 따라가 같은 파일이 나오는 자리만 같은 자리로 친다.
212
+ * 믿기·안믿기의 「이미 있나」·「뺄 줄」 도 같은 자로 잰다. 다른 자를 쓰면 믿긴 폴더를
213
+ * 못 빼거나, 이미 있는 줄을 또 적는다.
214
+ */
215
+ function 견줄꼴들(p, platform) {
216
+ return [...new Set(실제자리들(String(p ?? '')).map((x) => 고른경로(x, platform)))];
217
+ }
218
+
219
+ function 같은자리인가(가, 나, platform) {
220
+ const 나들 = 견줄꼴들(나, platform);
221
+ return 견줄꼴들(가, platform).some((x) => 나들.includes(x));
222
+ }
223
+
189
224
  /** 목록 파일의 글을 목록으로. 못 풀면 던진다 — 던진 것을 어떻게 칠지는 부르는 쪽이 정한다. */
190
225
  function 목록풀기(글) {
191
226
  const raw = JSON.parse(BOM떼기(글));
@@ -285,13 +320,12 @@ export function 믿기(폴더, { env = process.env, platform = process.platform,
285
320
  if (너무넓은자리(resolve(String(폴더 ?? '')), { env, platform })) {
286
321
  return { ok: false, 넓음: true, 왜: 말('trust.refuseWide') };
287
322
  }
288
- const 나 = 고른경로(폴더, platform);
289
323
  try {
290
324
  // 받아 놓고 안 넘기면 기본값(5초)을 기다린다. 짧게 기다려 보려고 준 수가 아무 일도 안 하면,
291
325
  // 그 수를 준 쪽(검사·배치)은 잠금을 지키는지 재고 있다고 믿으면서 5초를 서 있는다.
292
326
  return 잠그고(env, () => {
293
327
  const 것들 = 쓸목록읽기(env);
294
- if (것들.some((x) => 고른경로(x, platform) === 나)) return { ok: true, 이미: true, 자리: 신뢰자리(env) };
328
+ if (것들.some((x) => 같은자리인가(x, 폴더, platform))) return { ok: true, 이미: true, 자리: 신뢰자리(env) };
295
329
  것들.push(resolve(String(폴더)));
296
330
  return { ok: true, 이미: false, 자리: 쓰기(것들, env) };
297
331
  }, 기다림);
@@ -312,7 +346,6 @@ export function 믿기(폴더, { env = process.env, platform = process.platform,
312
346
  * 두 자리가 다른 자를 쓰면 어느 쪽이든 거짓말이 된다.
313
347
  */
314
348
  export function 안믿기(폴더, { env = process.env, platform = process.platform } = {}) {
315
- const 나 = 고른경로(폴더, platform);
316
349
  let 남길것;
317
350
  let 뺐나 = false;
318
351
  let 자리 = 신뢰자리(env);
@@ -320,12 +353,14 @@ export function 안믿기(폴더, { env = process.env, platform = process.platfo
320
353
  try {
321
354
  잠그고(env, () => {
322
355
  const 것들 = 쓸목록읽기(env);
323
- 남길것 = 것들.filter((x) => 고른경로(x, platform) !== 나);
356
+ 남길것 = 것들.filter((x) => !같은자리인가(x, 폴더, platform));
324
357
  뺐나 = 남길것.length !== 것들.length;
325
358
  if (뺐나) 자리 = 쓰기(남길것, env);
326
359
  });
327
360
  } catch (err) { return { ok: false, 왜: err?.message ?? String(err) }; }
328
- const 위 = 남길것.find((x) => !너무넓은자리(x, { env, platform }) && 나.startsWith(고른경로(x, platform) + '/')) ?? null;
361
+ const 나들 = 견줄꼴들(폴더, platform);
362
+ const 위 = 남길것.find((x) => !너무넓은자리(x, { env, platform })
363
+ && 견줄꼴들(x, platform).some((그것) => 나들.some((나) => 나.startsWith(그것 + '/')))) ?? null;
329
364
  return { ok: true, 뺐나, 자리, 위폴더: 위, 아직믿김: 믿나(폴더, { env, platform }) };
330
365
  }
331
366
 
@@ -93,10 +93,21 @@ function 윈도우에서(방, 부르기) {
93
93
  * PNG 512KB 만 넘어도 ENOBUFS 로 끊겼다 — 레티나 화면 캡처는 대개 그보다 커서
94
94
  * 맥에서는 보통 캡처가 안 붙었다. 그림 한도의 두 배에 여유를 더해 잡는다.
95
95
  */
96
+ /*
97
+ * 받을 자리(maxBuffer)가 넘친 것은 「못 불렀다」 가 아니라 **그림이 크다** 는 뜻이다. 맥에서 12.5MB 캡처를
98
+ * 붙이면 「osascript 를 못 불렀습니다: spawnSync osascript ENOBUFS」 가 나왔다(42회차 맥 실측) — 사람은
99
+ * osascript 가 고장 난 줄 안다. 한도 넘침과 같은 말로 맞춘다.
100
+ */
101
+ function 넘쳤나(r) { return r?.error?.code === 'ENOBUFS'; }
102
+ function 너무큰그림말() {
103
+ return `그림이 너무 큽니다 (한도 ${그림한도 / 1048576}MB 넘음) — 필요한 부분만 잘라서 다시 찍어 주세요.`;
104
+ }
105
+
96
106
  function 맥에서(부르기) {
97
107
  const r = 부르기('osascript', ['-e', 'the clipboard as «class PNGf»'], {
98
108
  encoding: 'utf8', timeout: 20000, maxBuffer: 그림한도 * 2 + 1024 * 1024,
99
109
  });
110
+ if (넘쳤나(r)) return { ok: false, 왜: 너무큰그림말() };
100
111
  if (r.error) return { ok: false, 왜: `osascript 를 못 불렀습니다: ${r.error.message}` };
101
112
  const 낸말 = String(r.stdout ?? '');
102
113
  const m = /«data PNGf([0-9A-Fa-f]+)»/.exec(낸말);
@@ -150,6 +161,7 @@ function 리눅스에서(부르기) {
150
161
  const 종류 = 부르기(x.이름, x.볼것, { encoding: 'utf8', timeout: 10000 });
151
162
  if (!/image\/png/i.test(String(종류.stdout ?? ''))) continue;
152
163
  const r = 부르기(x.이름, x.꺼내기, { timeout: 20000, maxBuffer: 64 * 1024 * 1024 });
164
+ if (넘쳤나(r)) return { ok: false, 왜: 너무큰그림말() };
153
165
  if (r.error || !r.stdout?.length) {
154
166
  const 탈 = r.error?.message || String(r.stderr ?? '').split('\n').find((l) => l.trim())?.trim() || '빈 응답';
155
167
  못꺼냄 ??= `${x.이름}: ${탈}`;