@wooojin/forgen 0.5.6 → 0.5.8
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/.claude-plugin/plugin.json +1 -1
- package/CHANGELOG.md +89 -0
- package/README.ja.md +1 -1
- package/README.ko.md +1 -1
- package/README.md +1 -1
- package/README.zh.md +1 -1
- package/dist/core/health-cli.js +11 -12
- package/dist/core/paths.d.ts +1 -1
- package/dist/core/settings-lock.d.ts +7 -1
- package/dist/core/settings-lock.js +19 -4
- package/dist/core/status-cli.d.ts +1 -1
- package/dist/core/trust-layer-intent.d.ts +2 -2
- package/dist/core/uninstall.d.ts +8 -3
- package/dist/core/uninstall.js +51 -17
- package/dist/engine/solution-fixup.js +2 -2
- package/dist/engine/solution-format.js +4 -4
- package/dist/engine/solution-quarantine.js +2 -2
- package/dist/host/install-claude.js +2 -3
- package/dist/host/install-codex.d.ts +39 -6
- package/dist/host/install-codex.js +383 -105
- package/dist/host/managed-marker.d.ts +23 -0
- package/dist/host/managed-marker.js +78 -0
- package/dist/host/opencode/plugin/forgen.d.ts +1 -1
- package/dist/host/uninstall-codex.d.ts +58 -0
- package/dist/host/uninstall-codex.js +246 -0
- package/package.json +5 -6
- package/plugin.json +1 -1
|
@@ -16,11 +16,13 @@ import * as fs from 'node:fs';
|
|
|
16
16
|
import * as os from 'node:os';
|
|
17
17
|
import * as path from 'node:path';
|
|
18
18
|
import { generateHooksJson } from '../hooks/hooks-generator.js';
|
|
19
|
+
import { HOOK_REGISTRY } from '../hooks/hook-registry.js';
|
|
20
|
+
import { hasManagedSkillMarker, isManagedAgentToml } from './managed-marker.js';
|
|
19
21
|
const MCP_MARKER_BEGIN = '# >>> forgen-managed-mcp';
|
|
20
22
|
const MCP_MARKER_END = '# <<< forgen-managed-mcp';
|
|
21
23
|
const NOTIFY_MARKER_BEGIN = '# >>> forgen-managed-notify';
|
|
22
24
|
const NOTIFY_MARKER_END = '# <<< forgen-managed-notify';
|
|
23
|
-
const FORGEN_SKILL_MARKER = '<!-- forgen-managed -->';
|
|
25
|
+
export const FORGEN_SKILL_MARKER = '<!-- forgen-managed -->';
|
|
24
26
|
const AGENTS_MD_BEGIN = '<!-- >>> forgen-managed-rules -->';
|
|
25
27
|
const AGENTS_MD_END = '<!-- <<< forgen-managed-rules -->';
|
|
26
28
|
function resolveCodexHome(opts) {
|
|
@@ -29,18 +31,35 @@ function resolveCodexHome(opts) {
|
|
|
29
31
|
// 0.4.6 fix — pkgRoot match 외에 script-marker fallback 추가.
|
|
30
32
|
// Stale install path (예: 다른 머신에서 install 한 hooks.json 마운트, 또는
|
|
31
33
|
// node_modules path 변경) 의 forgen entry 를 "user entry" 로 오분류 → 중복 누적
|
|
32
|
-
// 하던 버그.
|
|
33
|
-
//
|
|
34
|
-
|
|
34
|
+
// 하던 버그.
|
|
35
|
+
//
|
|
36
|
+
// 0.5.7 (critic): fallback 이 `dist/hooks/<아무이름>.js` 전부와 pkgRoot *부분문자열* 을 forgen 으로 봐서
|
|
37
|
+
// 다른 프로젝트의 `…/dist/hooks/pre-commit.js` 나 `<pkgRoot>-fork/hook.sh` 까지 forgen 소유로 분류했다
|
|
38
|
+
// (uninstall 이 그것을 지운다). 이제: (1) pkgRoot 의 dist/ 아래, (2) codex-adapter 경유, (3) registry 에
|
|
39
|
+
// 있는 forgen 훅 스크립트 이름 — 셋 중 하나일 때만.
|
|
40
|
+
const FORGEN_ADAPTER_RE = /[\\/]dist[\\/]host[\\/]codex-adapter\.js(?![\w-])/;
|
|
41
|
+
const FORGEN_HOOK_SCRIPT_RE = /[\\/]dist[\\/]hooks[\\/]([a-z][a-z0-9-]*)\.js(?![\w-])/;
|
|
42
|
+
const FORGEN_HOOK_SCRIPT_NAMES = new Set(HOOK_REGISTRY.map((h) => h.script.split(' ')[0].replace(/^hooks\//, '').replace(/\.js$/, '')));
|
|
43
|
+
/** 훅 command 문자열이 forgen 소유인가. */
|
|
44
|
+
export function isForgenHookCommand(command, pkgRoot) {
|
|
45
|
+
if (typeof command !== 'string')
|
|
46
|
+
return false;
|
|
47
|
+
if (command.includes(`${pkgRoot}/dist/`) || command.includes(`${pkgRoot}\\dist\\`))
|
|
48
|
+
return true;
|
|
49
|
+
if (FORGEN_ADAPTER_RE.test(command))
|
|
50
|
+
return true;
|
|
51
|
+
const script = command.match(FORGEN_HOOK_SCRIPT_RE)?.[1];
|
|
52
|
+
return script !== undefined && FORGEN_HOOK_SCRIPT_NAMES.has(script);
|
|
53
|
+
}
|
|
35
54
|
function isForgenManagedHook(entry, pkgRoot) {
|
|
36
55
|
if (!entry || typeof entry !== 'object')
|
|
37
56
|
return false;
|
|
38
57
|
const e = entry;
|
|
39
58
|
if (!Array.isArray(e.hooks))
|
|
40
59
|
return false;
|
|
41
|
-
return e.hooks.some((h) =>
|
|
60
|
+
return e.hooks.some((h) => isForgenHookCommand(h.command, pkgRoot));
|
|
42
61
|
}
|
|
43
|
-
function readJsonFile(p) {
|
|
62
|
+
export function readJsonFile(p) {
|
|
44
63
|
try {
|
|
45
64
|
if (!fs.existsSync(p))
|
|
46
65
|
return null;
|
|
@@ -50,32 +69,177 @@ function readJsonFile(p) {
|
|
|
50
69
|
return null;
|
|
51
70
|
}
|
|
52
71
|
}
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
72
|
+
// ── config.toml managed blocks ─────────────────────────────────────────
|
|
73
|
+
//
|
|
74
|
+
// Codex 는 config.toml 을 스스로 다시 쓴다 (toml_edit): `/hooks` 승인은 `[hooks.state."…"]` 테이블을,
|
|
75
|
+
// 모델 변경 등은 root 키를 추가한다. 주석은 "다음 항목의 장식" 으로 취급되므로 forgen 의 마커 주석은
|
|
76
|
+
// **제자리에 있지 않는다**:
|
|
77
|
+
// - END 마커가 파일 끝/첫 테이블 앞에 있으면 Codex 가 쓴 내용이 BEGIN…END 사이에 끼어든다
|
|
78
|
+
// (0.5.6 critic — 블록을 통째로 교체하면 훅 신뢰 22건과 사용자 설정이 사라졌다).
|
|
79
|
+
// - Codex 0.160 은 테이블을 재배치한다: BEGIN 은 forgen 테이블과 함께 파일 끝으로 가고 END 는 앞쪽에
|
|
80
|
+
// 고아로 남는다 (2026-10-02 실머신).
|
|
81
|
+
// - `codex mcp add <다른 서버>` 는 mcp_servers 를 통째로 다시 써서 BEGIN 마커를 **없앤다** (0.5.8 critic).
|
|
82
|
+
// 그래서 마커를 범위로도, 유일한 소유 근거로도 쓰지 않는다. 범위는 TOML 구조(테이블 헤더 ~ 다음 헤더, notify 한 줄),
|
|
83
|
+
// 소유는 "마커가 있거나, forgen 만 쓰는 내용 시그니처가 있다" 로 판정한다. forgen 이 쓴 줄만 고치고 나머지는 그대로 둔다.
|
|
84
|
+
const FORGEN_SERVER_KEY = `(?:forgen-compound|"forgen-compound"|'forgen-compound')`;
|
|
85
|
+
/** `[mcp_servers.forgen-compound]` — 따옴표/공백 표기도 같은 테이블이다 */
|
|
86
|
+
const MCP_HEADER_RE = new RegExp(`^\\[\\s*mcp_servers\\s*\\.\\s*${FORGEN_SERVER_KEY}\\s*\\]\\s*(#.*)?$`);
|
|
87
|
+
/** forgen 서버의 하위 테이블 (`[mcp_servers.forgen-compound.env]`, `[[…things]]`) */
|
|
88
|
+
const MCP_SUBTABLE_RE = new RegExp(`^\\[{1,2}\\s*mcp_servers\\s*\\.\\s*${FORGEN_SERVER_KEY}\\s*\\.`);
|
|
89
|
+
/** 같은 서버를 헤더가 아닌 형태로 정의한 줄 (inline table / dotted key) — 손으로 쓴 설정 */
|
|
90
|
+
const MCP_ALT_FORM_RE = new RegExp(`^\\s*(?:mcp_servers\\s*\\.\\s*)?${FORGEN_SERVER_KEY}\\s*(?:=|\\.)`);
|
|
91
|
+
/** forgen 이 쓰는 args 의 시그니처: `…/dist/mcp/server.js` + `--host=codex` (이 플래그는 forgen 전용) */
|
|
92
|
+
const MCP_SIGNATURE_RE = /[\\/]dist[\\/]mcp[\\/]server\.js["'][\s\S]*--host=codex/;
|
|
93
|
+
const MCP_TABLE_HEADER = '[mcp_servers.forgen-compound]';
|
|
94
|
+
const MCP_OWN_KEY_RE = /^\s*(command|args)\s*=/;
|
|
95
|
+
const TOML_KEY = `(?:[A-Za-z0-9_-]+|"[^"]*"|'[^']*')`;
|
|
96
|
+
/** 테이블 헤더 한 줄: `[a.b]`, `[[a.b]]`, `[a."b c"] # 주석`. 배열 값의 한 줄(`["x", "y"],`)과 구분한다. */
|
|
97
|
+
const TABLE_HEADER_RE = new RegExp(`^\\s*\\[\\[?\\s*${TOML_KEY}(?:\\s*\\.\\s*${TOML_KEY})*\\s*\\]\\]?\\s*(#.*)?$`);
|
|
98
|
+
/**
|
|
99
|
+
* 줄을 구조적으로 분류한다. 여러 줄 문자열(`"""`/`'''`)이나 여러 줄 배열의 이어지는 줄(`cont`)은 값의 일부이므로
|
|
100
|
+
* 그 안의 `[` 로 시작하는 줄을 헤더로, 마커처럼 보이는 줄을 마커로 오인하면 안 된다 (0.5.8 critic m6).
|
|
101
|
+
* 완전한 TOML 파서는 아니다 — 문자열 밖의 대괄호 균형과 삼중따옴표 열림/닫힘만 추적한다.
|
|
102
|
+
*/
|
|
103
|
+
function classifyLines(lines) {
|
|
104
|
+
const kinds = [];
|
|
105
|
+
let inString = null;
|
|
106
|
+
let depth = 0;
|
|
107
|
+
const stripInline = (l) => l.replace(/"(?:[^"\\\\]|\\\\.)*"|'[^']*'/g, '""').replace(/#.*$/, '');
|
|
108
|
+
const balance = (l) => (l.match(/\[/g)?.length ?? 0) - (l.match(/\]/g)?.length ?? 0);
|
|
109
|
+
for (const raw of lines) {
|
|
110
|
+
const line = raw.replace(/\r$/, '');
|
|
111
|
+
if (inString !== null) {
|
|
112
|
+
kinds.push('cont');
|
|
113
|
+
if (line.includes(inString))
|
|
114
|
+
inString = null;
|
|
115
|
+
continue;
|
|
116
|
+
}
|
|
117
|
+
if (depth > 0) {
|
|
118
|
+
kinds.push('cont');
|
|
119
|
+
depth = Math.max(0, depth + balance(stripInline(line)));
|
|
120
|
+
continue;
|
|
121
|
+
}
|
|
122
|
+
if (TABLE_HEADER_RE.test(line)) {
|
|
123
|
+
kinds.push('header');
|
|
124
|
+
continue;
|
|
125
|
+
}
|
|
126
|
+
kinds.push('other');
|
|
127
|
+
const t = line.trim();
|
|
128
|
+
if (t === '' || t.startsWith('#'))
|
|
129
|
+
continue;
|
|
130
|
+
const triple = line.match(/"""|'''/g) ?? [];
|
|
131
|
+
if (triple.length % 2 === 1) {
|
|
132
|
+
inString = triple[triple.length - 1];
|
|
133
|
+
continue;
|
|
134
|
+
}
|
|
135
|
+
depth = Math.max(0, balance(stripInline(line)));
|
|
136
|
+
}
|
|
137
|
+
return kinds;
|
|
71
138
|
}
|
|
72
139
|
/** BOM 은 파일 맨 앞에 있어야 한다 — 떼어 두었다가 결과 맨 앞에 다시 붙인다. 줄 끝(CRLF)도 보존. */
|
|
73
140
|
function tomlShape(toml) {
|
|
74
141
|
const bom = toml.startsWith('') ? '' : '';
|
|
75
142
|
return { bom, body: toml.slice(bom.length), cr: /\r\n/.test(toml) ? '\r' : '' };
|
|
76
143
|
}
|
|
77
|
-
|
|
78
|
-
|
|
144
|
+
/** 줄 배열 → 파일 내용. CRLF 파일의 마지막 줄이 `\r` 로만 끝나면(개행 없음) Codex 가 거부하므로 `\n` 을 붙인다. */
|
|
145
|
+
function joinToml(bom, lines) {
|
|
146
|
+
const text = lines.join('\n');
|
|
147
|
+
return bom + (text.endsWith('\r') ? `${text}\n` : text);
|
|
148
|
+
}
|
|
149
|
+
/**
|
|
150
|
+
* 지정한 줄들을 지운다. 지운 자리 양옆이 모두 빈 줄(또는 파일 시작)이면 빈 줄 하나도 함께 지워
|
|
151
|
+
* 제거/재설치를 반복해도 빈 줄이 쌓이지 않게 한다. 지우지 않은 구간의 서식은 건드리지 않는다.
|
|
152
|
+
*/
|
|
153
|
+
function deleteLines(lines, drop) {
|
|
154
|
+
const out = [];
|
|
155
|
+
let justDropped = false;
|
|
156
|
+
for (let i = 0; i < lines.length; i += 1) {
|
|
157
|
+
if (drop.has(i)) {
|
|
158
|
+
justDropped = true;
|
|
159
|
+
continue;
|
|
160
|
+
}
|
|
161
|
+
const blank = lines[i].trim() === '';
|
|
162
|
+
if (justDropped && blank && (out.length === 0 || out[out.length - 1].trim() === '')) {
|
|
163
|
+
// 마지막 요소('' = 파일 끝 개행)는 남기고 앞의 빈 줄을 대신 버린다. CRLF 파일에서 앞의 빈 줄은 '\r' 이라
|
|
164
|
+
// 그것을 마지막에 남기면 bare CR 로 끝나 Codex 가 로드하지 못한다 (0.5.8 critic C1).
|
|
165
|
+
if (i === lines.length - 1) {
|
|
166
|
+
if (out.length > 0)
|
|
167
|
+
out[out.length - 1] = lines[i];
|
|
168
|
+
else
|
|
169
|
+
out.push(lines[i]);
|
|
170
|
+
}
|
|
171
|
+
continue;
|
|
172
|
+
}
|
|
173
|
+
justDropped = false;
|
|
174
|
+
out.push(lines[i]);
|
|
175
|
+
}
|
|
176
|
+
return out;
|
|
177
|
+
}
|
|
178
|
+
/**
|
|
179
|
+
* 제거 뒤 파일 끝을 정리한다: 끝의 빈 줄은 걷어내고, 내용이 있으면 개행 하나로 끝나게 한다.
|
|
180
|
+
* 설치↔제거를 반복해도 결과가 같아지게 하기 위함 (설치는 블록을 개행으로 끝내므로).
|
|
181
|
+
*/
|
|
182
|
+
function trimEofBlankLines(lines) {
|
|
183
|
+
const out = [...lines];
|
|
184
|
+
while (out.length > 1 && out[out.length - 1] === '' && out[out.length - 2].trim() === '')
|
|
185
|
+
out.splice(out.length - 2, 1);
|
|
186
|
+
if (out.length > 0 && out[out.length - 1] !== '' && out.some((l) => l.trim() !== '')) {
|
|
187
|
+
// CRLF 파일이면 마지막 줄도 CRLF 로 끝낸다
|
|
188
|
+
if (out.some((l) => l.endsWith('\r')) && !out[out.length - 1].endsWith('\r'))
|
|
189
|
+
out[out.length - 1] += '\r';
|
|
190
|
+
out.push('');
|
|
191
|
+
}
|
|
192
|
+
return out;
|
|
193
|
+
}
|
|
194
|
+
/** 테이블 본문의 끝: 다음 헤더/마커 직전. 끝의 빈 줄과 주석은 *다음 항목의 장식* 이므로 본문이 아니다. */
|
|
195
|
+
function tableBodyEnd(lines, kinds, header, isMarker) {
|
|
196
|
+
let end = header + 1;
|
|
197
|
+
while (end < lines.length) {
|
|
198
|
+
if (kinds[end] === 'header' || (kinds[end] === 'other' && isMarker(lines[end].trim())))
|
|
199
|
+
break;
|
|
200
|
+
end += 1;
|
|
201
|
+
}
|
|
202
|
+
while (end > header + 1) {
|
|
203
|
+
const t = lines[end - 1].trim();
|
|
204
|
+
if (kinds[end - 1] === 'other' && (t === '' || t.startsWith('#')))
|
|
205
|
+
end -= 1;
|
|
206
|
+
else
|
|
207
|
+
break;
|
|
208
|
+
}
|
|
209
|
+
return end;
|
|
210
|
+
}
|
|
211
|
+
const isMcpMarker = (t) => t === MCP_MARKER_BEGIN || t === MCP_MARKER_END;
|
|
212
|
+
function locateMcp(lines) {
|
|
213
|
+
const kinds = classifyLines(lines);
|
|
214
|
+
const beginIdx = [];
|
|
215
|
+
const endIdx = [];
|
|
216
|
+
let header = -1;
|
|
217
|
+
lines.forEach((l, i) => {
|
|
218
|
+
if (kinds[i] === 'cont')
|
|
219
|
+
return; // 여러 줄 값 안의 텍스트
|
|
220
|
+
const t = l.trim();
|
|
221
|
+
if (t === MCP_MARKER_BEGIN)
|
|
222
|
+
beginIdx.push(i);
|
|
223
|
+
else if (t === MCP_MARKER_END)
|
|
224
|
+
endIdx.push(i);
|
|
225
|
+
else if (header === -1 && kinds[i] === 'header' && MCP_HEADER_RE.test(t))
|
|
226
|
+
header = i;
|
|
227
|
+
});
|
|
228
|
+
const bodyEnd = header === -1 ? 0 : tableBodyEnd(lines, kinds, header, isMcpMarker);
|
|
229
|
+
// 헤더 바로 위(빈 줄과 forgen 의 다른 마커/주석은 건너뛴다)에 BEGIN 이 있는가
|
|
230
|
+
let above = header - 1;
|
|
231
|
+
while (above >= 0) {
|
|
232
|
+
const t = lines[above].trim();
|
|
233
|
+
if (t === '' || t === MCP_MARKER_END || isNotifyMarker(t) || isNotifyOwnComment(t))
|
|
234
|
+
above -= 1;
|
|
235
|
+
else
|
|
236
|
+
break;
|
|
237
|
+
}
|
|
238
|
+
const beginAdjacent = above >= 0 && beginIdx.includes(above);
|
|
239
|
+
const owned = header !== -1
|
|
240
|
+
&& (beginAdjacent || MCP_SIGNATURE_RE.test(lines.slice(header + 1, bodyEnd).join('\n')));
|
|
241
|
+
return { kinds, beginIdx, endIdx, header, bodyEnd, owned };
|
|
242
|
+
}
|
|
79
243
|
function upsertMcpBlock(currentToml, pkgRoot) {
|
|
80
244
|
const { bom, body, cr } = tomlShape(currentToml);
|
|
81
245
|
const serverPath = path.join(pkgRoot, 'dist', 'mcp', 'server.js');
|
|
@@ -84,37 +248,73 @@ function upsertMcpBlock(currentToml, pkgRoot) {
|
|
|
84
248
|
// correction-record evidence 박제 시 host:"codex" 로 정확히 태깅되게 한다 (spec §10-5).
|
|
85
249
|
const ownKeys = ['command = "node"', `args = [${JSON.stringify(serverPath)}, "--host=codex"]`];
|
|
86
250
|
const lines = body.split('\n');
|
|
87
|
-
const
|
|
88
|
-
if (
|
|
89
|
-
//
|
|
90
|
-
|
|
251
|
+
const at = locateMcp(lines);
|
|
252
|
+
if (at.header === -1) {
|
|
253
|
+
// 헤더가 아닌 형태(inline table / dotted key)로 같은 서버가 정의돼 있으면 append 하지 않는다 —
|
|
254
|
+
// 중복 정의는 Codex 가 config 를 로드하지 못하게 한다.
|
|
255
|
+
if (lines.some((l, i) => at.kinds[i] === 'other' && MCP_ALT_FORM_RE.test(l)))
|
|
91
256
|
return { content: currentToml, alreadyPresent: true };
|
|
257
|
+
// 테이블이 없다 → 끝에 새 블록. 고아 마커(테이블만 지워진 흔적)는 걷어낸다.
|
|
258
|
+
const cleaned = deleteLines(lines, new Set([...at.beginIdx, ...at.endIdx])).join('\n');
|
|
92
259
|
const block = [MCP_MARKER_BEGIN, MCP_TABLE_HEADER, ...ownKeys, MCP_MARKER_END].map((l) => l + cr).join('\n');
|
|
93
|
-
const trimmed =
|
|
260
|
+
const trimmed = cleaned.replace(/\s+$/, '');
|
|
94
261
|
const sep = trimmed.length > 0 ? `${cr}\n${cr}\n` : '';
|
|
95
262
|
return { content: `${bom}${trimmed}${sep}${block}\n`, alreadyPresent: false };
|
|
96
263
|
}
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
const
|
|
114
|
-
const
|
|
115
|
-
const
|
|
264
|
+
// 마커도 forgen 시그니처도 없는 같은 이름의 테이블 = 사용자가 직접 관리. 건드리지도, append 하지도 않는다.
|
|
265
|
+
if (!at.owned)
|
|
266
|
+
return { content: currentToml, alreadyPresent: true };
|
|
267
|
+
const tableBody = lines.slice(at.header + 1, at.bodyEnd);
|
|
268
|
+
// 손으로 고쳐 여러 줄이 된 command/args 는 안전하게 다시 쓸 수 없다 — 그대로 둔다.
|
|
269
|
+
const own = tableBody.filter((l) => MCP_OWN_KEY_RE.test(l));
|
|
270
|
+
if (own.some((l) => !/(["'\]])\s*(#.*)?$/.test(l.trim())))
|
|
271
|
+
return { content: currentToml, alreadyPresent: true };
|
|
272
|
+
// 사용자가 Codex 로 이 서버에 붙인 설정(enabled, startup_timeout_sec …)은 테이블 안에 유지.
|
|
273
|
+
const extraKeys = tableBody.filter((l) => l.trim() !== '' && !MCP_OWN_KEY_RE.test(l)).map((l) => l.replace(/\r$/, ''));
|
|
274
|
+
const block = [MCP_MARKER_BEGIN, lines[at.header].replace(/\r$/, ''), ...ownKeys, ...extraKeys, MCP_MARKER_END].map((l) => l + cr);
|
|
275
|
+
// 마커는 어디에 있든 전부 걷어내고, 테이블 바로 위/아래에 다시 둔다 (재배치/소실된 마커 정규화).
|
|
276
|
+
const SENTINEL = '\u0000forgen-mcp-block\u0000';
|
|
277
|
+
const drop = new Set([...at.beginIdx, ...at.endIdx]);
|
|
278
|
+
for (let i = at.header + 1; i < at.bodyEnd; i += 1)
|
|
279
|
+
drop.add(i);
|
|
280
|
+
const kept = deleteLines(lines.map((l, i) => (i === at.header ? SENTINEL : l)), drop);
|
|
281
|
+
const pos = kept.indexOf(SENTINEL);
|
|
282
|
+
const next = kept[pos + 1];
|
|
283
|
+
// 블록 뒤에 다른 내용이 바로 붙으면 빈 줄로 구분
|
|
284
|
+
const needsGap = next !== undefined && next.trim() !== '';
|
|
285
|
+
kept.splice(pos, 1, ...block, ...(needsGap ? [cr] : []));
|
|
286
|
+
const content = joinToml(bom, kept);
|
|
116
287
|
return { content, alreadyPresent: content === currentToml };
|
|
117
288
|
}
|
|
289
|
+
/**
|
|
290
|
+
* forgen MCP 블록 제거 (uninstall, ADR-016 D4). forgen 테이블(본문 + 하위 테이블)과 마커 줄만 걷어낸다.
|
|
291
|
+
* 마커도 시그니처도 없는 같은 이름의 테이블(사용자 관리)은 건드리지 않는다.
|
|
292
|
+
* `removed` 는 테이블을 실제로 지웠을 때만 true — 고아 마커만 치운 경우는 false.
|
|
293
|
+
*/
|
|
294
|
+
export function removeMcpBlock(currentToml) {
|
|
295
|
+
const { bom, body } = tomlShape(currentToml);
|
|
296
|
+
const lines = body.split('\n');
|
|
297
|
+
const at = locateMcp(lines);
|
|
298
|
+
if (at.beginIdx.length === 0 && at.endIdx.length === 0 && !at.owned)
|
|
299
|
+
return { content: currentToml, removed: false };
|
|
300
|
+
const drop = new Set([...at.beginIdx, ...at.endIdx]);
|
|
301
|
+
if (at.owned) {
|
|
302
|
+
for (let i = at.header; i < at.bodyEnd; i += 1)
|
|
303
|
+
drop.add(i);
|
|
304
|
+
// 하위 테이블은 Codex 가 어디로 옮겼든 함께 제거 (command 없는 서버 정의가 남지 않게).
|
|
305
|
+
// 각 하위 테이블도 끝의 빈 줄/주석(다음 항목의 장식)은 남긴다.
|
|
306
|
+
lines.forEach((l, i) => {
|
|
307
|
+
if (at.kinds[i] !== 'header' || !MCP_SUBTABLE_RE.test(l.trim()))
|
|
308
|
+
return;
|
|
309
|
+
for (let k = i; k < tableBodyEnd(lines, at.kinds, i, isMcpMarker); k += 1)
|
|
310
|
+
drop.add(k);
|
|
311
|
+
});
|
|
312
|
+
}
|
|
313
|
+
const out = trimEofBlankLines(deleteLines(lines, drop));
|
|
314
|
+
while (out.length > 1 && out[0].trim() === '')
|
|
315
|
+
out.shift(); // 파일 맨 앞 빈 줄
|
|
316
|
+
return { content: joinToml(bom, out), removed: at.owned };
|
|
317
|
+
}
|
|
118
318
|
// ── ADR-016 D1: notify 폴백 (config.toml top-level `notify`) ───────────
|
|
119
319
|
/** forgen notify 바이너리 argv 접두 (`--` 뒤는 사용자가 수동으로 붙인 체인 프로그램). */
|
|
120
320
|
function forgenNotifyArgv(pkgRoot) {
|
|
@@ -124,76 +324,128 @@ const NOTIFY_OWN_COMMENTS = [
|
|
|
124
324
|
'# forgen turn-complete fallback (ADR-016): works even while forgen hooks are untrusted.',
|
|
125
325
|
'# To chain your own notifier, append: "--", "<program>", "<args…>" (kept across re-install).',
|
|
126
326
|
];
|
|
327
|
+
const isNotifyOwnComment = (t) => t.startsWith('# forgen turn-complete fallback') || t.startsWith('# To chain your own notifier');
|
|
328
|
+
const isNotifyMarker = (t) => t === NOTIFY_MARKER_BEGIN || t === NOTIFY_MARKER_END;
|
|
127
329
|
/** `notify`, `"notify"`, `'notify'` 키 (dotted `notify.x` 포함) — 어느 것이든 forgen 의 notify 와 충돌한다. */
|
|
128
330
|
const NOTIFY_KEY_RE = /^[ \t]*(?:notify|"notify"|'notify')[ \t]*[.=]/;
|
|
331
|
+
/** forgen notify argv 의 시그니처: `…/dist/host/codex-notify.js` */
|
|
332
|
+
const NOTIFY_SIGNATURE_RE = /[\\/]dist[\\/]host[\\/]codex-notify\.js$/;
|
|
333
|
+
/** `notify = ["a","b"]` 한 줄을 argv 로. 여러 줄 배열·홑따옴표·뒤 주석 등 JSON 으로 못 읽으면 null. */
|
|
334
|
+
function parseNotifyArgvLine(line) {
|
|
335
|
+
const value = line.trim().match(/^(?:notify|"notify"|'notify')[ \t]*=[ \t]*(\[.*\])$/)?.[1];
|
|
336
|
+
if (!value)
|
|
337
|
+
return null;
|
|
338
|
+
try {
|
|
339
|
+
const parsed = JSON.parse(value);
|
|
340
|
+
return Array.isArray(parsed) && parsed.every((a) => typeof a === 'string') ? parsed : null;
|
|
341
|
+
}
|
|
342
|
+
catch {
|
|
343
|
+
return null;
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
/**
|
|
347
|
+
* forgen notify 줄은 **argv 시그니처**(`…/dist/host/codex-notify.js`)로 찾는다. 마커는 Codex 가 옮기거나
|
|
348
|
+
* 없앨 수 있고, Codex 가 `notify` 값을 직접 바꾸면 forgen 블록 안에 사용자의 값이 들어앉기도 한다
|
|
349
|
+
* (그때 그 줄은 forgen 것이 아니다 — 마커/주석만 걷어내고 값은 보존).
|
|
350
|
+
*/
|
|
129
351
|
function parseNotifyBlock(lines) {
|
|
130
|
-
const
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
352
|
+
const kinds = classifyLines(lines);
|
|
353
|
+
const own = new Set();
|
|
354
|
+
lines.forEach((l, i) => {
|
|
355
|
+
if (kinds[i] === 'cont')
|
|
356
|
+
return; // 여러 줄 값 안의 텍스트는 건드리지 않는다
|
|
357
|
+
const t = l.trim();
|
|
358
|
+
if (isNotifyMarker(t) || isNotifyOwnComment(t))
|
|
359
|
+
own.add(i);
|
|
360
|
+
});
|
|
134
361
|
let argv = null;
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
362
|
+
const forgenIdx = lines.findIndex((l, i) => {
|
|
363
|
+
if (kinds[i] !== 'other' || !NOTIFY_KEY_RE.test(l))
|
|
364
|
+
return false;
|
|
365
|
+
const parsed = parseNotifyArgvLine(l);
|
|
366
|
+
if (!parsed?.some((a) => NOTIFY_SIGNATURE_RE.test(a)))
|
|
367
|
+
return false;
|
|
368
|
+
argv = parsed;
|
|
369
|
+
return true;
|
|
370
|
+
});
|
|
371
|
+
if (forgenIdx !== -1)
|
|
372
|
+
own.add(forgenIdx);
|
|
373
|
+
// BEGIN 바로 아래(forgen 주석만 사이)의 notify 키가 한 줄 JSON 이 아니면 손편집된 블록 — 호출부가 그대로 둔다.
|
|
374
|
+
let custom = false;
|
|
375
|
+
const begin = lines.findIndex((l) => l.trim() === NOTIFY_MARKER_BEGIN);
|
|
376
|
+
if (begin !== -1) {
|
|
377
|
+
let i = begin + 1;
|
|
378
|
+
while (i < lines.length && isNotifyOwnComment(lines[i].trim()))
|
|
379
|
+
i += 1;
|
|
380
|
+
if (i < lines.length && NOTIFY_KEY_RE.test(lines[i]) && parseNotifyArgvLine(lines[i]) === null)
|
|
381
|
+
custom = true;
|
|
144
382
|
}
|
|
145
|
-
|
|
146
|
-
|
|
383
|
+
return {
|
|
384
|
+
touched: own.size > 0,
|
|
385
|
+
hasForgenLine: forgenIdx !== -1,
|
|
386
|
+
argv,
|
|
387
|
+
custom,
|
|
388
|
+
rest: deleteLines(lines, own),
|
|
389
|
+
userNotifyKey: lines.some((l, i) => !own.has(i) && kinds[i] === 'other' && NOTIFY_KEY_RE.test(l)),
|
|
390
|
+
};
|
|
147
391
|
}
|
|
148
392
|
/**
|
|
149
393
|
* config.toml 에 forgen notify 블록을 upsert.
|
|
150
394
|
*
|
|
151
395
|
* - Codex 의 `notify` 는 top-level 단일 argv 다. 사용자가 이미 정의했으면 **건드리지 않는다** — 그리고
|
|
152
|
-
* forgen
|
|
396
|
+
* forgen 줄이 남아 있으면 제거한다 (중복 키 = config.toml 파싱 실패 → Codex 기동 불가).
|
|
153
397
|
* - top-level 키는 첫 테이블 헤더 앞에 와야 하므로 블록은 항상 파일 최상단(BOM 뒤)에 둔다.
|
|
154
|
-
* - 사용자가 forgen
|
|
398
|
+
* - 사용자가 forgen argv 뒤에 `"--", "<prog>", …` 로 자기 notifier 를 체인해 뒀으면 그 꼬리를 보존.
|
|
155
399
|
* 블록의 notify 줄을 한 줄 JSON 으로 읽을 수 없으면(여러 줄 배열 등 손편집) 아무것도 바꾸지 않는다.
|
|
156
|
-
* -
|
|
400
|
+
* - forgen 이 쓴 줄 외에는 원래 순서 그대로 둔다 (Codex 가 사이에 끼워 넣은 root 키 포함).
|
|
157
401
|
*/
|
|
158
402
|
export function upsertNotifyBlock(currentToml, pkgRoot) {
|
|
159
403
|
const { bom, body, cr } = tomlShape(currentToml);
|
|
160
404
|
const lines = body.split('\n');
|
|
161
|
-
const {
|
|
162
|
-
|
|
163
|
-
|
|
405
|
+
const { touched, rest, argv: existingArgv, custom, userNotifyKey } = parseNotifyBlock(lines);
|
|
406
|
+
if (custom)
|
|
407
|
+
return { content: currentToml, status: 'custom-block' };
|
|
408
|
+
// 보수적 판정: forgen 줄 밖 어디든 `notify =` 줄이 있으면 사용자 정의로 본다 (프로필 테이블 안이어도 skip —
|
|
164
409
|
// 폴백을 못 넣는 쪽이 config 를 깨뜨리는 쪽보다 낫다).
|
|
165
|
-
if (
|
|
166
|
-
|
|
167
|
-
return { content: currentToml, status: 'user-defined' };
|
|
168
|
-
return { content: bom + [...span.before, ...foreign, ...span.after].join('\n'), status: 'user-defined' };
|
|
410
|
+
if (userNotifyKey) {
|
|
411
|
+
return { content: touched ? joinToml(bom, rest) : currentToml, status: 'user-defined' };
|
|
169
412
|
}
|
|
170
|
-
if (existingArgv === 'unparseable')
|
|
171
|
-
return { content: currentToml, status: 'custom-block' };
|
|
172
413
|
const sep = existingArgv ? existingArgv.indexOf('--') : -1;
|
|
173
414
|
const chainTail = existingArgv && sep !== -1 ? existingArgv.slice(sep) : [];
|
|
174
415
|
const argv = [...forgenNotifyArgv(pkgRoot), ...chainTail];
|
|
175
416
|
const block = [NOTIFY_MARKER_BEGIN, ...NOTIFY_OWN_COMMENTS, `notify = ${JSON.stringify(argv)}`, NOTIFY_MARKER_END].map((l) => l + cr);
|
|
176
|
-
const rest = span ? [...span.before, ...foreign, ...span.after] : lines;
|
|
177
417
|
let start = 0;
|
|
178
418
|
while (start < rest.length && rest[start].trim() === '')
|
|
179
419
|
start += 1;
|
|
180
420
|
const tail = rest.slice(start);
|
|
181
|
-
const content = bom
|
|
421
|
+
const content = joinToml(bom, tail.length > 0 ? [...block, cr, ...tail] : [...block, '']);
|
|
182
422
|
return { content, status: content === currentToml ? 'already-present' : 'installed' };
|
|
183
423
|
}
|
|
184
|
-
/**
|
|
424
|
+
/**
|
|
425
|
+
* forgen notify 블록 제거 (`--no-notify`, uninstall).
|
|
426
|
+
*
|
|
427
|
+
* - 사용자가 블록의 notify 줄을 여러 줄 배열 등으로 손편집했으면(`custom`) **건드리지 않는다** — 첫 줄만
|
|
428
|
+
* 지우면 남은 줄이 깨진 TOML 이 되어 Codex 가 기동하지 못한다 (critic 2026-10-02).
|
|
429
|
+
* - `"--"` 뒤에 사용자가 체인해 둔 자기 notifier 가 있으면 그 argv 만으로 `notify` 를 되돌려 놓는다.
|
|
430
|
+
* - `removed` 는 forgen notify 줄을 실제로 지웠을 때만 true. 고아 마커/주석만 치운 경우는 false
|
|
431
|
+
* (내용은 정리된 것을 돌려준다).
|
|
432
|
+
*/
|
|
185
433
|
export function removeNotifyBlock(currentToml) {
|
|
186
|
-
const { bom, body } = tomlShape(currentToml);
|
|
187
|
-
const {
|
|
188
|
-
if (
|
|
189
|
-
return { content: currentToml, removed: false };
|
|
190
|
-
|
|
434
|
+
const { bom, body, cr } = tomlShape(currentToml);
|
|
435
|
+
const { touched, hasForgenLine, rest, argv, custom } = parseNotifyBlock(body.split('\n'));
|
|
436
|
+
if (custom)
|
|
437
|
+
return { content: currentToml, removed: false, custom: true, restoredChain: [] };
|
|
438
|
+
if (!touched)
|
|
439
|
+
return { content: currentToml, removed: false, custom: false, restoredChain: [] };
|
|
440
|
+
const sep = argv ? argv.indexOf('--') : -1;
|
|
441
|
+
const restoredChain = argv && sep !== -1 ? argv.slice(sep + 1) : [];
|
|
191
442
|
let start = 0;
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
443
|
+
while (start < rest.length - 1 && rest[start].trim() === '')
|
|
444
|
+
start += 1;
|
|
445
|
+
const tail = trimEofBlankLines(rest.slice(start));
|
|
446
|
+
const hasContent = tail.some((l) => l.trim() !== '');
|
|
447
|
+
const restored = restoredChain.length > 0 ? [`notify = ${JSON.stringify(restoredChain)}${cr}`, ...(hasContent ? [cr] : [])] : [];
|
|
448
|
+
return { content: joinToml(bom, [...restored, ...tail]), removed: hasForgenLine, custom: false, restoredChain };
|
|
197
449
|
}
|
|
198
450
|
export function planCodexInstall(opts) {
|
|
199
451
|
const codexHome = resolveCodexHome(opts);
|
|
@@ -223,7 +475,11 @@ export function planCodexInstall(opts) {
|
|
|
223
475
|
const out = [];
|
|
224
476
|
let inserted = false;
|
|
225
477
|
for (const group of existingGroups) {
|
|
226
|
-
|
|
478
|
+
// 빈 그룹(`{"hooks": []}`)은 uninstall 이 다른 도구 훅의 trust 인덱스를 지키려고 남긴 자리표시다 —
|
|
479
|
+
// 재설치 시 그 자리를 다시 채워 forgen 훅이 원래 인덱스(= 이미 승인된 trust 키)로 돌아가게 한다.
|
|
480
|
+
const isPlaceholder = Array.isArray(group?.hooks)
|
|
481
|
+
&& (group.hooks.length === 0);
|
|
482
|
+
if (isForgenManagedHook(group, opts.pkgRoot) || isPlaceholder) {
|
|
227
483
|
if (!inserted) {
|
|
228
484
|
out.push(...generatedGroups);
|
|
229
485
|
inserted = true;
|
|
@@ -271,10 +527,11 @@ export function planCodexInstall(opts) {
|
|
|
271
527
|
else {
|
|
272
528
|
// opt-out 은 "더 이상 등록하지 않음" 이 아니라 "없앰" 이어야 한다 (이전 설치의 블록이 남지 않게).
|
|
273
529
|
const r = removeNotifyBlock(configToml);
|
|
274
|
-
|
|
530
|
+
configToml = r.content; // 고아 마커만 정리된 경우도 반영
|
|
531
|
+
if (r.removed)
|
|
275
532
|
notify = 'removed';
|
|
276
|
-
|
|
277
|
-
|
|
533
|
+
else if (r.custom)
|
|
534
|
+
notify = 'custom-block';
|
|
278
535
|
}
|
|
279
536
|
const configTomlToWrite = configToml !== currentToml ? configToml : null;
|
|
280
537
|
// 5) 실제 쓰기 (dryRun 이면 skip) — hooks.json + config.toml
|
|
@@ -483,9 +740,7 @@ export function auditCodexHookTrust(opts) {
|
|
|
483
740
|
if (!Array.isArray(g.hooks))
|
|
484
741
|
return;
|
|
485
742
|
g.hooks.forEach((h, hi) => {
|
|
486
|
-
|
|
487
|
-
(h.command.includes(opts.pkgRoot) || FORGEN_HOOK_SCRIPT_MARKER.test(h.command));
|
|
488
|
-
if (!isForgen)
|
|
743
|
+
if (!isForgenHookCommand(h.command, opts.pkgRoot))
|
|
489
744
|
return;
|
|
490
745
|
const key = `${codexHookEventKey(event)}:${gi}:${hi}`;
|
|
491
746
|
if (!CODEX_SUPPORTED_HOOK_EVENTS.has(event)) {
|
|
@@ -513,8 +768,8 @@ export function auditCodexHookTrust(opts) {
|
|
|
513
768
|
return { total, trusted, untrusted, modified, disabled, ignoredByCodex, noStateRecorded: state.size === 0 };
|
|
514
769
|
}
|
|
515
770
|
// ── ADR-014 D2: Codex custom agents (~/.codex/agents/ch-*.toml) ──────
|
|
516
|
-
const AGENT_TOML_MARKER = '# forgen-managed';
|
|
517
|
-
const AGENT_NAME_PREFIX = 'ch-';
|
|
771
|
+
export const AGENT_TOML_MARKER = '# forgen-managed';
|
|
772
|
+
export const AGENT_NAME_PREFIX = 'ch-';
|
|
518
773
|
function parseAgentMarkdown(raw) {
|
|
519
774
|
const fm = raw.match(/^---\n([\s\S]*?)\n---\n([\s\S]*)$/);
|
|
520
775
|
if (!fm)
|
|
@@ -615,7 +870,7 @@ function installCodexAgents(opts) {
|
|
|
615
870
|
const st = fs.lstatSync(p);
|
|
616
871
|
if (st.isSymbolicLink())
|
|
617
872
|
return true; // 사용자 심링크 (dangling 포함) — 건드리지 않음
|
|
618
|
-
return !fs.readFileSync(p, 'utf-8')
|
|
873
|
+
return !isManagedAgentToml(fs.readFileSync(p, 'utf-8'));
|
|
619
874
|
}
|
|
620
875
|
catch {
|
|
621
876
|
return false; // 없음
|
|
@@ -638,8 +893,7 @@ function installCodexAgents(opts) {
|
|
|
638
893
|
try {
|
|
639
894
|
if (fs.lstatSync(p).isSymbolicLink())
|
|
640
895
|
continue;
|
|
641
|
-
|
|
642
|
-
if (!head.startsWith(AGENT_TOML_MARKER))
|
|
896
|
+
if (!isManagedAgentToml(fs.readFileSync(p, 'utf-8')))
|
|
643
897
|
continue;
|
|
644
898
|
fs.unlinkSync(p);
|
|
645
899
|
removed += 1;
|
|
@@ -664,7 +918,7 @@ function installCodexAgents(opts) {
|
|
|
664
918
|
// ── v0.4.9: dev-guide skills → ~/.codex/skills ────────────────────────
|
|
665
919
|
// dev-guide prefix pattern: forgen-<stack>-<skill> (e.g. forgen-react-fe-build)
|
|
666
920
|
// 반드시 stack 이 react|vue|node|go 인 것만 매칭 — forgen 자체 commands 보존
|
|
667
|
-
const DEV_GUIDE_SKILL_PATTERN = /^forgen-(react|vue|node|go)-/;
|
|
921
|
+
export const DEV_GUIDE_SKILL_PATTERN = /^forgen-(react|vue|node|go)-/;
|
|
668
922
|
function installDevGuideSkillsToCodex(opts) {
|
|
669
923
|
const devGuideRoot = path.join(opts.pkgRoot, 'assets', 'dev-guide');
|
|
670
924
|
const codexSkillsDir = path.join(opts.codexHome, 'skills');
|
|
@@ -764,8 +1018,7 @@ function installCodexSkills(opts) {
|
|
|
764
1018
|
// 사용자가 forgen 문서를 인용해 본문 안에 marker 가 우연히 포함될 수 있어
|
|
765
1019
|
// includes() 만으론 안전 X. 정규식으로 frontmatter 종결(`---\n`) 다음 빈 줄 다음
|
|
766
1020
|
// 첫 non-blank 줄에 marker 가 있는지 확인.
|
|
767
|
-
|
|
768
|
-
if (!fmMarkerRe.test(existing))
|
|
1021
|
+
if (!hasManagedSkillMarker(existing))
|
|
769
1022
|
continue; // 사용자 작성 또는 손상 — skip
|
|
770
1023
|
}
|
|
771
1024
|
const raw = fs.readFileSync(path.join(sourceDir, file), 'utf-8');
|
|
@@ -856,6 +1109,31 @@ export function upsertForgenRulesInAgentsMd(opts) {
|
|
|
856
1109
|
fs.writeFileSync(agentsMdPath, newContent, 'utf-8');
|
|
857
1110
|
return { injected: newContent !== current };
|
|
858
1111
|
}
|
|
1112
|
+
/** AGENTS.md 의 forgen 블록 제거 (uninstall). 블록뿐이던 파일은 삭제한다. */
|
|
1113
|
+
export function removeForgenRulesFromAgentsMd(opts) {
|
|
1114
|
+
let current;
|
|
1115
|
+
try {
|
|
1116
|
+
current = fs.readFileSync(opts.agentsMdPath, 'utf-8');
|
|
1117
|
+
}
|
|
1118
|
+
catch {
|
|
1119
|
+
return { removed: false, fileDeleted: false };
|
|
1120
|
+
}
|
|
1121
|
+
const re = new RegExp(`\\n*${escapeRegex(AGENTS_MD_BEGIN)}[\\s\\S]*?${escapeRegex(AGENTS_MD_END)}\\n?`);
|
|
1122
|
+
if (!re.test(current))
|
|
1123
|
+
return { removed: false, fileDeleted: false };
|
|
1124
|
+
const rest = current.replace(re, '\n').replace(/^\n+/, '');
|
|
1125
|
+
const empty = rest.trim().length === 0;
|
|
1126
|
+
// 심링크면 링크를 지우지 않고 대상 파일에 써 넣는다 (링크만 지우면 대상에 블록이 남는다).
|
|
1127
|
+
const isLink = fs.lstatSync(opts.agentsMdPath).isSymbolicLink();
|
|
1128
|
+
const deleteFile = empty && !isLink;
|
|
1129
|
+
if (!opts.dryRun) {
|
|
1130
|
+
if (deleteFile)
|
|
1131
|
+
fs.unlinkSync(opts.agentsMdPath);
|
|
1132
|
+
else
|
|
1133
|
+
fs.writeFileSync(opts.agentsMdPath, empty ? '' : (rest.endsWith('\n') ? rest : `${rest}\n`), 'utf-8');
|
|
1134
|
+
}
|
|
1135
|
+
return { removed: true, fileDeleted: deleteFile };
|
|
1136
|
+
}
|
|
859
1137
|
function escapeRegex(s) {
|
|
860
1138
|
return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
861
1139
|
}
|