deel-local-cli 2.1.2 → 2.1.4

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.
@@ -31,12 +31,13 @@
31
31
  import { existsSync, statSync, readFileSync } from 'node:fs';
32
32
  import { execFile } from 'node:child_process';
33
33
  import { tmpdir } from 'node:os';
34
- import { extname, dirname, resolve, join, delimiter, isAbsolute, relative, basename, sep } from 'node:path';
34
+ import { extname, dirname, resolve, join, isAbsolute, relative, basename, sep } from 'node:path';
35
35
  import { walk, SKIP_DIRS, 내부살림 } from './fsutil.js';
36
36
  import { 건너뜀말 } from './ignore.js';
37
37
  import { 확인법들 } from './checkmethods.js';
38
38
  import { decode, looksBinary } from './encoding.js';
39
39
  import { 셸환경 } from '../safety/shellenv.js';
40
+ import { 경로에서찾기 } from '../safety/which.js';
40
41
  import { 말 } from '../i18n/index.js';
41
42
 
42
43
  /* 결과 한 줄을 잇는다 — 빈 조각은 버린다(tools/index.js 의 이어 와 같은 것). */
@@ -271,9 +272,9 @@ export function json보기(글) {
271
272
  * 안 그러면 `-m py_compile` 이 작업 폴더를 sys.path 앞에 넣어 저장소의 `py_compile.py` 를
272
273
  * 가져와 돌린다(같은 구멍의 파이썬 쪽).
273
274
  */
274
- function 명령돌리기(파일, 인자, { cwd, 제한 = 60000, env } = {}) {
275
+ function 명령돌리기(파일, 인자, { cwd, 제한 = 60000, env, 입력 = null } = {}) {
275
276
  return new Promise((끝) => {
276
- execFile(파일, 인자, {
277
+ const 아이 = execFile(파일, 인자, {
277
278
  cwd, env, timeout: 제한, maxBuffer: 4 * 1024 * 1024,
278
279
  windowsHide: true, encoding: 'buffer',
279
280
  }, (err, so, se) => {
@@ -283,38 +284,13 @@ function 명령돌리기(파일, 인자, { cwd, 제한 = 60000, env } = {}) {
283
284
  const code = 시그널 ? null : (err?.code ?? 0);
284
285
  끝({ ok: !시그널 && code === 0, code, 시그널, out, 없음: err?.code === 'ENOENT' });
285
286
  });
287
+ // 표준입력으로 줄 글 (아래 ESM 다시 보기). 안 주면 바로 닫는다 — 열어 두면 읽는 쪽이 기다린다.
288
+ 아이.stdin?.on('error', () => {});
289
+ 아이.stdin?.end(입력 ?? undefined);
286
290
  });
287
291
  }
288
292
 
289
- /**
290
- * 이름으로 실행 파일을 PATH 에서 찾는다 — 셸이 찾게 두지 않는다 (위 머리말).
291
- *
292
- * 지금 폴더를 뜻하는 칸(빈 칸·`.`·상대 경로)과 작업 폴더 안의 칸은 건너뛴다. 그 자리의
293
- * 파일은 저장소가 심을 수 있다. 윈도우에서는 `.exe` 만 본다 — `.cmd`·`.bat` 은 결국
294
- * cmd.exe 를 부르는 것이라 셸을 안 거친다는 뜻이 없어진다.
295
- *
296
- * @returns {string|null} 절대 경로. 못 찾으면 null — 그러면 「도구가 없다」 로 말한다.
297
- */
298
- function 경로에서찾기(이름들, { env = process.env, 뿌리 = null } = {}) {
299
- const 윈 = process.platform === 'win32';
300
- // 윈도우 환경 이름은 대소문자를 안 가린다(Path · PATH). 거른 환경 객체는 평범한 객체라 직접 찾는다.
301
- const 칸 = Object.keys(env ?? {}).find((k) => (윈 ? k.toUpperCase() === 'PATH' : k === 'PATH'));
302
- const 목록 = String((칸 && env[칸]) ?? '').split(delimiter);
303
- const 뿌리abs = 뿌리 ? resolve(뿌리) : null;
304
- for (const 이름 of 이름들) {
305
- for (const 날것 of 목록) {
306
- const 폴더 = 날것.trim().replace(/^"(.*)"$/, '$1');
307
- if (!폴더 || !isAbsolute(폴더)) continue;
308
- if (뿌리abs) {
309
- const rel = relative(뿌리abs, resolve(폴더));
310
- if (rel === '' || (!rel.startsWith('..') && !isAbsolute(rel))) continue;
311
- }
312
- const 후보 = join(폴더, 윈 ? `${이름}.exe` : 이름);
313
- try { if (statSync(후보).isFile()) return 후보; } catch { /* 여기엔 없다 */ }
314
- }
315
- }
316
- return null;
317
- }
293
+ // 이름으로 실행 파일을 PATH 에서 찾는 자는 safety/which.js 로 옮겼다 (2.1.3) — 되돌리기의 git 뜨기도 같은 자를 쓴다.
318
294
 
319
295
  /**
320
296
  * 폴더를 통째로 볼 때 한 번에 보는 파일 수.
@@ -341,7 +317,8 @@ const 볼만한것인가 = (p) => 볼만한확장.has(extname(p).toLowerCase());
341
317
  function 돌릴명령(확장, { 뿌리 = null, env = process.env } = {}) {
342
318
  switch (확장) {
343
319
  case '.js': case '.mjs': case '.cjs':
344
- return { 파일: process.execPath, 인자: (p) => ['--check', p], 어떻게: 'node --check' };
320
+ // `.js` 만 모듈로 한 번 더 본다 — .mjs · .cjs 는 node 가 확장자로 정해 제대로 본다 (돌려보기 머리말).
321
+ return { 파일: process.execPath, 인자: (p) => ['--check', p], 어떻게: 'node --check', 모듈도볼까: 확장 === '.js' };
345
322
  case '.py':
346
323
  return {
347
324
  파일: 경로에서찾기(process.platform === 'win32' ? ['python', 'python3'] : ['python3', 'python'], { env, 뿌리 }),
@@ -363,6 +340,95 @@ const 짧게 = (s, n = 300) => {
363
340
  return t.length > n ? `${t.slice(0, n)}…` : t;
364
341
  };
365
342
 
343
+ /**
344
+ * 고른 명령으로 파일 하나를 돌려 본다. Verify 와 고친 직후 문법 검사(한파일문법)가 같이 쓴다.
345
+ *
346
+ * @returns {{ok:true} | {탈:string} | {못함:true}} 못함 — 이 컴퓨터에 그 도구가 없다
347
+ */
348
+ async function 돌려보기(만들기, abs, { 뿌리, env, 제한 }) {
349
+ const r = await 명령돌리기(만들기.파일, 만들기.인자(abs), { cwd: 뿌리, 제한, env });
350
+ // 도구 자체가 이 컴퓨터에 없으면 '틀렸다' 가 아니라 '못 봤다' 이다.
351
+ // 파이썬이 안 깔린 PC 에서 py 파일을 전부 빨갛게 칠하면 아무도 안 믿는다.
352
+ // (윈도우 스토어 자리표시 python.exe 는 「was not found」 를, 옛 python2 는 -I 에 「Unknown option」 을 낸다.)
353
+ // 그 말들은 **이 파일 이름이 안 나오는** 글에서만 믿는다 (2.0.0 6회차 검증6). 문법 오류 글에는 파일
354
+ // 경로와 원문 한 줄이 따라 나오는데, 원문에 `was not found` 가 있으면 깨진 파일이 「도구가 없다」 로 넘어갔다.
355
+ const 도구없다는말 = !r.out.includes(basename(abs))
356
+ && /not recognized|command not found|찾을 수 없|No such file|was not found|Unknown option/i.test(r.out);
357
+ if (!r.ok && (r.없음 || 도구없다는말)) return { 못함: true };
358
+ if (!r.ok) return { 탈: 짧게(r.out) || `종료코드 ${r.code}`, 끊김: !!r.시그널 };
359
+ /*
360
+ * ── `.js` 인데 import · export 가 있으면 모듈로 한 번 더 본다 (2.1.3) ─────────────
361
+ *
362
+ * package.json 에 "type" 이 없는 자리의 `.js` 는 node 가 CommonJS 로 읽어 보다가 ESM 꼴이면 모듈로
363
+ * 넘기는데(모듈 꼴 알아보기), `--check` 는 그 넘기는 자리에서 **괄호가 안 닫혀 있어도 0 으로 끝난다.**
364
+ * 재 보니 `export const a = 1;\nfunction f() {` 가 node 24 에서 초록이었다 — Verify 가 깨진 파일에
365
+ * 「✓ node --check」 를 적어 왔다. 표준입력으로 `--input-type=module` 을 주면 제대로 본다.
366
+ * 동적 `import(` · `import.meta` 는 CommonJS 에도 있어 안 센다 — 정적 import · export 꼴만.
367
+ *
368
+ * 꼴 찾기는 넉넉히 한다(줄 가운데 `; export { a }` · `export /* … *\/` 도). 넉넉해서 CommonJS 파일이 걸려도
369
+ * 탈을 붙이지 않는다 — 모듈로 틀렸으면 CommonJS 로 한 번 더 보고, 그쪽으로 맞으면 CommonJS 파일이다
370
+ * (틀 문자열 · 주석 속 import 줄. `const package = …` 는 모듈에서만 틀린다). 2차 눈이 짚은 셋이다.
371
+ */
372
+ if (만들기.모듈도볼까) {
373
+ let 글 = '';
374
+ try { 글 = decode(readFileSync(abs)).text; } catch { return { ok: true }; }
375
+ if (/\bexport(?:\s*[{*]|\s*\/\*|\s+(?:default|const|let|var|function|async|class)\b)|\bimport(?:\s*[{*'"]|\s*\/\*|\s+[\w$]+\s*(?:,|from\b))/.test(글)) {
376
+ const 넣어보기 = (꼴) => 명령돌리기(만들기.파일, [`--input-type=${꼴}`, '--check'], { cwd: 뿌리, 제한, env, 입력: 글 });
377
+ const m = await 넣어보기('module');
378
+ // 시간이 다 돼 끊긴 것은 본 것이 아니다 — 첫 검사가 끊겼을 때와 같이 적는다.
379
+ if (m.시그널) return { 탈: `모듈 검사가 ${Math.round(제한 / 1000)}초 안에 안 끝났습니다`, 끊김: true };
380
+ if (!m.ok && !m.없음 && !(await 넣어보기('commonjs')).ok) {
381
+ // 되받이 글($& 따위)로 읽히지 않게 함수로 바꾼다 — 경로에 $& 가 있으면 경로가 깨져 탈을 놓쳤다.
382
+ return { 탈: 짧게(m.out.replaceAll('[stdin]', () => abs)) || `종료코드 ${m.code}` };
383
+ }
384
+ }
385
+ }
386
+ return { ok: true };
387
+ }
388
+
389
+ /*
390
+ * ── 고친 직후 파일 하나의 문법 (2.1.3) ────────────────────────────────
391
+ *
392
+ * 고친 뒤 진단(lsp/diag.js)은 **이미 떠 있는 언어 서버**가 있을 때만 말한다. 로컬 모델을 쓰는 PC 대부분은
393
+ * 언어 서버가 없고, 있어도 첫 파일은 못 받는다. 그 자리에서 괄호 하나가 빠지면 아무 말이 없었다 — 모델은
394
+ * 고친 것으로 치고 다음 파일로 가고, 탈은 몇 걸음 뒤 돌려 볼 때에야 나왔다.
395
+ *
396
+ * 그래서 언어 서버가 말을 못 줬을 때 **싸게 볼 수 있는 것만** 본다. Verify 와 같은 명령 · 같은 거른 환경 ·
397
+ * 같은 셸 없는 길이다(명령돌리기 머리말). HTML 은 안 본다 — 빠진 참조를 찾느라 폴더를 뒤지고, 나눠 쓰는
398
+ * 중간(아직 안 쓴 짝)을 탈로 읽기 쉽다. 그건 일을 다 한 뒤 Verify 의 몫이다.
399
+ *
400
+ * @returns {Promise<{오류:number, 경고:number, 글:string, 출처:string}|null>} 못 봤으면 null — 「성하다」 가 아니다
401
+ */
402
+ export async function 한파일문법(abs, { 뿌리 = null, env = process.env, 제한 = 8000 } = {}) {
403
+ try {
404
+ const 확장 = extname(abs).toLowerCase();
405
+ const 탈하나 = (탈, 출처) => ({ 오류: 1, 경고: 0, 글: String(탈).split('\n').slice(0, 6).map((l) => ` ${l}`).join('\n'), 출처 });
406
+ const 읽는것 = { '.json': ['JSON 파싱', json보기], '.css': ['중괄호 짝', css보기], '.scss': ['중괄호 짝', css보기], '.less': ['중괄호 짝', css보기] }[확장];
407
+ if (읽는것) {
408
+ let buf;
409
+ try { buf = readFileSync(abs); } catch { return null; }
410
+ if (looksBinary(buf)) return null;
411
+ const 탈 = 읽는것[1](decode(buf).text);
412
+ return 탈.length ? 탈하나(탈.join('\n'), 읽는것[0]) : { 오류: 0, 경고: 0, 글: '', 출처: 읽는것[0] };
413
+ }
414
+ const 만들기 = 돌릴명령(확장, { 뿌리, env });
415
+ if (!만들기?.파일 || !existsSync(abs)) return null;
416
+ const r = await 돌려보기(만들기, abs, { 뿌리: 뿌리 ?? dirname(abs), env, 제한 });
417
+ // 없는 도구 · 시간이 다 돼 끊긴 것은 「틀렸다」 가 아니라 못 본 것이다. 고칠 때마다 붙는 자리라 짧게 기다린다.
418
+ if (r.못함 || r.끊김) return null;
419
+ /*
420
+ * 탈 글에 **이 파일 이름이 없으면** 이 파일의 문법 탈이 아니다 — 곁의 package.json 이 깨졌으면 node 는
421
+ * 그것을 읽다 넘어진 글을 낸다. 그걸 이 파일에 붙이면 멀쩡한 파일을 고치러 간다. 못 본 것으로 둔다.
422
+ */
423
+ // node 20 은 그 글에 「… while importing <이 파일>」 로 이 파일 이름까지 적는다 — 이름으로만 가르면 남의 탈이
424
+ // 붙는다(리눅스 CI 가 잡음). 꾸러미 설정 탈은 node 의 오류 번호로 먼저 가른다.
425
+ if (!r.ok && (/ERR_INVALID_PACKAGE_CONFIG|Invalid package config/.test(r.탈) || !r.탈.includes(basename(abs)))) return null;
426
+ return r.ok ? { 오류: 0, 경고: 0, 글: '', 출처: 만들기.어떻게 } : 탈하나(r.탈, 만들기.어떻게);
427
+ } catch {
428
+ return null; // 보다가 터져서 편집이 실패로 보이는 일은 없어야 한다
429
+ }
430
+ }
431
+
366
432
  export const VERIFY_TOOL = {
367
433
  schema: {
368
434
  name: 'Verify',
@@ -496,18 +562,10 @@ export const VERIFY_TOOL = {
496
562
  const 만들기 = 고른명령[확장];
497
563
  if (만들기) {
498
564
  if (!만들기.파일) { 못한것.push({ 이름, 왜: `${확장} 을 확인할 도구가 이 컴퓨터에 없습니다` }); continue; }
499
- const r = await 명령돌리기(만들기.파일, 만들기.인자(abs), { cwd: 뿌리, 제한: 30000, env: 자식환경 });
500
- // 도구 자체가 이 컴퓨터에 없으면 '틀렸다' 가 아니라 '못 봤다' 이다.
501
- // 파이썬이 안 깔린 PC 에서 py 파일을 전부 빨갛게 칠하면 아무도 안 믿는다.
502
- // (윈도우 스토어 자리표시 python.exe 는 「was not found」 를, 옛 python2 는 -I 에 「Unknown option」 을 낸다.)
503
- // 그 말들은 **이 파일 이름이 안 나오는** 글에서만 믿는다 (2.0.0 6회차 검증6). 문법 오류 글에는 파일
504
- // 경로와 원문 한 줄이 따라 나오는데, 원문에 `was not found` 가 있으면 깨진 파일이 「도구가 없다」 로 넘어갔다.
505
- const 도구없다는말 = !r.out.includes(basename(abs))
506
- && /not recognized|command not found|찾을 수 없|No such file|was not found|Unknown option/i.test(r.out);
507
- if (!r.ok && (r.없음 || 도구없다는말)) {
508
- 못한것.push({ 이름, 왜: `${확장} 을 확인할 도구가 이 컴퓨터에 없습니다` });
509
- } else if (r.ok) 된것.push({ 이름, 어떻게: 만들기.어떻게 });
510
- else 탈난것.push({ 이름, 탈: [짧게(r.out) || `종료코드 ${r.code}`] });
565
+ const r = await 돌려보기(만들기, abs, { 뿌리, env: 자식환경, 제한: 30000 });
566
+ if (r.못함) 못한것.push({ 이름, 왜: `${확장} 을 확인할 도구가 이 컴퓨터에 없습니다` });
567
+ else if (r.ok) 된것.push({ 이름, 어떻게: 만들기.어떻게 });
568
+ else 탈난것.push({ 이름, 탈: [r.탈] });
511
569
  continue;
512
570
  }
513
571
 
@@ -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
+ }