deel-local-cli 1.16.0 → 1.17.1
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 +54 -9
- package/README.md +54 -8
- package/bin/deel.js +385 -5
- package/package.json +1 -1
- package/src/acp/serve.js +39 -1
- package/src/agent/agents.js +203 -0
- package/src/agent/loop.js +209 -17
- package/src/agent/models.js +4 -0
- package/src/agent/outschema.js +351 -0
- package/src/agent/session.js +13 -2
- package/src/backend/adapter.js +38 -2
- package/src/backend/clientcert.js +164 -0
- package/src/backend/http.js +232 -26
- package/src/backend/mcp.js +194 -30
- package/src/backend/wire.js +10 -0
- package/src/cmdnames.js +2 -0
- package/src/commands.js +127 -2
- package/src/completion.js +34 -4
- package/src/config.js +86 -9
- package/src/configexplain.js +177 -0
- package/src/doctor.js +195 -0
- package/src/i18n/en.js +37 -0
- package/src/i18n/ja.js +37 -0
- package/src/i18n/ko.js +37 -0
- package/src/i18n/zh.js +37 -0
- package/src/lsp/client.js +2 -2
- package/src/oneshot.js +181 -4
- package/src/pack/sbom.js +77 -3
- package/src/pack/selfpack.js +1 -1
- package/src/pack/sheet.en.js +1 -1
- package/src/repl.js +74 -3
- package/src/safety/hooks.js +389 -0
- package/src/safety/policy.js +74 -0
- package/src/safety/shellenv.js +116 -0
- package/src/safety/trust.js +230 -0
- package/src/setup.js +9 -0
- package/src/stats.js +162 -0
- package/src/tools/docs.js +12 -2
- package/src/tools/hwpxwrite.js +268 -0
- package/src/tools/index.js +128 -9
- package/src/tools/jobs.js +11 -6
- package/src/tools/spawn.js +19 -1
- package/src/tools/task.js +8 -2
package/src/backend/wire.js
CHANGED
|
@@ -34,6 +34,7 @@
|
|
|
34
34
|
// 캐시가 매번 새로 엮인다. 배워서 값이 바뀌는 것은 예외다 — 그건 안 그러면
|
|
35
35
|
// 그 턴이 죽는 자리라, 한 번 흔들리는 값을 치른다.
|
|
36
36
|
import { 벤더 } from './toolfit.js';
|
|
37
|
+
import { 인증서등록 } from './clientcert.js';
|
|
37
38
|
import { 말 } from '../i18n/index.js';
|
|
38
39
|
|
|
39
40
|
/** 우리 눈금. agent/effort.js 의 LEVELS 와 같은 차례다 (off 는 따로 다룬다). */
|
|
@@ -608,6 +609,15 @@ export function 카드합치기(기본, 남긴것) {
|
|
|
608
609
|
*/
|
|
609
610
|
export function 전선붙이기(conn, 배움 = null) {
|
|
610
611
|
if (!conn) return conn;
|
|
612
|
+
/*
|
|
613
|
+
* 이 게이트웨이가 우리 인증서를 요구하면 여기서 매어 둔다 (backend/clientcert.js).
|
|
614
|
+
*
|
|
615
|
+
* 문이 넷이라 각 문에서 따로 부르면 언젠가 한 곳이 빠진다 — 그런 일이
|
|
616
|
+
* 실제로 두 번 있었다(vision·열쇠받기). 네 문이 다 지나는 자리는 여기다.
|
|
617
|
+
* `/model` 로 프로필을 갈아타도 여기를 다시 지나므로, 옛 프로필의 인증서가
|
|
618
|
+
* 새 창구로 따라가지 않는다.
|
|
619
|
+
*/
|
|
620
|
+
인증서등록(conn.base, conn.인증서 ?? null);
|
|
611
621
|
const 기본 = 기본카드(conn);
|
|
612
622
|
let 남긴것 = null;
|
|
613
623
|
try { 남긴것 = 배움?.아는전선?.(conn.model, conn.base, conn.kind) ?? null; } catch { 남긴것 = null; }
|
package/src/cmdnames.js
CHANGED
package/src/commands.js
CHANGED
|
@@ -30,6 +30,8 @@ import { 말, 말 as 옮긴말, 언어, 언어들, 언어정하기, 언어고르
|
|
|
30
30
|
import { 프로필찾기, 쓸수있나, 연결만들기, 알릴말, 목록보기 } from './agent/models.js';
|
|
31
31
|
import { allowTemporarily } from './safety/network.js';
|
|
32
32
|
import { chat, 규격이름, 더할머리 } from './backend/adapter.js';
|
|
33
|
+
import { 잠잠기본 } from './backend/http.js';
|
|
34
|
+
import { 인증서설정, 인증서말 } from './backend/clientcert.js';
|
|
33
35
|
import { 알림채움 } from './backend/retry.js';
|
|
34
36
|
import { 프록시고르기, 프록시설정 } from './backend/proxy.js';
|
|
35
37
|
import { 정한셸 } from './tools/shell.js';
|
|
@@ -1036,6 +1038,16 @@ export async function handle(line, session, ctx) {
|
|
|
1036
1038
|
} else if (k.auth !== 'none') {
|
|
1037
1039
|
say(` ${c.gray(pad(말('status.keyStore'), 10))} ${열쇠보관(load())}`);
|
|
1038
1040
|
}
|
|
1041
|
+
/*
|
|
1042
|
+
* 우리 인증서로 붙고 있으면 그렇다고 적는다.
|
|
1043
|
+
*
|
|
1044
|
+
* 인증서를 둘 이상 가진 사람은 어느 것이 먹었는지 알 길이 없다 — 붙으면
|
|
1045
|
+
* 조용하고 안 붙으면 TLS 악수 실패 한 줄만 온다. **암호는 절대 안 적는다**
|
|
1046
|
+
* (backend/clientcert.js 의 인증서말).
|
|
1047
|
+
*/
|
|
1048
|
+
if (session.conn.인증서) {
|
|
1049
|
+
say(` ${c.gray(pad(말('status.clientCert'), 10))} ${c.gray(인증서말(session.conn.인증서))}`);
|
|
1050
|
+
}
|
|
1039
1051
|
say(` ${c.gray(pad(말('status.rules'), 10))} ${session.rules ? session.rules.name : 말('status.noRules')}`);
|
|
1040
1052
|
// 파일이 있는데 못 읽은 것을 '없음' 으로 적으면, 규칙을 적어 둔 사람은
|
|
1041
1053
|
// 걸려 있다고 믿는다. 안 걸린 채로 도는 것이 여기서 제일 나쁜 모양이다.
|
|
@@ -1269,12 +1281,30 @@ export async function handle(line, session, ctx) {
|
|
|
1269
1281
|
}
|
|
1270
1282
|
|
|
1271
1283
|
for (const s of 붙은것) {
|
|
1272
|
-
|
|
1284
|
+
/*
|
|
1285
|
+
* 세 가지 상태를 **갈라서** 적는다 (backend/mcp.js 의 지연 로딩).
|
|
1286
|
+
*
|
|
1287
|
+
* ● 떠 있다 지금 프로세스가 돌고 있다
|
|
1288
|
+
* ◐ 대기 적어 둔 목록으로 서 있다 — 그 도구를 부르면 뜬다
|
|
1289
|
+
* ○ 죽었다 띄웠는데 안 됐거나 도중에 죽었다
|
|
1290
|
+
*
|
|
1291
|
+
* 대기를 죽음으로 뭉개면 사람은 없는 탈을 고치러 간다. 반대로 대기를
|
|
1292
|
+
* 초록으로 적으면 「띄워 봤다」 는 말이 거짓이 된다.
|
|
1293
|
+
*/
|
|
1294
|
+
const 상태 = s.살아있나() ? c.green('●') : (s.대기 ? c.yellow('◐') : c.red('○'));
|
|
1273
1295
|
const 이름들 = s.도구.map((t) => t.name);
|
|
1274
1296
|
say(` ${상태} ${c.bold(s.이름)} ${c.gray(s.정보?.name ? `${s.정보.name} ${s.정보.version ?? ''}` : s.설정.command)}`);
|
|
1275
1297
|
say(` ${c.gray('도구 ' + s.도구.length + '개')} ${c.gray(clip(이름들.join(' · '), 60))}`);
|
|
1298
|
+
if (s.대기) say(` ${c.gray('아직 안 띄웠습니다 — 적어 둔 목록입니다. 이 도구를 부르면 그때 뜹니다.')}`);
|
|
1276
1299
|
if (s.잘림) say(` ${mark.warn} ${c.gray(`${s.잘림}개는 뺐습니다 — 한 서버에 ${도구최대}개까지만 받습니다(컨텍스트가 줄어듭니다)`)}`);
|
|
1277
|
-
if (
|
|
1300
|
+
if (s.달라짐) {
|
|
1301
|
+
const 것 = [
|
|
1302
|
+
s.달라짐.늘어난것?.length ? `늘어남 ${s.달라짐.늘어난것.join(' ')}` : null,
|
|
1303
|
+
s.달라짐.없어진것?.length ? `없어짐 ${s.달라짐.없어진것.join(' ')}` : null,
|
|
1304
|
+
].filter(Boolean).join(' · ');
|
|
1305
|
+
say(` ${mark.warn} ${c.gray(`띄워 보니 목록이 달랐습니다 — ${것}`)}`);
|
|
1306
|
+
}
|
|
1307
|
+
if (!s.살아있나() && !s.대기) say(` ${c.red(s.죽음 ?? '죽었습니다')}`);
|
|
1278
1308
|
}
|
|
1279
1309
|
say('');
|
|
1280
1310
|
say(` ${c.gray('모델에게는')} ${c.white('mcp__<서버>__<도구>')} ${c.gray('라는 이름으로 보입니다.')}`);
|
|
@@ -1288,6 +1318,97 @@ export async function handle(line, session, ctx) {
|
|
|
1288
1318
|
return { handled: true };
|
|
1289
1319
|
}
|
|
1290
1320
|
|
|
1321
|
+
/*
|
|
1322
|
+
* 적어 둔 훅을 보여 준다 (safety/hooks.js).
|
|
1323
|
+
*
|
|
1324
|
+
* /mcp 와 같은 자리다. 남의 프로그램을 돌리는 것을 켜 놓았으면, 무엇이
|
|
1325
|
+
* 켜져 있는지 볼 화면이 있어야 한다. 없으면 사람은 제가 무엇을 켜 뒀는지
|
|
1326
|
+
* 모른 채로 쓰게 되고, 그건 켠 적 없는 것과 똑같이 위험하다.
|
|
1327
|
+
*
|
|
1328
|
+
* **이번 판이 실제로 들고 있는 것**을 보여 준다. 파일을 다시 읽지 않는다 —
|
|
1329
|
+
* 파일은 대화 도중에 바뀔 수 있고, 그러면 이 화면과 실제로 도는 것이
|
|
1330
|
+
* 달라진다. 화면이 거짓말을 하느니 옛것을 보여 주는 편이 낫다.
|
|
1331
|
+
*/
|
|
1332
|
+
case 'hooks':
|
|
1333
|
+
case '훅': {
|
|
1334
|
+
const { 자리들: 훅자리들, 프로젝트자리: 훅프로젝트자리, 이PC자리: 훅이PC자리 } = await import('./safety/hooks.js');
|
|
1335
|
+
const 것들 = ctx?.훅들 ?? [];
|
|
1336
|
+
rule('적어 둔 훅', 70);
|
|
1337
|
+
|
|
1338
|
+
if (!것들.length) {
|
|
1339
|
+
say(` ${c.gray('걸린 훅이 없습니다.')}`);
|
|
1340
|
+
say('');
|
|
1341
|
+
say(` ${c.gray('적는 자리 —')} ${c.white(훅프로젝트자리(session.root))} ${c.gray('(이 프로젝트)')}`);
|
|
1342
|
+
say(` ${c.gray(' ')} ${c.white(훅이PC자리())} ${c.gray('(이 PC 전체)')}`);
|
|
1343
|
+
say(` ${c.gray('{ "hooks": [ { "때": "도구전", "도구": "Bash", "명령": "python .deel/gate.py" } ] }')}`);
|
|
1344
|
+
say('');
|
|
1345
|
+
say(` ${c.gray('걸 수 있는 자리 —')} ${c.white(훅자리들.join(' · '))}`);
|
|
1346
|
+
say(` ${c.yellow('※')} ${c.gray('프로젝트 파일은 믿는 폴더에서만 읽습니다 (deel trust).')}`);
|
|
1347
|
+
say('');
|
|
1348
|
+
return { handled: true };
|
|
1349
|
+
}
|
|
1350
|
+
|
|
1351
|
+
for (const h of 것들) {
|
|
1352
|
+
const 막나 = h.자리 === '도구전' || h.자리 === '말전';
|
|
1353
|
+
say(` ${c.bold(h.자리)} ${c.gray(h.무늬글 === '*' ? '(모든 도구)' : h.무늬글)} ${c.gray(`· ${h.출처}`)}`);
|
|
1354
|
+
say(` ${c.white(clip(h.명령, 62))}`);
|
|
1355
|
+
say(` ${c.gray(`${Math.round(h.제한 / 1000)}초 안에 · `)}${막나
|
|
1356
|
+
? c.gray(h.지나갈까 ? '고장나면 지나감 (적어 두셨습니다)' : '고장나면 막음')
|
|
1357
|
+
: c.gray('여기서는 못 막습니다 — 말만 전합니다')}`);
|
|
1358
|
+
}
|
|
1359
|
+
say('');
|
|
1360
|
+
say(` ${c.yellow('※')} ${c.gray('훅은 남의 프로그램을 돌립니다 —')} ${c.white(`작업 범위(${session.root}) 를 안 지킵니다.`)}`);
|
|
1361
|
+
say(` ${c.gray(' 무엇이 돌았고 무엇을 막았는지는')} ${c.white('.deel/audit.jsonl')} ${c.gray('에 남습니다.')}`);
|
|
1362
|
+
say(` ${c.gray(' 이번 판만 끄려면')} ${c.white('deel --no-hooks')} ${c.gray('· 아예 끄려면')} ${c.white('DEEL_HOOKS=off')}`);
|
|
1363
|
+
say('');
|
|
1364
|
+
return { handled: true };
|
|
1365
|
+
}
|
|
1366
|
+
|
|
1367
|
+
/*
|
|
1368
|
+
* 이름 붙인 하위 작업을 보여 준다 (agent/agents.js).
|
|
1369
|
+
*
|
|
1370
|
+
* 모델이 보는 것은 이름과 한 줄 설명뿐이다. 사람은 그 아래 — 어느 모드로,
|
|
1371
|
+
* 어느 모델에게, 어떤 도구만 쥐여 주는지 — 를 볼 수 있어야 한다. 그게
|
|
1372
|
+
* 안 보이면 「리뷰어를 시켰는데 왜 파일을 고쳤지」 를 알아낼 길이 없다.
|
|
1373
|
+
*/
|
|
1374
|
+
case 'agents':
|
|
1375
|
+
case '에이전트': {
|
|
1376
|
+
const { 자리들: 에자리들 } = await import('./agent/agents.js');
|
|
1377
|
+
const 것들 = ctx?.에이전트들 ?? [];
|
|
1378
|
+
rule('이름 붙인 하위 작업', 70);
|
|
1379
|
+
|
|
1380
|
+
if (!것들.length) {
|
|
1381
|
+
say(` ${c.gray('정의해 둔 것이 없습니다.')}`);
|
|
1382
|
+
say('');
|
|
1383
|
+
say(` ${c.gray('적는 자리 —')} ${c.white(에자리들(session.root)[0])} ${c.gray('(이 프로젝트)')}`);
|
|
1384
|
+
say(` ${c.gray('파일 하나가 이름 하나입니다.')} ${c.white('리뷰어.json')}`);
|
|
1385
|
+
say(` ${c.gray('{ "설명": "고친 코드를 훑고 위험한 데만 짚는다",')}`);
|
|
1386
|
+
say(` ${c.gray(' "모드": "inspect", "도구": ["Read", "Grep", "Glob"] }')}`);
|
|
1387
|
+
say('');
|
|
1388
|
+
say(` ${c.gray('부를 때는')} ${c.white('Task({ agent: "리뷰어", … })')} ${c.gray('— 모델이 이름으로 고릅니다.')}`);
|
|
1389
|
+
say('');
|
|
1390
|
+
return { handled: true };
|
|
1391
|
+
}
|
|
1392
|
+
|
|
1393
|
+
for (const a of 것들) {
|
|
1394
|
+
say(` ${c.bold(a.이름)} ${c.gray(`· ${a.출처}`)}`);
|
|
1395
|
+
say(` ${c.white(clip(a.설명, 62))}`);
|
|
1396
|
+
const 곁들 = [
|
|
1397
|
+
a.모드 ? `모드 ${a.모드}` : null,
|
|
1398
|
+
a.모델 ? `모델 ${a.모델}` : null,
|
|
1399
|
+
a.걸음 ? `${a.걸음}걸음` : null,
|
|
1400
|
+
a.도구?.length ? `도구 ${a.도구.join(' ')}` : null,
|
|
1401
|
+
a.지침 ? `지침 ${a.지침.length}자` : null,
|
|
1402
|
+
].filter(Boolean);
|
|
1403
|
+
if (곁들.length) say(` ${c.gray(곁들.join(' · '))}`);
|
|
1404
|
+
}
|
|
1405
|
+
say('');
|
|
1406
|
+
say(` ${c.gray('모델에게는')} ${c.white('Task({ agent: "<이름>", … })')} ${c.gray('로 보입니다 — 이름과 설명만 실립니다.')}`);
|
|
1407
|
+
say(` ${c.gray('도구는')} ${c.white('줄이기만')} ${c.gray('합니다. 지금 모드가 안 주는 것을 적어 두면 그것만 빠집니다.')}`);
|
|
1408
|
+
say('');
|
|
1409
|
+
return { handled: true };
|
|
1410
|
+
}
|
|
1411
|
+
|
|
1291
1412
|
case 'thread':
|
|
1292
1413
|
case '갈래': return 갈래명령(session, ctx, arg), { handled: true };
|
|
1293
1414
|
|
|
@@ -2794,6 +2915,10 @@ function 연결적용(session, p, ctx = null) {
|
|
|
2794
2915
|
kind: p.kind, base: p.baseUrl, auth: p.auth, key: resolveKey(p), model: p.model,
|
|
2795
2916
|
ctx: p.ctx, maxTokens: p.maxTokens ?? null,
|
|
2796
2917
|
streaming: p.streaming, tools: p.tools, json: p.json, think: p.think,
|
|
2918
|
+
// 프로필마다 다를 수 있다 — 사내 게이트웨이는 잠잠 상한이 크고 로컬은 작다.
|
|
2919
|
+
잠잠: p.잠잠 ?? p.streamIdleMs ?? 잠잠기본,
|
|
2920
|
+
// 프로필마다 인증서가 다르다. 안 갈아 끼우면 옛 프로필의 신원으로 붙는다.
|
|
2921
|
+
인증서: 인증서설정(p),
|
|
2797
2922
|
/*
|
|
2798
2923
|
* ── 이 두 줄이 없어서 회사 토큰이 남의 창구로 나갔다 ────────────────
|
|
2799
2924
|
*
|
package/src/completion.js
CHANGED
|
@@ -41,6 +41,10 @@ export const 명령들 = [
|
|
|
41
41
|
{ 이름: 'audit', 뜻: '무엇을 했는지 기록을 본다', en: 'show the record of what it did' },
|
|
42
42
|
{ 이름: 'doc2md', 뜻: '문서·시안을 마크다운으로 (hwpx·docx·pptx·xlsx·pdf·fig)', en: 'document or design to Markdown (hwpx/docx/pptx/xlsx/pdf/fig)' },
|
|
43
43
|
{ 이름: 'sbom', 뜻: '무엇이 들었는지 목록을 낸다', en: 'list what is inside (SBOM)' },
|
|
44
|
+
{ 이름: 'trust', 뜻: '이 폴더의 프로젝트 설정을 읽게 한다', en: "read this folder's project config" },
|
|
45
|
+
{ 이름: 'stats', 뜻: '이 폴더에서 무엇을 했나 (감사기록 요약)', en: 'what happened in this folder (audit summary)' },
|
|
46
|
+
{ 이름: 'config', 뜻: '설정 값이 어디서 왔는지 (config explain <칸>)', en: 'where a config value comes from (config explain <key>)' },
|
|
47
|
+
{ 이름: 'rules', 뜻: '적어 둔 승인 규칙이 진짜 그렇게 도나 확인한다', en: 'check that the approval rules actually do what they say' },
|
|
44
48
|
{ 이름: 'scan', 뜻: '게이트웨이에 어떤 모델이 있나 훑는다', en: 'scan the gateway for available models' },
|
|
45
49
|
{ 이름: 'sessions', 뜻: '지난 대화 목록', en: 'list past conversations' },
|
|
46
50
|
{ 이름: 'ls', 뜻: 'sessions 와 같다', en: 'same as sessions' },
|
|
@@ -85,9 +89,15 @@ export const 깃발들 = [
|
|
|
85
89
|
{ 이름: '--yes', 뜻: '물어보지 않고 진행', en: 'do not ask, just proceed', 값: false },
|
|
86
90
|
{ 이름: '--hard', 뜻: 'reset all 에서 되돌리기·감사기록까지', en: 'with reset all: undo snapshots and the audit log too', 값: false },
|
|
87
91
|
{ 이름: '--json', 뜻: '결과를 JSON 으로', en: 'output JSON', 값: false },
|
|
92
|
+
{ 이름: '--days', 뜻: 'stats 에서 볼 기간(일)', en: 'stats: how many days back', 값: null },
|
|
93
|
+
{ 이름: '--all', 뜻: 'stats 에서 기간을 안 자름', en: 'stats: no time window', 값: false },
|
|
94
|
+
// 값이 파일이라 셸이 파일을 완성해 줘야 한다. 목록으로 못 준다 —
|
|
95
|
+
// 스키마 파일 이름은 사람이 짓는 것이라 우리가 알 수가 없다.
|
|
96
|
+
{ 이름: '--output-schema', 뜻: '답을 이 JSON Schema 모양으로', en: 'answer must match this JSON Schema', 값: '파일' },
|
|
88
97
|
{ 이름: '--quiet', 뜻: '군말 없이', en: 'no chatter', 값: false },
|
|
89
98
|
{ 이름: '--tui', 뜻: '입력 상자를 켠다', en: 'force the input box on', 값: false },
|
|
90
99
|
{ 이름: '--no-tui', 뜻: '입력 상자 없이 줄 화면으로', en: 'plain line output, no input box', 값: false },
|
|
100
|
+
{ 이름: '--no-hooks', 뜻: '이번 판만 훅을 끄고 돌린다', en: 'run this once with hooks off', 값: false },
|
|
91
101
|
{ 이름: '--continue', 뜻: '지난 대화 이어서', en: 'continue the last conversation', 값: null },
|
|
92
102
|
{ 이름: '--resume', 뜻: '고른 대화 이어서', en: 'resume a chosen conversation', 값: null },
|
|
93
103
|
{ 이름: '--version', 뜻: '판 번호', en: 'version number', 값: false },
|
|
@@ -102,13 +112,32 @@ const 깃발이름만 = () => 깃발들.map((x) => x.이름);
|
|
|
102
112
|
/** 셸 문자열 안에 그대로 넣어도 되게. 우리 목록은 다 ASCII 라 넉넉하다. */
|
|
103
113
|
const 홑따옴표 = (s) => `'${String(s).replace(/'/g, `'\\''`)}'`;
|
|
104
114
|
|
|
115
|
+
/*
|
|
116
|
+
* 파워셸의 홑따옴표는 **셈법이 다르다.**
|
|
117
|
+
*
|
|
118
|
+
* bash 는 따옴표를 닫고 이스케이프한 따옴표를 붙였다 다시 여는 식이지만
|
|
119
|
+
* (`'\''`), 파워셸에서 그 글자열은 그냥 역슬래시와 따옴표다. 파워셸은
|
|
120
|
+
* 홑따옴표를 **두 번 적어서** 하나로 친다.
|
|
121
|
+
*
|
|
122
|
+
* 여태 안 터진 것은 설명 글에 아포스트로피가 하나도 없었기 때문이다.
|
|
123
|
+
* `read this folder's project config` 한 줄을 넣자마자 스크립트가 통째로
|
|
124
|
+
* 문법 오류가 났다 — 저장한 사람은 셸을 켤 때마다 빨간 글을 보고, 그게
|
|
125
|
+
* deel 때문인 줄도 모른다. 아포스트로피를 피해 글을 쓰는 것으로 때우면
|
|
126
|
+
* 다음에 적는 사람이 같은 데 빠진다. 따옴표는 따옴표 짓는 자리에서 짓는다.
|
|
127
|
+
*/
|
|
128
|
+
const 파워셸따옴표 = (s) => `'${String(s).replace(/'/g, "''")}'`;
|
|
129
|
+
|
|
105
130
|
function bash판({ zsh = false } = {}) {
|
|
106
131
|
const 값깃발 = 깃발들.filter((x) => Array.isArray(x.값));
|
|
107
132
|
const 폴더깃발 = 깃발들.filter((x) => x.값 === '폴더').map((x) => x.이름);
|
|
133
|
+
const 파일깃발 = 깃발들.filter((x) => x.값 === '파일').map((x) => x.이름);
|
|
108
134
|
const 값갈래 = 값깃발.map((x) => ` ${x.이름}) COMPREPLY=( $(compgen -W ${홑따옴표(x.값.join(' '))} -- "$cur") ); return 0 ;;`).join('\n');
|
|
109
135
|
const 폴더갈래 = 폴더깃발.length
|
|
110
136
|
? ` ${폴더깃발.join('|')}) COMPREPLY=( $(compgen -d -- "$cur") ); return 0 ;;`
|
|
111
137
|
: '';
|
|
138
|
+
const 파일갈래 = 파일깃발.length
|
|
139
|
+
? ` ${파일깃발.join('|')}) COMPREPLY=( $(compgen -f -- "$cur") ); return 0 ;;`
|
|
140
|
+
: '';
|
|
112
141
|
|
|
113
142
|
return `# deel 탭 완성 (${zsh ? 'zsh' : 'bash'})
|
|
114
143
|
#
|
|
@@ -125,6 +154,7 @@ _deel() {
|
|
|
125
154
|
case "$prev" in
|
|
126
155
|
${값갈래}
|
|
127
156
|
${폴더갈래}
|
|
157
|
+
${파일갈래}
|
|
128
158
|
completion) COMPREPLY=( $(compgen -W ${홑따옴표(셸들.join(' '))} -- "$cur") ); return 0 ;;
|
|
129
159
|
esac
|
|
130
160
|
|
|
@@ -179,11 +209,11 @@ complete -F _deel deel
|
|
|
179
209
|
* (bash·zsh 완성은 설명을 아예 안 보여 주므로 잃는 것이 없다.)
|
|
180
210
|
*/
|
|
181
211
|
function 파워셸판() {
|
|
182
|
-
const 명령목록 = 명령들.map((x) => ` @{ name = ${
|
|
183
|
-
const 깃발목록 = 깃발들.map((x) => ` @{ name = ${
|
|
212
|
+
const 명령목록 = 명령들.map((x) => ` @{ name = ${파워셸따옴표(x.이름)}; help = ${파워셸따옴표(x.en)} }`).join('\n');
|
|
213
|
+
const 깃발목록 = 깃발들.map((x) => ` @{ name = ${파워셸따옴표(x.이름)}; help = ${파워셸따옴표(x.en)} }`).join('\n');
|
|
184
214
|
const 값목록 = 깃발들
|
|
185
215
|
.filter((x) => Array.isArray(x.값))
|
|
186
|
-
.map((x) => ` ${
|
|
216
|
+
.map((x) => ` ${파워셸따옴표(x.이름)} = @(${x.값.map(파워셸따옴표).join(', ')})`)
|
|
187
217
|
.join('\n');
|
|
188
218
|
|
|
189
219
|
// 파워셸은 완성 후보에 설명을 같이 실을 수 있다. 탭을 누르면 뜻이 같이 뜬다.
|
|
@@ -210,7 +240,7 @@ ${깃발목록}
|
|
|
210
240
|
)
|
|
211
241
|
$values = @{
|
|
212
242
|
${값목록}
|
|
213
|
-
'completion' = @(${셸들.map(
|
|
243
|
+
'completion' = @(${셸들.map(파워셸따옴표).join(', ')})
|
|
214
244
|
}
|
|
215
245
|
|
|
216
246
|
# Which word comes before the one being typed?
|
package/src/config.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
// 연결 프로필 저장/읽기. ~/.deel/config.json
|
|
1
|
+
// 연결 프로필 저장/읽기. ~/.deel/config.json 위에 프로젝트의 .deel/config.json 을 겹친다
|
|
2
|
+
// (믿는 폴더일 때만 — safety/trust.js).
|
|
2
3
|
import { homedir } from 'node:os';
|
|
3
4
|
import { join, resolve } from 'node:path';
|
|
4
5
|
import { readFileSync, writeFileSync, mkdirSync, existsSync, chmodSync } from 'node:fs';
|
|
@@ -7,6 +8,7 @@ import { 셸정하기 } from './tools/shell.js';
|
|
|
7
8
|
import { 애저정하기 } from './backend/azure.js';
|
|
8
9
|
import { 정책읽기 } from './safety/policy.js';
|
|
9
10
|
import { 잠그기, 풀기, 잠긴것인가, 쓸수있나, 보관방식 } from './safety/keystore.js';
|
|
11
|
+
import { 믿나, 프로젝트거르기 } from './safety/trust.js';
|
|
10
12
|
|
|
11
13
|
// 설정이 놓이는 자리.
|
|
12
14
|
//
|
|
@@ -33,9 +35,17 @@ function projectDir() {
|
|
|
33
35
|
*/
|
|
34
36
|
export function homeDir() { return userDir(); }
|
|
35
37
|
|
|
38
|
+
/**
|
|
39
|
+
* 지금 실제로 쓰는 설정 파일.
|
|
40
|
+
*
|
|
41
|
+
* 프로젝트 설정이 있어도 **믿는 폴더일 때만** 그쪽을 준다 (safety/trust.js).
|
|
42
|
+
* 안 읽는 파일에 쓰지도 않아야 한다 — 안 믿어서 무시한 파일에 `deel setup`
|
|
43
|
+
* 이 값을 적으면, 다음에 켤 때 그 값이 또 무시된다. 사람은 두 번 적고 두 번
|
|
44
|
+
* 다 안 먹는 것을 보는데, 그때 무엇 때문인지 알 길이 없다.
|
|
45
|
+
*/
|
|
36
46
|
export function configPath() {
|
|
37
47
|
const local = join(projectDir(), 'config.json');
|
|
38
|
-
if (existsSync(local)) return local;
|
|
48
|
+
if (existsSync(local) && 믿나(process.cwd())) return local;
|
|
39
49
|
return join(userDir(), 'config.json');
|
|
40
50
|
}
|
|
41
51
|
|
|
@@ -133,15 +143,82 @@ function 정책덮기(cfg) {
|
|
|
133
143
|
return cfg;
|
|
134
144
|
}
|
|
135
145
|
|
|
146
|
+
function 한장읽기(p) {
|
|
147
|
+
try { return JSON.parse(readFileSync(p, 'utf8')); }
|
|
148
|
+
catch (err) { throw new Error(`설정 파일을 읽지 못했습니다: ${p}\n ${err.message}`); }
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/*
|
|
152
|
+
* ── 두 장을 겹친다 ─────────────────────────────────────────────────────
|
|
153
|
+
*
|
|
154
|
+
* 예전에는 프로젝트 설정이 있으면 이 PC 설정을 **통째로 안 읽었다.** 그래서
|
|
155
|
+
* 저장소에 `.deel/config.json` 한 줄만 들어 있어도 그 폴더에서는 프로필이
|
|
156
|
+
* 하나도 없는 것이 됐다 — 「이 저장소는 code 모드로 연다」 를 적으려던 사람이
|
|
157
|
+
* 제 게이트웨이를 통째로 잃는다. 그러면 사람은 프로젝트 설정을 안 쓴다.
|
|
158
|
+
*
|
|
159
|
+
* 이제 겹친다. 규칙은 하나다 — **좁히는 쪽만 이긴다.** 금지(deny)는 두 장을
|
|
160
|
+
* 합치고, 허락(allow)은 애초에 프로젝트에서 안 읽는다(safety/trust.js).
|
|
161
|
+
*/
|
|
162
|
+
function 겹치기(집, 방) {
|
|
163
|
+
const cfg = { ...집, ...방 };
|
|
164
|
+
|
|
165
|
+
// 프로필은 이름으로 맞춘다. 통째로 갈아치우면 이 PC 프로필이 사라진다.
|
|
166
|
+
const 이름표 = new Map((집.profiles ?? []).map((x) => [x?.name, x]));
|
|
167
|
+
for (const p of 방.profiles ?? []) {
|
|
168
|
+
if (!p?.name) continue;
|
|
169
|
+
이름표.set(p.name, { ...(이름표.get(p.name) ?? {}), ...p });
|
|
170
|
+
}
|
|
171
|
+
cfg.profiles = [...이름표.values()];
|
|
172
|
+
|
|
173
|
+
// 금지는 합친다. 어느 쪽이 적었든 금지는 금지다.
|
|
174
|
+
const 금지 = [...(집.permissions?.deny ?? []), ...(방.permissions?.deny ?? [])];
|
|
175
|
+
cfg.permissions = { ...(집.permissions ?? {}), ...(방.permissions ?? {}) };
|
|
176
|
+
if (금지.length) cfg.permissions.deny = [...new Set(금지)];
|
|
177
|
+
// 허락은 이 PC 것만. 프로젝트가 적었어도 위에서 이미 걸러졌지만, 겹치는
|
|
178
|
+
// 자리에서 한 번 더 못 박는다 — 이 한 줄이 없으면 나중에 누가 프로젝트에
|
|
179
|
+
// allow 를 허용하도록 고쳤을 때 아무 데서도 안 걸린다.
|
|
180
|
+
if (Array.isArray(집.permissions?.allow)) cfg.permissions.allow = 집.permissions.allow;
|
|
181
|
+
else delete cfg.permissions.allow;
|
|
182
|
+
return cfg;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
let 신뢰소식 = null; // 프로젝트 설정에 대해 할 말. 한 번 읽으면 지워진다.
|
|
186
|
+
|
|
187
|
+
/**
|
|
188
|
+
* 프로젝트 설정을 안 읽었거나 일부를 걷어냈으면 그것을 알려 준다.
|
|
189
|
+
*
|
|
190
|
+
* 조용히 무시하면 안 된다 — 적어 둔 사람은 걸린 줄 알고, 실제로는 안 걸린
|
|
191
|
+
* 채로 일이 돈다. 그 어긋남이 설정 전체를 못 믿게 만든다.
|
|
192
|
+
*
|
|
193
|
+
* @returns {{갈래:'안믿음', 자리:string, 폴더:string}
|
|
194
|
+
* |{갈래:'걸러냄', 자리:string, 걸러낸것:Array<{칸:string,왜:string}>}
|
|
195
|
+
* |null}
|
|
196
|
+
*/
|
|
197
|
+
export function 프로젝트설정소식() { const s = 신뢰소식; 신뢰소식 = null; return s; }
|
|
198
|
+
|
|
136
199
|
function 읽기() {
|
|
137
|
-
const
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
200
|
+
const 집파일 = join(userDir(), 'config.json');
|
|
201
|
+
const 방파일 = join(projectDir(), 'config.json');
|
|
202
|
+
const 집 = existsSync(집파일)
|
|
203
|
+
? { ...structuredClone(EMPTY), ...한장읽기(집파일) }
|
|
204
|
+
: structuredClone(EMPTY);
|
|
205
|
+
if (!existsSync(방파일)) return 집;
|
|
206
|
+
|
|
207
|
+
/*
|
|
208
|
+
* 안 믿는 폴더의 설정은 **안 읽는다.**
|
|
209
|
+
*
|
|
210
|
+
* 이 파일은 저장소에 딸려 온다. 남의 저장소 하나를 받은 것만으로 오간 말이
|
|
211
|
+
* 다른 주소로 가고, 열쇠받기 명령이 이 계정 권한으로 도는 길이 여기다.
|
|
212
|
+
* 그 명령은 도구 승인 화면보다 **앞**이라 어떤 승인 정책으로도 안 걸린다.
|
|
213
|
+
*/
|
|
214
|
+
if (!믿나(process.cwd())) {
|
|
215
|
+
신뢰소식 = { 갈래: '안믿음', 자리: 방파일, 폴더: process.cwd() };
|
|
216
|
+
return 집;
|
|
144
217
|
}
|
|
218
|
+
|
|
219
|
+
const { 값: 방, 걸러낸것 } = 프로젝트거르기(한장읽기(방파일));
|
|
220
|
+
if (걸러낸것.length) 신뢰소식 = { 갈래: '걸러냄', 자리: 방파일, 걸러낸것 };
|
|
221
|
+
return 겹치기(집, 방);
|
|
145
222
|
}
|
|
146
223
|
|
|
147
224
|
export function save(cfg, { toProject = false } = {}) {
|
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
// `deel config explain <칸>` — 이 값은 **어디서 왔나**.
|
|
2
|
+
//
|
|
3
|
+
// ── 왜 필요한가 ────────────────────────────────────────────────────────
|
|
4
|
+
//
|
|
5
|
+
// 설정을 읽는 자리가 넷이다 — 관리 정책 · 환경변수 · 프로젝트 설정 · 이 PC
|
|
6
|
+
// 설정. 겹치는 규칙은 문서에 적어 두었지만, 문서를 읽는 것과 **지금 이 자리에서
|
|
7
|
+
// 무엇이 이겼는지 아는 것**은 다른 일이다.
|
|
8
|
+
//
|
|
9
|
+
// 막히는 모양은 늘 같다. 파일에 분명히 적어 놨는데 안 먹는다. 그러면 사람은
|
|
10
|
+
// 그 파일을 고치고, 또 고치고, 결국 설정이라는 것을 안 믿게 된다. 실제로 값을
|
|
11
|
+
// 쥐고 있던 것은 환경변수 하나이거나, 프로젝트 설정을 안 믿는 폴더여서
|
|
12
|
+
// 읽지도 않은 것이다. 둘 다 **말해 주면 5초에 끝나는** 일이다.
|
|
13
|
+
//
|
|
14
|
+
// 그래서 층을 하나씩 열어서 「이 칸을 적어 둔 곳」 을 다 보여 주고, 어느 것이
|
|
15
|
+
// 이겼는지 표시한다. 값이 겹칠 때만 쓸모 있는 화면이 아니라, **아무 데도 안
|
|
16
|
+
// 적혀 있다**는 것을 보여 줄 때도 똑같이 쓸모 있다.
|
|
17
|
+
//
|
|
18
|
+
// ── 비밀은 안 보여 준다 ────────────────────────────────────────────────
|
|
19
|
+
//
|
|
20
|
+
// 열쇠 칸(apiKey)은 값을 안 적고 「적혀 있음」 만 적는다. 이 화면은 그대로
|
|
21
|
+
// 캡처되어 사내 메신저로 간다.
|
|
22
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
23
|
+
import { join } from 'node:path';
|
|
24
|
+
import { homeDir } from './config.js';
|
|
25
|
+
import { 믿나 } from './safety/trust.js';
|
|
26
|
+
import { 정책읽기, 정책자리 } from './safety/policy.js';
|
|
27
|
+
|
|
28
|
+
/** 값을 안 보여 줄 칸. 이름만 봐도 알 수 있는 것만 넣는다. */
|
|
29
|
+
const 가릴칸 = new Set(['apiKey', 'key', 'passphrase', '암호']);
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* 이 칸을 정할 수 있는 환경변수.
|
|
33
|
+
*
|
|
34
|
+
* 손으로 적는다. 자동으로 긁으면 「무엇이 이 칸을 이길 수 있나」 가 코드를
|
|
35
|
+
* 읽어야만 아는 것이 되는데, 그건 이 명령이 없애려는 바로 그 상태다.
|
|
36
|
+
*/
|
|
37
|
+
const 환경변수 = {
|
|
38
|
+
apiKey: ['DEEL_API_KEY', 'DEEL_KEY_<프로필ID>'],
|
|
39
|
+
shell: ['DEEL_SHELL'],
|
|
40
|
+
offline: [],
|
|
41
|
+
proxy: ['HTTPS_PROXY', 'HTTP_PROXY', 'ALL_PROXY', 'NO_PROXY'],
|
|
42
|
+
lang: ['DEEL_LANG'],
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
/*
|
|
46
|
+
* 비밀 칸의 값은 **읽는 자리에서** 바꿔치기한다.
|
|
47
|
+
*
|
|
48
|
+
* 그리는 자리에서만 가리면 `--json` 이 날것을 그대로 낸다. 그 출력은
|
|
49
|
+
* 파이프를 타고 로그로, 사내 티켓으로 간다 — 화면만 가려 놓고 안전하다고
|
|
50
|
+
* 하는 것이 이런 자리에서 제일 흔한 사고다.
|
|
51
|
+
*/
|
|
52
|
+
export const 비밀표시 = '(적혀 있음 — 값은 안 보여 줍니다)';
|
|
53
|
+
const 지운값 = (칸, v) => (v !== undefined && 가릴칸.has(칸) ? 비밀표시 : v);
|
|
54
|
+
|
|
55
|
+
const 값보이기 = (칸, v) => {
|
|
56
|
+
if (v === undefined) return null;
|
|
57
|
+
if (v === null) return 'null';
|
|
58
|
+
if (typeof v === 'object') return JSON.stringify(v);
|
|
59
|
+
return String(v);
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* 한 칸이 어느 층에 적혀 있나.
|
|
64
|
+
*
|
|
65
|
+
* @param {string} 칸 `offline` 처럼 맨 위 칸, 또는 `profiles.사내.model` 처럼
|
|
66
|
+
* 프로필 안의 칸
|
|
67
|
+
* @returns {{칸:string, 층들:Array, 이긴층:object|null, 환경변수:string[]}}
|
|
68
|
+
*/
|
|
69
|
+
export function 설명(칸, { root = process.cwd(), env = process.env } = {}) {
|
|
70
|
+
const 조각 = String(칸 ?? '').split('.').filter(Boolean);
|
|
71
|
+
const 층들 = [];
|
|
72
|
+
|
|
73
|
+
const 파일읽기 = (자리) => {
|
|
74
|
+
if (!existsSync(자리)) return { 있나: false };
|
|
75
|
+
try { return { 있나: true, 값: JSON.parse(readFileSync(자리, 'utf8')) }; }
|
|
76
|
+
catch (err) { return { 있나: true, 탈: err?.message ?? String(err) }; }
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
const 파고들기 = (덩이) => {
|
|
80
|
+
let 여기 = 덩이;
|
|
81
|
+
for (let i = 0; i < 조각.length; i++) {
|
|
82
|
+
if (여기 == null || typeof 여기 !== 'object') return undefined;
|
|
83
|
+
const 이름 = 조각[i];
|
|
84
|
+
/*
|
|
85
|
+
* `profiles.<이름>.<칸>` 은 배열을 이름으로 뒤진다.
|
|
86
|
+
*
|
|
87
|
+
* 사람이 설정에서 프로필을 부르는 이름은 배열 번호가 아니라 name 이다.
|
|
88
|
+
* 번호로 적게 하면 프로필 하나를 지웠을 때 이 명령이 조용히 딴 것을
|
|
89
|
+
* 가리킨다.
|
|
90
|
+
*/
|
|
91
|
+
if (Array.isArray(여기)) {
|
|
92
|
+
여기 = 여기.find((x) => x?.name === 이름 || x?.id === 이름);
|
|
93
|
+
continue;
|
|
94
|
+
}
|
|
95
|
+
여기 = 여기[이름];
|
|
96
|
+
}
|
|
97
|
+
return 여기;
|
|
98
|
+
};
|
|
99
|
+
|
|
100
|
+
const 끝칸이름지금 = 조각[조각.length - 1] ?? '';
|
|
101
|
+
|
|
102
|
+
// ── 1. 이 PC 설정 (제일 아래) ───────────────────────────────────────
|
|
103
|
+
const 집자리 = join(homeDir(), 'config.json');
|
|
104
|
+
const 집 = 파일읽기(집자리);
|
|
105
|
+
if (집.있나) {
|
|
106
|
+
층들.push({
|
|
107
|
+
층: '이 PC 설정', 자리: 집자리, 읽나: !집.탈,
|
|
108
|
+
값: 집.탈 ? undefined : 지운값(끝칸이름지금, 파고들기(집.값)), 탈: 집.탈 ?? null,
|
|
109
|
+
});
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// ── 2. 프로젝트 설정 (믿는 폴더에서만) ──────────────────────────────
|
|
113
|
+
const 방자리 = join(root, '.deel', 'config.json');
|
|
114
|
+
if (existsSync(방자리)) {
|
|
115
|
+
const 믿나결과 = 믿나(root);
|
|
116
|
+
const 방 = 파일읽기(방자리);
|
|
117
|
+
층들.push({
|
|
118
|
+
층: '프로젝트 설정', 자리: 방자리, 읽나: 믿나결과 && !방.탈,
|
|
119
|
+
값: 방.탈 ? undefined : 지운값(끝칸이름지금, 파고들기(방.값)), 탈: 방.탈 ?? null,
|
|
120
|
+
// 읽지도 않는 파일에 적어 둔 것이 이 화면에서 제일 자주 나오는 답이다.
|
|
121
|
+
왜못읽나: 믿나결과 ? null : '믿는 폴더가 아닙니다 (deel trust)',
|
|
122
|
+
});
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// ── 3. 환경변수 ─────────────────────────────────────────────────────
|
|
126
|
+
const 끝칸 = 조각[조각.length - 1] ?? '';
|
|
127
|
+
const 볼환경 = 환경변수[끝칸] ?? [];
|
|
128
|
+
const 켜진환경 = 볼환경.filter((이름) => !이름.includes('<') && env[이름]);
|
|
129
|
+
if (켜진환경.length) {
|
|
130
|
+
층들.push({
|
|
131
|
+
층: '환경변수', 자리: 켜진환경.join(' · '), 읽나: true,
|
|
132
|
+
값: 지운값(끝칸, env[켜진환경[0]]),
|
|
133
|
+
});
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// ── 4. 관리 정책 (제일 위) ──────────────────────────────────────────
|
|
137
|
+
const 정책 = 정책읽기({ env, 다시: true });
|
|
138
|
+
if (정책.값 && typeof 정책.값 === 'object') {
|
|
139
|
+
const 그값 = 정책.값[끝칸];
|
|
140
|
+
if (그값 !== undefined) {
|
|
141
|
+
층들.push({ 층: '관리 정책', 자리: 정책.곳 ?? 정책자리(env)[0], 읽나: true, 값: 지운값(끝칸, 그값) });
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// 이긴 층 — 뒤에 온 것이 이긴다. 다만 **읽는 층** 중에서만 고른다.
|
|
146
|
+
const 쓸수있는것 = 층들.filter((x) => x.읽나 && x.값 !== undefined);
|
|
147
|
+
const 이긴층 = 쓸수있는것.length ? 쓸수있는것[쓸수있는것.length - 1] : null;
|
|
148
|
+
|
|
149
|
+
return { 칸, 층들, 이긴층, 환경변수: 볼환경 };
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/** 화면에 그릴 줄들. 색은 부르는 쪽이 입힌다. */
|
|
153
|
+
export function 설명줄들(r) {
|
|
154
|
+
const 줄 = [];
|
|
155
|
+
if (r.이긴층) 줄.push({ 갈래: '값', 글: `${값보이기(끝칸이름(r.칸), r.이긴층.값)}`, 곁: `${r.이긴층.층}` });
|
|
156
|
+
else 줄.push({ 갈래: '없음', 글: '아무 데도 안 적혀 있습니다 (기본값으로 돕니다)', 곁: null });
|
|
157
|
+
|
|
158
|
+
for (const 층 of r.층들) {
|
|
159
|
+
const 값 = 값보이기(끝칸이름(r.칸), 층.값);
|
|
160
|
+
줄.push({
|
|
161
|
+
갈래: 층 === r.이긴층 ? '이김' : (층.읽나 ? '아래' : '안읽음'),
|
|
162
|
+
글: `${층.층}`,
|
|
163
|
+
곁: [
|
|
164
|
+
층.자리,
|
|
165
|
+
값 !== null ? `= ${값}` : '(이 칸은 없음)',
|
|
166
|
+
층.왜못읽나 ? `— ${층.왜못읽나}` : null,
|
|
167
|
+
층.탈 ? `— 못 읽었습니다: ${층.탈}` : null,
|
|
168
|
+
].filter(Boolean).join(' '),
|
|
169
|
+
});
|
|
170
|
+
}
|
|
171
|
+
if (r.환경변수.length) {
|
|
172
|
+
줄.push({ 갈래: '참고', 글: '이 칸을 이길 수 있는 환경변수', 곁: r.환경변수.join(' · ') });
|
|
173
|
+
}
|
|
174
|
+
return 줄;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
const 끝칸이름 = (칸) => String(칸 ?? '').split('.').filter(Boolean).pop() ?? '';
|