deel-local-cli 1.16.0 → 1.17.2
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 +55 -9
- package/README.md +55 -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/probe.js +27 -1
- package/src/backend/wire.js +10 -0
- package/src/cmdnames.js +2 -0
- package/src/commands.js +139 -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 +302 -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
|
@@ -0,0 +1,351 @@
|
|
|
1
|
+
// 답을 **정해진 모양**으로 받는다 — `deel run --output-schema`.
|
|
2
|
+
//
|
|
3
|
+
// ── 왜 필요한가 ────────────────────────────────────────────────────────
|
|
4
|
+
//
|
|
5
|
+
// `deel run` 은 글을 낸다. 사람이 읽으면 그만이지만, 스크립트에 물리면
|
|
6
|
+
// 거기서부터 일이 시작된다.
|
|
7
|
+
//
|
|
8
|
+
// deel run "이 계약서에서 계약 기간과 해지 조건을 뽑아 줘" spec.hwpx | ???
|
|
9
|
+
//
|
|
10
|
+
// 뒤에 무엇을 붙일 수 있나. 답이 매번 다른 모양으로 오므로 `grep` 이나
|
|
11
|
+
// `sed` 로 뜯어야 하고, 그 뜯는 코드는 모델이 다음번에 문장을 조금 다르게
|
|
12
|
+
// 쓰는 날 조용히 틀린다. **조용히** 가 핵심이다 — 빈 값을 받은 스크립트는
|
|
13
|
+
// 오류를 안 내고 그냥 빈 값으로 다음 단계에 넘긴다.
|
|
14
|
+
//
|
|
15
|
+
// 모양을 미리 못 박으면 이 문제가 통째로 사라진다.
|
|
16
|
+
//
|
|
17
|
+
// deel run --output-schema 계약.schema.json "..." | jq -r .해지조건
|
|
18
|
+
//
|
|
19
|
+
// 그러면 이 프로그램은 **셸 파이프라인에서 부를 수 있는 함수**가 된다.
|
|
20
|
+
// doc2md 와 붙이면 「hwpx 계약서 → 구조화 JSON」 이 되는데, 그건 지금
|
|
21
|
+
// 어느 경쟁 도구도 못 한다.
|
|
22
|
+
//
|
|
23
|
+
// ── 왜 우리가 직접 재나 ────────────────────────────────────────────────
|
|
24
|
+
//
|
|
25
|
+
// 「JSON 으로 답해 줘」 라고 부탁만 하고 안 재는 길도 있다. 그런데 그러면
|
|
26
|
+
// 지키는 날과 안 지키는 날이 생기고, 안 지킨 날에 스크립트가 깨진다 —
|
|
27
|
+
// 그것도 우리가 아니라 **뒤에 붙은 남의 코드**에서 깨진다. 부탁은 계약이
|
|
28
|
+
// 아니다. 계약이라고 부르려면 재야 한다.
|
|
29
|
+
//
|
|
30
|
+
// 그래서 받아 놓고 여기서 맞춰 본다. 안 맞으면 무엇이 어떻게 안 맞는지를
|
|
31
|
+
// 모델에게 그대로 돌려주고 한 번 더 시킨다. 그래도 안 맞으면 **0 이 아닌
|
|
32
|
+
// 값으로 끝낸다.** 모양이 안 맞는 JSON 을 표준출력으로 흘려보내는 것이
|
|
33
|
+
// 여기서 할 수 있는 제일 나쁜 일이다.
|
|
34
|
+
//
|
|
35
|
+
// ── 무엇을 안 하나 ─────────────────────────────────────────────────────
|
|
36
|
+
//
|
|
37
|
+
// **`$ref` 로 바깥을 안 본다.** JSON Schema 는 `$ref` 에 주소를 적을 수 있고,
|
|
38
|
+
// 검사기 대부분이 그걸 받아 온다. 우리는 안 받아 온다 — 스키마 파일 하나가
|
|
39
|
+
// 이 프로그램의 「나가는 주소는 사람이 정한 하나뿐」 을 깨뜨리는 길이 되면
|
|
40
|
+
// 안 된다. 같은 파일 안(`#/$defs/...`)만 따라간다.
|
|
41
|
+
//
|
|
42
|
+
// 규격을 다 받지도 않는다. 아래 목록에 없는 열쇠는 **본 척하지 않고 그냥
|
|
43
|
+
// 넘긴다.** 모르는 규칙을 아는 척 통과시키는 것이 모르는 채로 두는 것보다
|
|
44
|
+
// 나쁘다 — 사람은 잰 줄 알고 지낸다. 그래서 `모르는열쇠()` 로 무엇을 안
|
|
45
|
+
// 봤는지 셀 수 있게 해 두고, 화면이 그것을 말한다.
|
|
46
|
+
import { readFileSync } from 'node:fs';
|
|
47
|
+
|
|
48
|
+
/** 우리가 실제로 재는 열쇠들. 여기 없는 것은 안 재고, 안 쟀다고 말한다. */
|
|
49
|
+
export const 아는열쇠 = Object.freeze([
|
|
50
|
+
'type', 'enum', 'const',
|
|
51
|
+
'properties', 'required', 'additionalProperties',
|
|
52
|
+
'items', 'minItems', 'maxItems',
|
|
53
|
+
'minLength', 'maxLength', 'pattern',
|
|
54
|
+
'minimum', 'maximum', 'exclusiveMinimum', 'exclusiveMaximum', 'multipleOf',
|
|
55
|
+
'anyOf', 'oneOf', 'allOf', 'not',
|
|
56
|
+
'$ref', '$defs', 'definitions',
|
|
57
|
+
// 재는 것이 아니라 적어 두는 것들. 모델에게 보여 줄 때 쓴다.
|
|
58
|
+
'$schema', '$id', 'title', 'description', 'default', 'examples',
|
|
59
|
+
]);
|
|
60
|
+
|
|
61
|
+
const 아는것 = new Set(아는열쇠);
|
|
62
|
+
// 그 아래 열쇠가 규격 낱말이 아니라 '사람이 지은 칸 이름' 인 자리.
|
|
63
|
+
const 이름칸 = new Set(['properties', '$defs', 'definitions']);
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* 이 스키마에서 우리가 안 보는 열쇠들.
|
|
67
|
+
*
|
|
68
|
+
* 조용히 넘기면 사람은 잰 줄 알고 지낸다. 세어서 화면이 말하게 한다.
|
|
69
|
+
*/
|
|
70
|
+
export function 모르는열쇠(스키마, 본것 = new Set()) {
|
|
71
|
+
const out = new Set();
|
|
72
|
+
const 훑기 = (s) => {
|
|
73
|
+
if (!s || typeof s !== 'object' || 본것.has(s)) return;
|
|
74
|
+
본것.add(s);
|
|
75
|
+
if (Array.isArray(s)) { for (const x of s) 훑기(x); return; }
|
|
76
|
+
for (const [k, v] of Object.entries(s)) {
|
|
77
|
+
if (!아는것.has(k)) out.add(k);
|
|
78
|
+
if (!v || typeof v !== 'object') continue;
|
|
79
|
+
/*
|
|
80
|
+
* `properties`·`$defs` 아래 열쇠는 **사람이 지은 칸 이름**이지 규격
|
|
81
|
+
* 낱말이 아니다. 그걸 세면 칸을 하나 만들 때마다 「안 재는 열쇠」 가
|
|
82
|
+
* 하나씩 늘어난다 — 그러면 이 경고는 아무도 안 읽는 소음이 되고,
|
|
83
|
+
* 진짜로 안 재는 낱말(format 같은 것)이 그 속에 묻힌다.
|
|
84
|
+
*/
|
|
85
|
+
if (이름칸.has(k)) { for (const x of Object.values(v)) 훑기(x); }
|
|
86
|
+
else 훑기(v);
|
|
87
|
+
}
|
|
88
|
+
};
|
|
89
|
+
훑기(스키마);
|
|
90
|
+
return [...out].sort();
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* 스키마 파일을 읽는다.
|
|
95
|
+
*
|
|
96
|
+
* 못 읽으면 **일을 시작하지 않는다.** 스키마가 없는데 그냥 도는 것은
|
|
97
|
+
* 「모양을 못 박아 달라」 는 부탁을 조용히 무시하는 것이고, 그 결과는 파이프
|
|
98
|
+
* 뒤에서 터진다.
|
|
99
|
+
*
|
|
100
|
+
* @returns {{ok: true, 스키마: object, 모른것: string[]} | {ok: false, 왜: string}}
|
|
101
|
+
*/
|
|
102
|
+
export function 스키마읽기(경로) {
|
|
103
|
+
let 글;
|
|
104
|
+
try { 글 = readFileSync(경로, 'utf8'); }
|
|
105
|
+
catch (err) { return { ok: false, 왜: `스키마 파일을 못 읽었습니다: ${경로}\n ${err?.message ?? err}` }; }
|
|
106
|
+
let s;
|
|
107
|
+
try { s = JSON.parse(글); }
|
|
108
|
+
catch (err) { return { ok: false, 왜: `스키마가 JSON 이 아닙니다: ${경로}\n ${err?.message ?? err}` }; }
|
|
109
|
+
if (!s || typeof s !== 'object' || Array.isArray(s)) {
|
|
110
|
+
return { ok: false, 왜: `스키마는 객체여야 합니다: ${경로}` };
|
|
111
|
+
}
|
|
112
|
+
// 바깥을 가리키는 $ref 는 여기서 막는다. 읽고 나서 막으면 이미 늦다.
|
|
113
|
+
const 바깥 = 바깥ref찾기(s);
|
|
114
|
+
if (바깥.length) {
|
|
115
|
+
return {
|
|
116
|
+
ok: false,
|
|
117
|
+
왜: `스키마가 바깥을 가리킵니다: ${바깥.slice(0, 3).join(' · ')}\n`
|
|
118
|
+
+ ' 이 프로그램은 스키마를 받아 오지 않습니다 — 나가는 주소는 사람이 정한 하나뿐이어야 합니다.\n'
|
|
119
|
+
+ ' 같은 파일 안(#/$defs/...)으로 바꿔 주세요.',
|
|
120
|
+
};
|
|
121
|
+
}
|
|
122
|
+
return { ok: true, 스키마: s, 모른것: 모르는열쇠(s) };
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/** 같은 파일 안(`#`으로 시작)이 아닌 `$ref` 를 다 찾는다. */
|
|
126
|
+
export function 바깥ref찾기(스키마, 본것 = new Set()) {
|
|
127
|
+
const out = [];
|
|
128
|
+
const 훑기 = (s) => {
|
|
129
|
+
if (!s || typeof s !== 'object' || 본것.has(s)) return;
|
|
130
|
+
본것.add(s);
|
|
131
|
+
if (Array.isArray(s)) { for (const x of s) 훑기(x); return; }
|
|
132
|
+
if (typeof s.$ref === 'string' && !s.$ref.startsWith('#')) out.push(s.$ref);
|
|
133
|
+
for (const v of Object.values(s)) if (v && typeof v === 'object') 훑기(v);
|
|
134
|
+
};
|
|
135
|
+
훑기(스키마);
|
|
136
|
+
return [...new Set(out)];
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/** `#/$defs/이름` 같은 자리를 뿌리에서 따라간다. 못 찾으면 null. */
|
|
140
|
+
function ref따라가기(뿌리, ref) {
|
|
141
|
+
if (typeof ref !== 'string' || !ref.startsWith('#')) return null;
|
|
142
|
+
const 길 = ref.slice(1).split('/').filter(Boolean)
|
|
143
|
+
.map((x) => decodeURIComponent(x.replace(/~1/g, '/').replace(/~0/g, '~')));
|
|
144
|
+
let 여기 = 뿌리;
|
|
145
|
+
for (const 조각 of 길) {
|
|
146
|
+
if (!여기 || typeof 여기 !== 'object') return null;
|
|
147
|
+
여기 = 여기[조각];
|
|
148
|
+
}
|
|
149
|
+
return 여기 && typeof 여기 === 'object' ? 여기 : null;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
const 갈래 = (v) => {
|
|
153
|
+
if (v === null) return 'null';
|
|
154
|
+
if (Array.isArray(v)) return 'array';
|
|
155
|
+
if (Number.isInteger(v)) return 'integer';
|
|
156
|
+
return typeof v === 'number' ? 'number' : typeof v;
|
|
157
|
+
};
|
|
158
|
+
|
|
159
|
+
const 갈래맞나 = (v, t) => {
|
|
160
|
+
if (t === 'integer') return Number.isInteger(v);
|
|
161
|
+
if (t === 'number') return typeof v === 'number' && Number.isFinite(v);
|
|
162
|
+
if (t === 'array') return Array.isArray(v);
|
|
163
|
+
if (t === 'null') return v === null;
|
|
164
|
+
if (t === 'object') return v !== null && typeof v === 'object' && !Array.isArray(v);
|
|
165
|
+
return typeof v === t;
|
|
166
|
+
};
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* 값이 스키마에 맞나.
|
|
170
|
+
*
|
|
171
|
+
* 탈은 **다 모아서** 준다. 하나만 주면 고치고 다시 돌리고를 반복하게 되는데,
|
|
172
|
+
* 그 한 바퀴가 모델 호출 한 번이라 값이 비싸다. 그리고 사람이 스키마를
|
|
173
|
+
* 잘못 적은 경우에도 한 번에 다 보이는 편이 낫다.
|
|
174
|
+
*
|
|
175
|
+
* @returns {{ok: boolean, 탈: string[]}}
|
|
176
|
+
*/
|
|
177
|
+
export function 맞나(값, 스키마, { 뿌리 = 스키마, 자리 = '' } = {}) {
|
|
178
|
+
const 탈 = [];
|
|
179
|
+
const 여기 = 자리 || '(뿌리)';
|
|
180
|
+
const s = 스키마;
|
|
181
|
+
if (!s || typeof s !== 'object') return { ok: true, 탈 };
|
|
182
|
+
|
|
183
|
+
// true/false 스키마와 $ref 를 먼저 푼다.
|
|
184
|
+
if (typeof s.$ref === 'string') {
|
|
185
|
+
const 간것 = ref따라가기(뿌리, s.$ref);
|
|
186
|
+
if (!간것) return { ok: false, 탈: [`${여기}: 스키마 안에 ${s.$ref} 가 없습니다`] };
|
|
187
|
+
return 맞나(값, 간것, { 뿌리, 자리 });
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
if (Array.isArray(s.type) ? !s.type.some((t) => 갈래맞나(값, t)) : (s.type && !갈래맞나(값, s.type))) {
|
|
191
|
+
탈.push(`${여기}: ${Array.isArray(s.type) ? s.type.join('|') : s.type} 이어야 하는데 ${갈래(값)} 입니다`);
|
|
192
|
+
// 갈래부터 틀리면 그 아래는 재 봐야 헛말만 늘어난다.
|
|
193
|
+
return { ok: false, 탈 };
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
if (Array.isArray(s.enum) && !s.enum.some((x) => JSON.stringify(x) === JSON.stringify(값))) {
|
|
197
|
+
탈.push(`${여기}: ${s.enum.map((x) => JSON.stringify(x)).join(' · ')} 중 하나여야 합니다`);
|
|
198
|
+
}
|
|
199
|
+
if ('const' in s && JSON.stringify(s.const) !== JSON.stringify(값)) {
|
|
200
|
+
탈.push(`${여기}: ${JSON.stringify(s.const)} 여야 합니다`);
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
if (typeof 값 === 'string') {
|
|
204
|
+
if (Number.isFinite(s.minLength) && [...값].length < s.minLength) 탈.push(`${여기}: ${s.minLength}자 이상이어야 합니다`);
|
|
205
|
+
if (Number.isFinite(s.maxLength) && [...값].length > s.maxLength) 탈.push(`${여기}: ${s.maxLength}자 이하여야 합니다`);
|
|
206
|
+
if (typeof s.pattern === 'string') {
|
|
207
|
+
// 스키마를 적은 사람의 정규식이 잘못됐을 수 있다. 그것도 탈로 말한다 —
|
|
208
|
+
// 조용히 통과시키면 안 재고 있는 규칙이 하나 생긴다.
|
|
209
|
+
try { if (!new RegExp(s.pattern).test(값)) 탈.push(`${여기}: ${s.pattern} 모양이어야 합니다`); }
|
|
210
|
+
catch { 탈.push(`${여기}: 스키마의 pattern 이 잘못된 정규식입니다 (${s.pattern})`); }
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
if (typeof 값 === 'number') {
|
|
215
|
+
if (Number.isFinite(s.minimum) && 값 < s.minimum) 탈.push(`${여기}: ${s.minimum} 이상이어야 합니다`);
|
|
216
|
+
if (Number.isFinite(s.maximum) && 값 > s.maximum) 탈.push(`${여기}: ${s.maximum} 이하여야 합니다`);
|
|
217
|
+
if (Number.isFinite(s.exclusiveMinimum) && 값 <= s.exclusiveMinimum) 탈.push(`${여기}: ${s.exclusiveMinimum} 보다 커야 합니다`);
|
|
218
|
+
if (Number.isFinite(s.exclusiveMaximum) && 값 >= s.exclusiveMaximum) 탈.push(`${여기}: ${s.exclusiveMaximum} 보다 작아야 합니다`);
|
|
219
|
+
if (Number.isFinite(s.multipleOf) && s.multipleOf > 0) {
|
|
220
|
+
const 나눈것 = 값 / s.multipleOf;
|
|
221
|
+
if (Math.abs(나눈것 - Math.round(나눈것)) > 1e-9) 탈.push(`${여기}: ${s.multipleOf} 의 배수여야 합니다`);
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
if (Array.isArray(값)) {
|
|
226
|
+
if (Number.isFinite(s.minItems) && 값.length < s.minItems) 탈.push(`${여기}: ${s.minItems}개 이상이어야 합니다`);
|
|
227
|
+
if (Number.isFinite(s.maxItems) && 값.length > s.maxItems) 탈.push(`${여기}: ${s.maxItems}개 이하여야 합니다`);
|
|
228
|
+
if (s.items && typeof s.items === 'object' && !Array.isArray(s.items)) {
|
|
229
|
+
값.forEach((v, i) => 탈.push(...맞나(v, s.items, { 뿌리, 자리: `${여기}[${i}]` }).탈));
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
if (값 !== null && typeof 값 === 'object' && !Array.isArray(값)) {
|
|
234
|
+
for (const k of Array.isArray(s.required) ? s.required : []) {
|
|
235
|
+
if (!Object.prototype.hasOwnProperty.call(값, k)) 탈.push(`${여기}: ${k} 칸이 없습니다`);
|
|
236
|
+
}
|
|
237
|
+
const 칸들 = s.properties && typeof s.properties === 'object' ? s.properties : {};
|
|
238
|
+
for (const [k, v] of Object.entries(값)) {
|
|
239
|
+
if (Object.prototype.hasOwnProperty.call(칸들, k)) {
|
|
240
|
+
탈.push(...맞나(v, 칸들[k], { 뿌리, 자리: 여기 === '(뿌리)' ? k : `${여기}.${k}` }).탈);
|
|
241
|
+
} else if (s.additionalProperties === false) {
|
|
242
|
+
탈.push(`${여기}: ${k} 는 적으면 안 되는 칸입니다`);
|
|
243
|
+
} else if (s.additionalProperties && typeof s.additionalProperties === 'object') {
|
|
244
|
+
탈.push(...맞나(v, s.additionalProperties, { 뿌리, 자리: 여기 === '(뿌리)' ? k : `${여기}.${k}` }).탈);
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
if (Array.isArray(s.allOf)) for (const x of s.allOf) 탈.push(...맞나(값, x, { 뿌리, 자리 }).탈);
|
|
250
|
+
if (Array.isArray(s.anyOf) && !s.anyOf.some((x) => 맞나(값, x, { 뿌리, 자리 }).ok)) {
|
|
251
|
+
탈.push(`${여기}: anyOf 중 어느 것에도 안 맞습니다`);
|
|
252
|
+
}
|
|
253
|
+
if (Array.isArray(s.oneOf)) {
|
|
254
|
+
const 맞은수 = s.oneOf.filter((x) => 맞나(값, x, { 뿌리, 자리 }).ok).length;
|
|
255
|
+
if (맞은수 !== 1) 탈.push(`${여기}: oneOf 중 정확히 하나에 맞아야 하는데 ${맞은수}개에 맞습니다`);
|
|
256
|
+
}
|
|
257
|
+
if (s.not && typeof s.not === 'object' && 맞나(값, s.not, { 뿌리, 자리 }).ok) {
|
|
258
|
+
탈.push(`${여기}: not 에 걸립니다`);
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
return { ok: 탈.length === 0, 탈 };
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
/**
|
|
265
|
+
* 모델이 낸 글에서 JSON 을 뽑는다.
|
|
266
|
+
*
|
|
267
|
+
* 「JSON 만 내라」 고 적어 둬도 모델은 ```json 울타리를 두르거나 앞에 한 줄
|
|
268
|
+
* 인사를 붙인다. 그걸 실패로 치면 멀쩡한 답을 버리게 되므로, **뽑을 수 있으면
|
|
269
|
+
* 뽑는다.** 다만 뽑아 놓고 아무 말도 안 하면 안 된다 — 무엇을 걷어냈는지
|
|
270
|
+
* 돌려줘서 화면이 말하게 한다.
|
|
271
|
+
*
|
|
272
|
+
* @returns {{ok: true, 값: any, 군말: boolean} | {ok: false, 왜: string}}
|
|
273
|
+
*/
|
|
274
|
+
export function 답에서JSON뽑기(글) {
|
|
275
|
+
const s = String(글 ?? '').trim();
|
|
276
|
+
if (!s) return { ok: false, 왜: '답이 비었습니다' };
|
|
277
|
+
|
|
278
|
+
const 해보기 = (t) => { try { return { ok: true, 값: JSON.parse(t) }; } catch { return null; } };
|
|
279
|
+
|
|
280
|
+
const 그대로 = 해보기(s);
|
|
281
|
+
if (그대로) return { ...그대로, 군말: false };
|
|
282
|
+
|
|
283
|
+
// ```json … ``` 울타리
|
|
284
|
+
const 울타리 = /```(?:json|jsonc)?\s*\n([\s\S]*?)\n?```/i.exec(s);
|
|
285
|
+
if (울타리) {
|
|
286
|
+
const r = 해보기(울타리[1].trim());
|
|
287
|
+
if (r) return { ...r, 군말: true };
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
/*
|
|
291
|
+
* 앞뒤에 말이 붙은 경우. 첫 `{`(또는 `[`)부터 짝이 맞는 자리까지 잘라 본다.
|
|
292
|
+
*
|
|
293
|
+
* 따옴표 안의 괄호를 안 세는 것이 중요하다 — `{"글": "}"}` 를 깊이로만
|
|
294
|
+
* 세면 첫 `}` 에서 끊긴다.
|
|
295
|
+
*/
|
|
296
|
+
const 시작 = Math.min(...['{', '['].map((ch) => { const i = s.indexOf(ch); return i < 0 ? Infinity : i; }));
|
|
297
|
+
if (Number.isFinite(시작)) {
|
|
298
|
+
const 열림 = s[시작];
|
|
299
|
+
const 닫힘 = 열림 === '{' ? '}' : ']';
|
|
300
|
+
let 깊이 = 0;
|
|
301
|
+
let 글속 = false;
|
|
302
|
+
for (let i = 시작; i < s.length; i++) {
|
|
303
|
+
const ch = s[i];
|
|
304
|
+
if (글속) {
|
|
305
|
+
if (ch === '\\') { i++; continue; }
|
|
306
|
+
if (ch === '"') 글속 = false;
|
|
307
|
+
continue;
|
|
308
|
+
}
|
|
309
|
+
if (ch === '"') { 글속 = true; continue; }
|
|
310
|
+
if (ch === 열림) 깊이++;
|
|
311
|
+
else if (ch === 닫힘 && --깊이 === 0) {
|
|
312
|
+
const r = 해보기(s.slice(시작, i + 1));
|
|
313
|
+
if (r) return { ...r, 군말: true };
|
|
314
|
+
break;
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
return { ok: false, 왜: '답에서 JSON 을 못 찾았습니다' };
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
/**
|
|
322
|
+
* 모델에게 붙일 말.
|
|
323
|
+
*
|
|
324
|
+
* 스키마를 통째로 싣는다 — 줄여 적으면 모델이 못 본 규칙을 어기고, 그러면
|
|
325
|
+
* 우리가 재서 되돌리는 한 바퀴가 더 돈다. 그 한 바퀴가 모델 호출 한 번이라
|
|
326
|
+
* 스키마를 다 싣는 것보다 비싸다.
|
|
327
|
+
*/
|
|
328
|
+
export function 시킬말(스키마, { 다시 = null } = {}) {
|
|
329
|
+
const 몸 = [
|
|
330
|
+
'',
|
|
331
|
+
'── 답의 모양이 정해져 있습니다 ─────────────────────────────────',
|
|
332
|
+
'',
|
|
333
|
+
'마지막 답은 **JSON 하나**여야 하고, 아래 스키마에 맞아야 합니다.',
|
|
334
|
+
'설명·인사·```울타리를 붙이지 마세요. JSON 만 내세요.',
|
|
335
|
+
'',
|
|
336
|
+
'```json',
|
|
337
|
+
JSON.stringify(스키마, null, 2),
|
|
338
|
+
'```',
|
|
339
|
+
'',
|
|
340
|
+
'모르는 값은 지어내지 말고, 스키마가 허락하면 null 이나 빈 배열로 두세요.',
|
|
341
|
+
'지어낸 값 하나가 이 답을 받아 쓰는 쪽에서 조용히 틀린 결정이 됩니다.',
|
|
342
|
+
];
|
|
343
|
+
if (다시?.length) {
|
|
344
|
+
몸.push(
|
|
345
|
+
'',
|
|
346
|
+
'방금 낸 답이 아래에서 안 맞았습니다. 그것만 고쳐서 JSON 을 다시 내세요.',
|
|
347
|
+
...다시.map((x) => ` - ${x}`),
|
|
348
|
+
);
|
|
349
|
+
}
|
|
350
|
+
return 몸.join('\n');
|
|
351
|
+
}
|
package/src/agent/session.js
CHANGED
|
@@ -346,7 +346,18 @@ export class Session {
|
|
|
346
346
|
}
|
|
347
347
|
|
|
348
348
|
/**
|
|
349
|
-
* 이 폴더의 규칙 파일. DEEL.md → CLAUDE.md → AGENTS.md
|
|
349
|
+
* 이 폴더의 규칙 파일. DEEL.md → CLAUDE.md → AGENTS.md → GEMINI.md 중
|
|
350
|
+
* 먼저 읽히는 하나.
|
|
351
|
+
*
|
|
352
|
+
* 남의 이름을 읽는 것이 이 목록의 요점이다. 규칙 파일은 사람이 며칠에 걸쳐
|
|
353
|
+
* 다듬어 놓은 것이고, 옮겨 오는 사람에게 「그건 못 읽으니 다시 적으세요」
|
|
354
|
+
* 라고 하면 대부분 안 옮긴다. GEMINI.md 를 뒤에 붙인 것은 Gemini CLI 가
|
|
355
|
+
* 2026-06-18 로 개인 사용자를 안 받게 되면서 그 파일을 든 채로 갈 곳을
|
|
356
|
+
* 찾는 사람이 실제로 생겼기 때문이다.
|
|
357
|
+
*
|
|
358
|
+
* 순서는 **좁은 것부터**다. DEEL.md 는 우리한테 하는 말이라 제일 세고,
|
|
359
|
+
* GEMINI.md 는 남한테 하던 말이라 제일 약하다. 두 개가 같이 있는 폴더에서
|
|
360
|
+
* 우리 이름을 진 파일이 지는 일은 없어야 한다.
|
|
350
361
|
*
|
|
351
362
|
* 없는 것은 그냥 없는 것이라 아무 말도 안 한다. 그런데 **있는데 못 읽는**
|
|
352
363
|
* 것까지 같이 삼키면 안 된다 — 권한이 막혔거나 같은 이름의 폴더가 있으면
|
|
@@ -356,7 +367,7 @@ export class Session {
|
|
|
356
367
|
* 못 읽은 것은 적어 두고 /status 가 '없음' 대신 그 까닭을 말한다.
|
|
357
368
|
*/
|
|
358
369
|
#loadRules() {
|
|
359
|
-
for (const name of ['DEEL.md', 'CLAUDE.md', 'AGENTS.md']) {
|
|
370
|
+
for (const name of ['DEEL.md', 'CLAUDE.md', 'AGENTS.md', 'GEMINI.md']) {
|
|
360
371
|
try { return { name, text: readFileSync(join(this.root, name), 'utf8').slice(0, 20000) }; }
|
|
361
372
|
catch (err) {
|
|
362
373
|
// 없으면 그냥 없는 것이다 — 말할 일이 아니다. existsSync 로 먼저 보지
|
package/src/backend/adapter.js
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
// 진단(probe)과 에이전트 루프가 같은 함수를 쓴다.
|
|
3
3
|
import { mkdirSync, writeFileSync } from 'node:fs';
|
|
4
4
|
import { join } from 'node:path';
|
|
5
|
-
import { req, headersFor, serverMessage, Aborted } from './http.js';
|
|
5
|
+
import { req, headersFor, serverMessage, Aborted, 잠잠기본, 초로 } from './http.js';
|
|
6
6
|
import { 할당량기억, 미리기다릴까, 마지막할당량, 할당량자리 } from './quota.js';
|
|
7
7
|
import { 열쇠 as 열쇠받아오기, 쓸수있나 } from '../safety/authcmd.js';
|
|
8
8
|
import { 말 } from '../i18n/index.js';
|
|
@@ -1110,7 +1110,13 @@ export async function* chatStream(conn, opts) {
|
|
|
1110
1110
|
method: 'POST',
|
|
1111
1111
|
headers: await 머리말짓기(conn, opts),
|
|
1112
1112
|
body,
|
|
1113
|
+
/*
|
|
1114
|
+
* 흘려 받는 자리에서 `timeout` 은 **머리말까지**다. 그 뒤로는 잠잠한
|
|
1115
|
+
* 시간을 잰다 (backend/http.js 의 잠잠감시). 안 그러면 오래 생각하는
|
|
1116
|
+
* 모델의 답이 5분에서 잘리고, 멎어 버린 게이트웨이는 5분을 꽉 채운다.
|
|
1117
|
+
*/
|
|
1113
1118
|
timeout: opts.timeout ?? 300000,
|
|
1119
|
+
잠잠: conn.잠잠 ?? 잠잠기본,
|
|
1114
1120
|
stream: true,
|
|
1115
1121
|
signal: opts.signal ?? null,
|
|
1116
1122
|
});
|
|
@@ -1175,7 +1181,26 @@ export async function* chatStream(conn, opts) {
|
|
|
1175
1181
|
let done;
|
|
1176
1182
|
let value;
|
|
1177
1183
|
try { ({ done, value } = await reader.read()); }
|
|
1178
|
-
catch (err) {
|
|
1184
|
+
catch (err) {
|
|
1185
|
+
if (opts.signal?.aborted) throw new Aborted();
|
|
1186
|
+
/*
|
|
1187
|
+
* 흐름이 멎어서 우리가 끊은 것.
|
|
1188
|
+
*
|
|
1189
|
+
* 여기까지 온 글자는 **버리지 않는다.** 3천 자를 받아 놓고 마지막에
|
|
1190
|
+
* 멎었다고 통째로 던지면, 사람은 아무것도 못 보고 같은 것을 다시
|
|
1191
|
+
* 시킨다 — 같은 자리에서 또 멎는다. 받은 것은 주고, 왜 멎었는지에
|
|
1192
|
+
* 이름을 붙여 화면이 사실대로 말하게 한다.
|
|
1193
|
+
*
|
|
1194
|
+
* 다만 한 글자도 못 받았으면 그건 그냥 실패다. 빈 답을 '답' 이라고
|
|
1195
|
+
* 부르는 것이 이 파일에서 제일 하면 안 되는 일이다.
|
|
1196
|
+
*/
|
|
1197
|
+
if (err?.code === 'STALL' && (acc.content || acc.thinking || acc.toolCalls.length)) {
|
|
1198
|
+
acc.stopped = 흐름멎음;
|
|
1199
|
+
acc.멎은초 = 초로(err.잠잠 ?? 잠잠기본);
|
|
1200
|
+
break;
|
|
1201
|
+
}
|
|
1202
|
+
throw err;
|
|
1203
|
+
}
|
|
1179
1204
|
// 끝났으면 decoder 에 걸쳐 있던 마지막 글자까지 뱉게 한다(flush).
|
|
1180
1205
|
buf += done ? dec.decode() : dec.decode(value, { stream: true });
|
|
1181
1206
|
|
|
@@ -1231,6 +1256,17 @@ export async function* chatStream(conn, opts) {
|
|
|
1231
1256
|
/** 서버가 끝난 까닭을 안 주고 흘려보내기를 멈춘 것. 'stop' 과 구별해야 한다. */
|
|
1232
1257
|
export const 말없이끝남 = '말없이끝남';
|
|
1233
1258
|
|
|
1259
|
+
/**
|
|
1260
|
+
* 흘러오던 것이 **멎어서 우리가 끊은** 것. 말없이끝남 과 구별한다.
|
|
1261
|
+
*
|
|
1262
|
+
* 말없이끝남 은 상대가 연결을 곱게 닫은 것이고, 이쪽은 연결이 살아 있는 채로
|
|
1263
|
+
* 아무것도 안 오는 것이다. 고칠 자리가 다르다 — 앞은 중계 프록시가 몸통을
|
|
1264
|
+
* 자르는 것이고, 뒤는 게이트웨이가 답을 통째로 모았다가 한 번에 주려다
|
|
1265
|
+
* 우리 시계를 넘긴 것(그때는 잠잠 상한을 올리는 것이 답이다).
|
|
1266
|
+
*/
|
|
1267
|
+
export const 흐름멎음 = '흐름멎음';
|
|
1268
|
+
|
|
1269
|
+
|
|
1234
1270
|
// 조각 하나를 누적하고, 화면에 흘릴 것만 내보낸다.
|
|
1235
1271
|
function absorb(shape, obj, acc) {
|
|
1236
1272
|
const out = [];
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
// 게이트웨이가 **우리 인증서**를 요구할 때 (mTLS).
|
|
2
|
+
//
|
|
3
|
+
// ── 왜 필요한가 ────────────────────────────────────────────────────────
|
|
4
|
+
//
|
|
5
|
+
// 금융·공공 쪽 사내 게이트웨이는 열쇠 하나로 안 열린다. 서버가 클라이언트에게도
|
|
6
|
+
// 인증서를 내라고 한다 — 열쇠는 훔쳐 갈 수 있지만 기기에 발급한 인증서는 그
|
|
7
|
+
// 기기를 벗어나지 못한다는 것이 그 요구의 요지다. 그 자리에서 우리가 인증서를
|
|
8
|
+
// 못 내면 붙는 방법이 아예 없다. 「사내에서 못 씁니다」 가 되고, 그건 다른
|
|
9
|
+
// 어떤 기능으로도 못 메운다.
|
|
10
|
+
//
|
|
11
|
+
// ── 왜 fetch 를 못 쓰나 ────────────────────────────────────────────────
|
|
12
|
+
//
|
|
13
|
+
// Node 의 `fetch` 에는 인증서를 실을 자리가 없다. 그 자리는 undici 의
|
|
14
|
+
// dispatcher 인데, 그건 가져다 쓸 수 있는 이름이 아니다(그리고 이 프로그램은
|
|
15
|
+
// 딸린 것을 안 쓴다). 그래서 인증서를 쓰는 요청은 `node:https` 로 보낸다 —
|
|
16
|
+
// 프록시를 거치는 길에서 이미 그렇게 하고 있으므로 길이 하나 더 생기는 것이
|
|
17
|
+
// 아니라, 이미 있던 길에 인증서를 얹는 것이다 (backend/http.js 의 노드로).
|
|
18
|
+
//
|
|
19
|
+
// ── 어디에 적나 ────────────────────────────────────────────────────────
|
|
20
|
+
//
|
|
21
|
+
// 프로필에 적는다. 파일 **경로**만 적는다 — 알맹이를 설정 파일에 넣으면 그
|
|
22
|
+
// 파일이 곧 개인키가 되고, 그건 백업·동기화·화면 공유를 타고 아무 데나 간다.
|
|
23
|
+
//
|
|
24
|
+
// "인증서": {
|
|
25
|
+
// "cert": "C:/certs/client.pem", PEM 인증서
|
|
26
|
+
// "key": "C:/certs/client.key", PEM 개인키
|
|
27
|
+
// "ca": "C:/certs/corp-ca.pem" (선택) 사내 루트
|
|
28
|
+
// }
|
|
29
|
+
//
|
|
30
|
+
// PKCS#12 한 덩이(`.pfx`·`.p12`)도 받는다 — 윈도우에서 받아 오는 모양이다.
|
|
31
|
+
//
|
|
32
|
+
// "인증서": { "pfx": "C:/certs/client.pfx" }
|
|
33
|
+
//
|
|
34
|
+
// 암호는 **설정에 안 적는 것을 기본으로** 한다. `DEEL_CERT_PASS` 환경변수를
|
|
35
|
+
// 먼저 보고, 없을 때만 설정의 `passphrase` 를 쓴다. 어느 쪽이든 화면·기록에
|
|
36
|
+
// 안 나간다 (safety/secrets.js 가 가리는 것과 같은 규칙).
|
|
37
|
+
//
|
|
38
|
+
// ── 어느 주소에 붙나 ───────────────────────────────────────────────────
|
|
39
|
+
//
|
|
40
|
+
// **주소(origin)로 기억한다.** 프로필에 적힌 게이트웨이 주소에만 붙고, 다른
|
|
41
|
+
// 집으로는 절대 안 나간다. 되돌림(redirect)을 따라 남의 집으로 갈 때 우리
|
|
42
|
+
// 인증서가 따라가면 그건 신원을 남에게 보여 주는 것이다 — 열쇠 머리말을 떼는
|
|
43
|
+
// 것과 같은 까닭으로 여기서도 안 따라간다.
|
|
44
|
+
import { readFileSync, statSync } from 'node:fs';
|
|
45
|
+
|
|
46
|
+
/** 프로필에 적힌 것을 한 모양으로 편다. 없으면 null. */
|
|
47
|
+
export function 인증서설정(prof) {
|
|
48
|
+
const 것 = prof?.인증서 ?? prof?.clientCert ?? null;
|
|
49
|
+
if (!것 || typeof 것 !== 'object') return null;
|
|
50
|
+
const 글 = (v) => (typeof v === 'string' && v.trim() ? v.trim() : null);
|
|
51
|
+
const 편것 = {
|
|
52
|
+
cert: 글(것.cert ?? 것.certFile ?? 것.인증서),
|
|
53
|
+
key: 글(것.key ?? 것.keyFile ?? 것.열쇠),
|
|
54
|
+
pfx: 글(것.pfx ?? 것.p12 ?? 것.pfxFile),
|
|
55
|
+
ca: 글(것.ca ?? 것.caFile ?? 것.뿌리),
|
|
56
|
+
// 암호만 값이다. 나머지는 전부 파일 경로다.
|
|
57
|
+
passphrase: 글(것.passphrase ?? 것.암호),
|
|
58
|
+
};
|
|
59
|
+
/*
|
|
60
|
+
* 아무것도 안 적었으면 없는 것으로 친다. 빈 껍데기를 들고 다니면 아래에서
|
|
61
|
+
* 「인증서를 쓴다」 고 판단해 놓고 실제로는 아무것도 안 싣는다.
|
|
62
|
+
*
|
|
63
|
+
* `ca` 만 적은 것도 받는다. 사내 루트만 필요하고 우리 인증서는 안 내는
|
|
64
|
+
* 게이트웨이가 흔한데, 여태 그 자리의 길은 NODE_EXTRA_CA_CERTS 환경변수
|
|
65
|
+
* 하나뿐이었다 — 그건 프로필마다 다르게 못 준다. 사내 게이트웨이와 로컬
|
|
66
|
+
* 모델을 오가는 사람에게는 그게 곧 못 쓰는 것이다.
|
|
67
|
+
*/
|
|
68
|
+
if (!편것.cert && !편것.pfx && !편것.ca) return null;
|
|
69
|
+
return 편것;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// 주소 → 설정. 프로그램이 사는 동안만 있는다.
|
|
73
|
+
const 등록부 = new Map();
|
|
74
|
+
|
|
75
|
+
/** 주소를 origin 하나로. 못 읽으면 null. */
|
|
76
|
+
function 집(주소) {
|
|
77
|
+
try { return new URL(String(주소)).origin; } catch { return null; }
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** 이 게이트웨이는 우리 인증서를 요구한다고 적어 둔다. */
|
|
81
|
+
export function 인증서등록(주소, 설정) {
|
|
82
|
+
const o = 집(주소);
|
|
83
|
+
if (!o) return;
|
|
84
|
+
if (설정) 등록부.set(o, 설정);
|
|
85
|
+
else 등록부.delete(o);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** 검사와 `/model` 갈아타기가 쓴다. */
|
|
89
|
+
export function 인증서잊기() { 등록부.clear(); 읽은것.clear(); }
|
|
90
|
+
|
|
91
|
+
// 파일 경로 → { 때, 알맹이 }. 요청마다 디스크를 읽지 않으려고 들고 있는다.
|
|
92
|
+
// 파일이 바뀌면(인증서를 새로 받으면) 다시 읽는다 — 프로그램을 껐다 켜야
|
|
93
|
+
// 새 인증서가 먹는다면 그건 하루짜리 인증서를 쓰는 자리에서 못 쓴다는 뜻이다.
|
|
94
|
+
const 읽은것 = new Map();
|
|
95
|
+
|
|
96
|
+
function 파일읽기(경로, 무엇) {
|
|
97
|
+
let 때;
|
|
98
|
+
try { 때 = statSync(경로).mtimeMs; }
|
|
99
|
+
catch (err) { throw 못읽음(무엇, 경로, err); }
|
|
100
|
+
const 있던것 = 읽은것.get(경로);
|
|
101
|
+
if (있던것 && 있던것.때 === 때) return 있던것.알맹이;
|
|
102
|
+
let 알맹이;
|
|
103
|
+
try { 알맹이 = readFileSync(경로); }
|
|
104
|
+
catch (err) { throw 못읽음(무엇, 경로, err); }
|
|
105
|
+
읽은것.set(경로, { 때, 알맹이 });
|
|
106
|
+
return 알맹이;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function 못읽음(무엇, 경로, err) {
|
|
110
|
+
return Object.assign(
|
|
111
|
+
new Error(`${무엇}를 못 읽었습니다: ${경로}\n ${err?.message ?? err}`),
|
|
112
|
+
{ code: 'CERT_READ' },
|
|
113
|
+
);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* 이 주소에 실을 TLS 옵션. 등록된 것이 없으면 null.
|
|
118
|
+
*
|
|
119
|
+
* 파일을 여기서 읽는다 — 켤 때 한 번 읽어 두면 짧게 도는 인증서를 갈아 끼웠을
|
|
120
|
+
* 때 프로그램을 껐다 켜야 한다. 읽은 것은 파일이 그대로면 다시 안 읽는다.
|
|
121
|
+
*
|
|
122
|
+
* @throws 인증서 파일을 못 읽으면 던진다. 조용히 빼고 붙으면 서버가 주는
|
|
123
|
+
* 것은 TLS 악수 실패인데, 그 화면은 「인증서가 없다」 와 「인증서가
|
|
124
|
+
* 틀렸다」 를 구별해 주지 않는다.
|
|
125
|
+
*/
|
|
126
|
+
export function 인증서찾기(주소) {
|
|
127
|
+
const o = 집(주소);
|
|
128
|
+
const 설정 = o ? 등록부.get(o) : null;
|
|
129
|
+
if (!설정) return null;
|
|
130
|
+
const 옵션 = {};
|
|
131
|
+
if (설정.pfx) 옵션.pfx = 파일읽기(설정.pfx, '클라이언트 인증서(pfx)');
|
|
132
|
+
if (설정.cert) 옵션.cert = 파일읽기(설정.cert, '클라이언트 인증서');
|
|
133
|
+
if (설정.key) 옵션.key = 파일읽기(설정.key, '클라이언트 개인키');
|
|
134
|
+
if (설정.ca) 옵션.ca = 파일읽기(설정.ca, '사내 루트 인증서');
|
|
135
|
+
/*
|
|
136
|
+
* 암호는 환경변수가 먼저다.
|
|
137
|
+
*
|
|
138
|
+
* 설정 파일은 백업·동기화·화면 공유를 타고 아무 데나 간다. 환경변수는 그
|
|
139
|
+
* 프로세스에만 있다. 둘 다 있으면 환경변수를 쓴다 — 파일에 적힌 것을
|
|
140
|
+
* 「임시로 다른 것을 써 보기」 위해 고치지 않아도 되게.
|
|
141
|
+
*/
|
|
142
|
+
const 암호 = process.env.DEEL_CERT_PASS || 설정.passphrase;
|
|
143
|
+
if (암호) 옵션.passphrase = 암호;
|
|
144
|
+
return Object.keys(옵션).length ? 옵션 : null;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* 화면에 보여 줄 한 줄. **암호는 절대 안 들어간다.**
|
|
149
|
+
*
|
|
150
|
+
* 무엇을 쓰고 있는지 사람이 볼 수 있어야 한다 — 붙었는지 아닌지만 알고
|
|
151
|
+
* 무엇으로 붙었는지 모르면, 인증서를 두 개 가진 사람은 어느 것이 먹었는지
|
|
152
|
+
* 알 길이 없다.
|
|
153
|
+
*/
|
|
154
|
+
export function 인증서말(설정) {
|
|
155
|
+
if (!설정) return null;
|
|
156
|
+
const 조각 = [];
|
|
157
|
+
if (설정.pfx) 조각.push(`pfx ${설정.pfx}`);
|
|
158
|
+
if (설정.cert) 조각.push(`cert ${설정.cert}`);
|
|
159
|
+
if (설정.key) 조각.push('key(적힘)');
|
|
160
|
+
if (설정.ca) 조각.push(`ca ${설정.ca}`);
|
|
161
|
+
const 암호 = process.env.DEEL_CERT_PASS ? '환경변수' : (설정.passphrase ? '설정' : null);
|
|
162
|
+
if (암호) 조각.push(`암호 ${암호}에서`);
|
|
163
|
+
return 조각.join(' · ');
|
|
164
|
+
}
|