@rch4com/agent-setup 1.10.0 → 1.10.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/LICENSE +21 -21
- package/README.md +199 -199
- package/install.mjs +163 -163
- package/lib/args.mjs +276 -276
- package/lib/bootstrap/adapter.mjs +96 -96
- package/lib/bootstrap/apply.mjs +380 -380
- package/lib/bootstrap/flow.mjs +140 -140
- package/lib/bootstrap/record.mjs +154 -154
- package/lib/bootstrap/templates.mjs +148 -148
- package/lib/bootstrap/text.mjs +19 -19
- package/lib/catalog.mjs +234 -234
- package/lib/claude-plugins.mjs +64 -64
- package/lib/clis.mjs +111 -111
- package/lib/context.mjs +66 -66
- package/lib/deps.mjs +17 -17
- package/lib/design-md/catalog.json +454 -454
- package/lib/design-md/catalog.mjs +266 -266
- package/lib/design-md/flow.mjs +192 -192
- package/lib/design-md/open.mjs +51 -51
- package/lib/design-md/providers/awesome-design-md.mjs +124 -124
- package/lib/design-md/providers/index.mjs +9 -9
- package/lib/design-md/scan.mjs +295 -295
- package/lib/engine.mjs +115 -115
- package/lib/gitignore.mjs +18 -18
- package/lib/i18n/catalog/en.mjs +426 -426
- package/lib/i18n/catalog/ko.mjs +411 -411
- package/lib/i18n/detect.mjs +39 -39
- package/lib/i18n/index.mjs +93 -93
- package/lib/items/mcp.codebase-memory.mjs +6 -6
- package/lib/items/mcp.notion.mjs +6 -6
- package/lib/items/mcp.supabase.mjs +6 -6
- package/lib/items/mcp.vercel.mjs +6 -6
- package/lib/items/plugin.mattpocock-skills.mjs +8 -8
- package/lib/items/plugin.ponytail.mjs +104 -104
- package/lib/items/skill.gsd.mjs +44 -44
- package/lib/items/skill.gstack.mjs +54 -54
- package/lib/jsonfile.mjs +40 -40
- package/lib/status.mjs +93 -93
- package/lib/tomlfile.mjs +60 -60
- package/lib/tui/detail.mjs +127 -127
- package/lib/tui/progress.mjs +160 -160
- package/lib/tui/render.mjs +268 -268
- package/lib/tui/rows.mjs +317 -317
- package/lib/tui/run.mjs +396 -396
- package/lib/tui/state.mjs +249 -249
- package/lib/update.mjs +85 -85
- package/lib/width.mjs +115 -115
- package/package.json +36 -36
package/lib/bootstrap/apply.mjs
CHANGED
|
@@ -1,380 +1,380 @@
|
|
|
1
|
-
// 매니페스트 선언을 실제 파일시스템 변경으로 옮기는 실행기.
|
|
2
|
-
// 추가 전용 — 여기에는 삭제 경로가 없다.
|
|
3
|
-
import { lstatSync, mkdirSync, writeFileSync, readFileSync, appendFileSync } from 'node:fs'
|
|
4
|
-
import { dirname } from 'node:path'
|
|
5
|
-
import { repoPath, repoPathStrict } from '../context.mjs'
|
|
6
|
-
import { ensureGitignoreEntries } from '../gitignore.mjs'
|
|
7
|
-
import { createT, msg } from '../i18n/index.mjs'
|
|
8
|
-
import { hashBody, normalizeBody } from './text.mjs'
|
|
9
|
-
import { BEGIN_MARKER, END_MARKER, extractBlock, managedKey } from './record.mjs'
|
|
10
|
-
import { configureAdapter } from './adapter.mjs'
|
|
11
|
-
|
|
12
|
-
// existsSync는 깨진 심볼릭 링크에 false를 반환한다. 그대로 쓰면 사용자가 만든
|
|
13
|
-
// 링크를 덮어쓰게 되므로 lstat으로 "항목이 있는가"를 본다.
|
|
14
|
-
export function pathExists(target) {
|
|
15
|
-
try {
|
|
16
|
-
lstatSync(target)
|
|
17
|
-
return true
|
|
18
|
-
} catch {
|
|
19
|
-
return false
|
|
20
|
-
}
|
|
21
|
-
}
|
|
22
|
-
|
|
23
|
-
// normalizeBody는 text.mjs가 단일 출처다 — writeText(새 파일)와 ensureBlocks의
|
|
24
|
-
// 덧붙이기(기존 파일)가 쓰는 정규화와 해시 판정이 갈리면, 갓 쓴 파일조차
|
|
25
|
-
// 드리프트로 보고된다.
|
|
26
|
-
|
|
27
|
-
function writeText(file, text) {
|
|
28
|
-
const body = normalizeBody(text)
|
|
29
|
-
mkdirSync(dirname(file), { recursive: true })
|
|
30
|
-
writeFileSync(file, body, { encoding: 'utf8' })
|
|
31
|
-
}
|
|
32
|
-
|
|
33
|
-
export function ensureDirs(root, dirs, { dryRun = false, log, t = createT('en') }) {
|
|
34
|
-
return dirs.map((rel) => {
|
|
35
|
-
// 존재 확인은 어휘적 경로로 한다 — 만들지 않을 것이면 지켜야 할 쓰기도 없다.
|
|
36
|
-
if (pathExists(repoPath(root, rel))) return { ok: true, action: 'skip', path: rel }
|
|
37
|
-
// 실제로 만드는 경로만 엄격 검사한다(링크를 통한 저장소 이탈 차단).
|
|
38
|
-
// 검사가 던지면 하지 않은 동작을 보고하지 않도록 로그보다 먼저 수행한다.
|
|
39
|
-
const target = repoPathStrict(root, rel)
|
|
40
|
-
log(t('log.dir.create', { path: rel }))
|
|
41
|
-
if (!dryRun) mkdirSync(target, { recursive: true })
|
|
42
|
-
return { ok: true, action: 'create', path: rel }
|
|
43
|
-
})
|
|
44
|
-
}
|
|
45
|
-
|
|
46
|
-
export function ensureFiles(root, files, { dryRun = false, log, t = createT('en') }) {
|
|
47
|
-
return files.map(({ path: rel, template }) => {
|
|
48
|
-
// 깨진 링크도 lstat으로는 존재한다 — 내용을 보지 않고 그대로 보존한다.
|
|
49
|
-
if (pathExists(repoPath(root, rel))) {
|
|
50
|
-
log(t('log.file.keep', { path: rel }))
|
|
51
|
-
return { ok: true, action: 'keep', path: rel }
|
|
52
|
-
}
|
|
53
|
-
// 검사가 던지면 하지 않은 동작을 보고하지 않도록 로그보다 먼저 수행한다.
|
|
54
|
-
const target = repoPathStrict(root, rel)
|
|
55
|
-
log(t('log.file.create', { path: rel }))
|
|
56
|
-
if (!dryRun) writeText(target, template)
|
|
57
|
-
return { ok: true, action: 'create', path: rel }
|
|
58
|
-
})
|
|
59
|
-
}
|
|
60
|
-
|
|
61
|
-
export function ensureBlocks(root, blocks, { dryRun = false, log, t = createT('en') }) {
|
|
62
|
-
return blocks.map(({ path: rel, block }) => {
|
|
63
|
-
// 존재 확인은 어휘적 경로로 한다 — 만들지 않을 것이면 지켜야 할 쓰기도 없다.
|
|
64
|
-
if (!pathExists(repoPath(root, rel))) {
|
|
65
|
-
// 실제로 만드는 경로만 엄격 검사한다(링크를 통한 저장소 이탈 차단).
|
|
66
|
-
// 검사가 던지면 하지 않은 동작을 보고하지 않도록 로그보다 먼저 수행한다.
|
|
67
|
-
const target = repoPathStrict(root, rel)
|
|
68
|
-
log(t('log.file.create', { path: rel }))
|
|
69
|
-
if (!dryRun) writeText(target, block)
|
|
70
|
-
return { ok: true, action: 'create', path: rel }
|
|
71
|
-
}
|
|
72
|
-
|
|
73
|
-
const target = repoPath(root, rel)
|
|
74
|
-
let text
|
|
75
|
-
try {
|
|
76
|
-
text = readFileSync(target, 'utf8')
|
|
77
|
-
} catch (err) {
|
|
78
|
-
log(t('log.warn.unreadable', { path: rel, code: err.code ?? err.message }))
|
|
79
|
-
return { ok: true, action: 'warn', path: rel, message: msg('msg.readFailed') }
|
|
80
|
-
}
|
|
81
|
-
|
|
82
|
-
if (text.includes(BEGIN_MARKER)) {
|
|
83
|
-
log(t('log.block.keep', { path: rel }))
|
|
84
|
-
return { ok: true, action: 'skip', path: rel }
|
|
85
|
-
}
|
|
86
|
-
|
|
87
|
-
// 생성 분기와 같은 형태로: 검사는 dry-run 여부와 무관하게 항상 하고,
|
|
88
|
-
// 실제 쓰기만 !dryRun으로 막는다. dry-run 안에 검사를 가두면 저장소 밖으로
|
|
89
|
-
// 이탈하는 기존 파일에 대해 오류 없이 append 예정이라고 보고하게 된다.
|
|
90
|
-
// 검사가 던지면 하지 않은 동작을 보고하지 않도록 로그보다 먼저 수행한다.
|
|
91
|
-
const strictTarget = repoPathStrict(root, rel)
|
|
92
|
-
log(t('log.block.add', { path: rel }))
|
|
93
|
-
if (!dryRun) {
|
|
94
|
-
// 기존 마지막 줄을 닫고 빈 줄 하나를 띄운 뒤 블록을 붙인다.
|
|
95
|
-
const separator = text.endsWith('\n') ? '\n' : '\n\n'
|
|
96
|
-
appendFileSync(strictTarget, separator + normalizeBody(block), { encoding: 'utf8' })
|
|
97
|
-
}
|
|
98
|
-
return { ok: true, action: 'append', path: rel }
|
|
99
|
-
})
|
|
100
|
-
}
|
|
101
|
-
|
|
102
|
-
// 원본 setup-agents.ps1의 Add-GitignoreEntries·setup-agents.sh의
|
|
103
|
-
// ensure_gitignore_entries가 항목을 추가할 때 앞에 넣는 안내 주석.
|
|
104
|
-
// 헤더가 이미 있으면 다시 넣지 않는다 — 원본과 글자 단위로 같아야 한다.
|
|
105
|
-
const IGNORE_HEADER = '# agent-kit: local skill adapters (do not commit duplicated skills)'
|
|
106
|
-
|
|
107
|
-
// 부정 항목(`!path`)의 부모 디렉터리 전체를 제외하는 패턴이 이미 있으면
|
|
108
|
-
// git 규칙상 그 부정 항목은 조용히 무효가 된다(F-3) — 부모가 제외되면
|
|
109
|
-
// git이 그 안을 들여다보지 않아 자식을 되살릴 수 없다. `.vscode/*`처럼
|
|
110
|
-
// 내용물만 제외하는 형태는 무해하다. 손으로 쓴 `.gitignore`·Node/Python
|
|
111
|
-
// 템플릿에서 흔한 두 형태(`.vscode`, `.vscode/`)만 검사한다 — gitignore
|
|
112
|
-
// 전체 문법을 구현하지 않고 실제로 부딪히는 모양만 잡는다. 위치는 무관하다:
|
|
113
|
-
// git은 부정 항목의 순서와 무관하게 부모 제외를 우선한다.
|
|
114
|
-
function warnBlockedNegations(lines, entries, log, t) {
|
|
115
|
-
const warnings = []
|
|
116
|
-
for (const entry of entries) {
|
|
117
|
-
if (!entry.startsWith('!')) continue
|
|
118
|
-
const negated = entry.slice(1)
|
|
119
|
-
const parent = negated.split('/').slice(0, -1).join('/')
|
|
120
|
-
if (parent && (lines.has(parent) || lines.has(`${parent}/`))) {
|
|
121
|
-
log(t('log.warn.ignoreShadowed', { parent, entry }))
|
|
122
|
-
warnings.push({ ok: true, action: 'warn', path: entry, message: msg('log.warn.ignoreShadowed', { parent, entry }) })
|
|
123
|
-
}
|
|
124
|
-
}
|
|
125
|
-
return warnings
|
|
126
|
-
}
|
|
127
|
-
|
|
128
|
-
export function ensureIgnore(root, entries, { dryRun = false, log, t = createT('en') }) {
|
|
129
|
-
// ensureDirs/ensureFiles/ensureBlocks와 달리 경로가 '.gitignore' 하나로 고정돼
|
|
130
|
-
// 있고, 함수 자체의 목적이 "항목을 보장한다"는 쓰기 의도이므로 존재 확인과
|
|
131
|
-
// 쓰기 판단을 나누지 않고 맨 앞에서 한 번만 엄격 검사한다. ensureGitignoreEntries
|
|
132
|
-
// (lib/gitignore.mjs)도 같은 검사를 하지만, 그쪽을 부르지 않는 dry-run 경로에서도
|
|
133
|
-
// 이탈을 거부해야 하므로 여기서 먼저 확인한다.
|
|
134
|
-
const target = repoPathStrict(root, '.gitignore')
|
|
135
|
-
const text = pathExists(target) ? readFileSync(target, 'utf8') : ''
|
|
136
|
-
const lines = new Set(text.split(/\r?\n/))
|
|
137
|
-
const missing = entries.filter((e) => !lines.has(e))
|
|
138
|
-
// 이미 있는 항목이든 새로 추가하는 항목이든, 기존 .gitignore가 부모
|
|
139
|
-
// 디렉터리를 통째로 제외하고 있으면 매 실행마다 경고한다 — 사용자가
|
|
140
|
-
// 직접 고치기 전까지는 계속 알려야 "조용한 실패"가 아니게 된다.
|
|
141
|
-
const warnings = warnBlockedNegations(lines, entries, log, t)
|
|
142
|
-
|
|
143
|
-
if (missing.length === 0) {
|
|
144
|
-
log(t('log.ignore.keep', { entries: entries.join(', ') }))
|
|
145
|
-
return [...entries.map((e) => ({ ok: true, action: 'skip', path: e })), ...warnings]
|
|
146
|
-
}
|
|
147
|
-
|
|
148
|
-
log(t('log.ignore.add', { entries: missing.join(', ') }))
|
|
149
|
-
if (!dryRun) {
|
|
150
|
-
// 헤더가 아직 없을 때만 항목들보다 먼저 넣는다. ensureGitignoreEntries는
|
|
151
|
-
// 넘긴 항목 중 파일에 없는 줄만 추가하므로, 헤더도 그 목록의 맨 앞에
|
|
152
|
-
// 끼워 넣으면 "없을 때만 추가, 있으면 그대로"가 자연히 성립한다.
|
|
153
|
-
const toAdd = lines.has(IGNORE_HEADER) ? missing : [IGNORE_HEADER, ...missing]
|
|
154
|
-
ensureGitignoreEntries(root, toAdd)
|
|
155
|
-
}
|
|
156
|
-
return [...missing.map((e) => ({ ok: true, action: 'append', path: e })), ...warnings]
|
|
157
|
-
}
|
|
158
|
-
|
|
159
|
-
// 줄 주석(//)과 블록 주석(/* */)을 건너뛰며 유의미한 문자를 하나씩 넘겨준다.
|
|
160
|
-
// findRootBrace(루트 '{' 탐색)와 isEmptyObjectBody(빈 객체 판정, F-1)가 같은
|
|
161
|
-
// 스캐너를 공유한다 — 판정 기준이 둘로 갈리면 한쪽만 고치는 회귀가 생기기 쉽다.
|
|
162
|
-
// 한계: 문자열 리터럴을 모른다. `"https://x"` 안의 `//`도 줄 주석으로 본다.
|
|
163
|
-
// 현재 두 소비자는 루트 '{' 이전과 그 바로 뒤 한 글자만 보므로 문자열 안까지
|
|
164
|
-
// 들어가지 않아 영향이 없다. 다른 위치에서 재사용하려면 문자열 인식을 먼저 더해야 한다.
|
|
165
|
-
function* scanSignificant(text) {
|
|
166
|
-
let inBlock = false
|
|
167
|
-
let offset = 0
|
|
168
|
-
|
|
169
|
-
for (const line of text.split('\n')) {
|
|
170
|
-
let i = 0
|
|
171
|
-
while (i < line.length) {
|
|
172
|
-
if (inBlock) {
|
|
173
|
-
const end = line.indexOf('*/', i)
|
|
174
|
-
if (end === -1) { i = line.length; break }
|
|
175
|
-
inBlock = false
|
|
176
|
-
i = end + 2
|
|
177
|
-
continue
|
|
178
|
-
}
|
|
179
|
-
if (line[i] === '/' && line[i + 1] === '/') break
|
|
180
|
-
if (line[i] === '/' && line[i + 1] === '*') { inBlock = true; i += 2; continue }
|
|
181
|
-
yield { char: line[i], offset: offset + i }
|
|
182
|
-
i++
|
|
183
|
-
}
|
|
184
|
-
offset += line.length + 1 // split이 제거한 개행 1자
|
|
185
|
-
}
|
|
186
|
-
}
|
|
187
|
-
|
|
188
|
-
// JSONC인 .vscode/settings.json은 파일 첫머리에 주석이 오는 일이 흔하고,
|
|
189
|
-
// 그 주석 안의 중괄호에 속으면 주석 한가운데에 키를 끼워 넣게 된다.
|
|
190
|
-
function findRootBrace(text) {
|
|
191
|
-
for (const { char, offset } of scanSignificant(text)) {
|
|
192
|
-
if (char === '{') return offset
|
|
193
|
-
}
|
|
194
|
-
return -1
|
|
195
|
-
}
|
|
196
|
-
|
|
197
|
-
// rest(루트 '{' 다음 텍스트)에서 공백을 건너뛴 첫 유의미 문자가 '}'인지 본다.
|
|
198
|
-
// trimStart만으로는 `{ // 안내\n}` 처럼 첫 항목이 주석인 빈 객체를 항목 있는
|
|
199
|
-
// 객체로 오판해 후행 콤마를 남긴다(F-1) — 주석도 whitespace와 함께 건너뛰어야
|
|
200
|
-
// 진짜 "다음 항목이 있는가"를 알 수 있다.
|
|
201
|
-
function isEmptyObjectBody(rest) {
|
|
202
|
-
for (const { char } of scanSignificant(rest)) {
|
|
203
|
-
if (/\s/.test(char)) continue
|
|
204
|
-
return char === '}'
|
|
205
|
-
}
|
|
206
|
-
return true // 닫는 중괄호를 못 찾은 경우(도달 불가에 가깝다) — 방어적으로 empty 취급
|
|
207
|
-
}
|
|
208
|
-
|
|
209
|
-
// 기존 JSON 파일을 보존한 채 최상위 키 하나를 보장한다.
|
|
210
|
-
// 부트스트랩은 외부 의존성을 쓸 수 없어(jsonc-parser는 설치기 경로 전용)
|
|
211
|
-
// 파싱·재직렬화 대신 텍스트 삽입으로 처리한다. 재직렬화하지 않으므로
|
|
212
|
-
// 사용자의 주석·들여쓰기·줄바꿈이 그대로 남는다.
|
|
213
|
-
export function ensureJsonKeys(root, entries, { dryRun = false, log, t = createT('en') }) {
|
|
214
|
-
return entries.map(({ path: rel, key, value }) => {
|
|
215
|
-
const pair = `${JSON.stringify(key)}: ${JSON.stringify(value)}`
|
|
216
|
-
|
|
217
|
-
// 존재 확인은 어휘적 경로로 한다 — 만들지 않을 것이면 지켜야 할 쓰기도 없다.
|
|
218
|
-
if (!pathExists(repoPath(root, rel))) {
|
|
219
|
-
// 검사가 던지면 하지 않은 동작을 보고하지 않도록 로그보다 먼저 수행한다.
|
|
220
|
-
const target = repoPathStrict(root, rel)
|
|
221
|
-
log(t('log.file.create', { path: rel }))
|
|
222
|
-
if (!dryRun) writeText(target, `{\n ${pair}\n}`)
|
|
223
|
-
return { ok: true, action: 'create', path: rel }
|
|
224
|
-
}
|
|
225
|
-
|
|
226
|
-
let text
|
|
227
|
-
try {
|
|
228
|
-
text = readFileSync(repoPath(root, rel), 'utf8')
|
|
229
|
-
} catch (err) {
|
|
230
|
-
log(t('log.warn.unreadable', { path: rel, code: err.code ?? err.message }))
|
|
231
|
-
return { ok: true, action: 'warn', path: rel, message: msg('msg.readFailed') }
|
|
232
|
-
}
|
|
233
|
-
|
|
234
|
-
// 주석 안에 있어도 건드리지 않는다 — 사용자가 언급한 키를 스크립트가
|
|
235
|
-
// 되살리지 않는 편이 "기존 설정을 덮어쓰지 않는다"는 원칙에 맞는다.
|
|
236
|
-
// 문자열 포함 검사라 키 이름이 어떤 값 안에 들어 있어도 건너뛴다. 보수적인
|
|
237
|
-
// 쪽으로 틀리는 것(넣지 않음)이라 파일을 망가뜨리지는 않는다. 정확히 하려면
|
|
238
|
-
// 최상위 키만 보는 파서가 필요한데, 부트스트랩은 의존성을 쓸 수 없다.
|
|
239
|
-
if (text.includes(JSON.stringify(key))) {
|
|
240
|
-
log(t('log.json.keep', { path: rel, key }))
|
|
241
|
-
return { ok: true, action: 'skip', path: rel }
|
|
242
|
-
}
|
|
243
|
-
|
|
244
|
-
const brace = findRootBrace(text)
|
|
245
|
-
if (brace === -1) {
|
|
246
|
-
log(t('log.warn.noRootObject', { path: rel }))
|
|
247
|
-
return { ok: true, action: 'warn', path: rel, message: msg('msg.noRootObject') }
|
|
248
|
-
}
|
|
249
|
-
|
|
250
|
-
const strictTarget = repoPathStrict(root, rel)
|
|
251
|
-
log(t('log.json.add', { path: rel, key }))
|
|
252
|
-
if (!dryRun) {
|
|
253
|
-
const rest = text.slice(brace + 1)
|
|
254
|
-
// 빈 객체면 콤마 없이, 뒤에 항목이 있으면 콤마를 붙여 유효한 JSON을 유지한다.
|
|
255
|
-
const empty = isEmptyObjectBody(rest)
|
|
256
|
-
// 파일의 우세 줄바꿈을 따른다 — CRLF 파일에 삽입한 줄만 LF로 섞이는 것을 막는다.
|
|
257
|
-
const eol = text.includes('\r\n') ? '\r\n' : '\n'
|
|
258
|
-
// 중괄호 사이가 공백뿐이면 그 공백을 우리 줄로 대체한다. 그대로 두면
|
|
259
|
-
// `{\n}` 같은 파일에 빈 줄이 하나 남는다. 주석이 들어 있는 빈 객체는
|
|
260
|
-
// 건드리지 않는다 — 주석의 들여쓰기를 지켜야 한다.
|
|
261
|
-
const body = empty && /^\s*\}/.test(rest) ? rest.replace(/^\s*/, '') : rest
|
|
262
|
-
const inserted = `${eol} ${pair}${empty ? eol : ','}`
|
|
263
|
-
writeFileSync(strictTarget, text.slice(0, brace + 1) + inserted + body, { encoding: 'utf8' })
|
|
264
|
-
}
|
|
265
|
-
return { ok: true, action: 'insert', path: rel }
|
|
266
|
-
})
|
|
267
|
-
}
|
|
268
|
-
|
|
269
|
-
// flow.mjs와 update.mjs가 같은 실패 격리를 쓴다 — 어댑터 하나가 실패해도
|
|
270
|
-
// 나머지를 계속해야 한다.
|
|
271
|
-
export function configureAdapterSafe(root, entry, ctx) {
|
|
272
|
-
try {
|
|
273
|
-
return configureAdapter(root, entry, ctx)
|
|
274
|
-
} catch (err) {
|
|
275
|
-
// err.message는 항상 영어다(LocalizedError 또는 원시 Error) — 여기서는
|
|
276
|
-
// 구조화하지 않고 그대로 옮긴다. flow.mjs·bootstrap.flow.test.mjs가
|
|
277
|
-
// 이 사실에 기대어 실패 메시지를 검증한다.
|
|
278
|
-
return { ok: false, action: 'link', path: entry.path, message: err.message }
|
|
279
|
-
}
|
|
280
|
-
}
|
|
281
|
-
|
|
282
|
-
// 갱신 경로. 생성 경로(ensureFiles·ensureBlocks)와 분리한 이유는 계약이
|
|
283
|
-
// 반대이기 때문이다 — 생성은 "있으면 손대지 않는다", 갱신은 "우리가 쓴
|
|
284
|
-
// 그대로면 바꾼다". 한 함수에 두 계약을 넣으면 어느 쪽도 읽히지 않는다.
|
|
285
|
-
//
|
|
286
|
-
// managed[key]가 우리가 마지막으로 쓴 내용의 해시다.
|
|
287
|
-
// 현재 == managed → 우리가 쓴 그대로 → 교체
|
|
288
|
-
// 현재 != managed → 사용자가 고쳤다 → 건드리지 않는다(force면 교체)
|
|
289
|
-
// managed 없음/null → 출처 불명 → 건드리지 않는다(force면 교체)
|
|
290
|
-
// 파일 없음 → 생성 (새 도구 지원이 이 경로로 들어온다)
|
|
291
|
-
function decide(current, recorded, force) {
|
|
292
|
-
if (current === null) return 'create'
|
|
293
|
-
if (recorded && hashBody(current) === recorded) return 'update'
|
|
294
|
-
return force ? 'update' : 'drift'
|
|
295
|
-
}
|
|
296
|
-
|
|
297
|
-
export function updateFiles(root, files, { managed = {}, dryRun = false, force = false, log, t = createT('en') }) {
|
|
298
|
-
return files.map(({ path: rel, template }) => {
|
|
299
|
-
const lexical = repoPath(root, rel)
|
|
300
|
-
const exists = pathExists(lexical)
|
|
301
|
-
let current = null
|
|
302
|
-
if (exists) {
|
|
303
|
-
try {
|
|
304
|
-
current = readFileSync(lexical, 'utf8')
|
|
305
|
-
} catch (err) {
|
|
306
|
-
log(t('log.warn.unreadable', { path: rel, code: err.code ?? err.message }))
|
|
307
|
-
return { ok: true, action: 'warn', path: rel, message: msg('msg.readFailed') }
|
|
308
|
-
}
|
|
309
|
-
}
|
|
310
|
-
|
|
311
|
-
const wanted = hashBody(template)
|
|
312
|
-
// 이미 새 템플릿과 같으면 쓰지 않는다 — 무의미한 mtime 변경과
|
|
313
|
-
// "갱신 N건" 과대 보고를 막는다.
|
|
314
|
-
if (current !== null && hashBody(current) === wanted) {
|
|
315
|
-
return { ok: true, action: 'skip', path: rel, hash: wanted }
|
|
316
|
-
}
|
|
317
|
-
|
|
318
|
-
const verdict = decide(current, managed[rel], force)
|
|
319
|
-
if (verdict === 'drift') {
|
|
320
|
-
log(t('log.file.userEdited', { path: rel }))
|
|
321
|
-
return { ok: true, action: 'drift', path: rel }
|
|
322
|
-
}
|
|
323
|
-
|
|
324
|
-
// 검사가 던지면 하지 않은 동작을 보고하지 않도록 로그보다 먼저 수행한다.
|
|
325
|
-
const target = repoPathStrict(root, rel)
|
|
326
|
-
log(verdict === 'create' ? t('log.file.create', { path: rel }) : t('log.file.update', { path: rel }))
|
|
327
|
-
if (!dryRun) writeText(target, template)
|
|
328
|
-
return { ok: true, action: verdict, path: rel, hash: wanted }
|
|
329
|
-
})
|
|
330
|
-
}
|
|
331
|
-
|
|
332
|
-
export function updateBlocks(root, blocks, { managed = {}, dryRun = false, force = false, log, t = createT('en') }) {
|
|
333
|
-
return blocks.map(({ path: rel, block }) => {
|
|
334
|
-
const key = managedKey(rel, true)
|
|
335
|
-
const wanted = hashBody(extractBlock(block) ?? block)
|
|
336
|
-
|
|
337
|
-
if (!pathExists(repoPath(root, rel))) {
|
|
338
|
-
const target = repoPathStrict(root, rel)
|
|
339
|
-
log(t('log.file.create', { path: rel }))
|
|
340
|
-
if (!dryRun) writeText(target, block)
|
|
341
|
-
return { ok: true, action: 'create', path: rel, hash: wanted }
|
|
342
|
-
}
|
|
343
|
-
|
|
344
|
-
let text
|
|
345
|
-
try {
|
|
346
|
-
text = readFileSync(repoPath(root, rel), 'utf8')
|
|
347
|
-
} catch (err) {
|
|
348
|
-
log(t('log.warn.unreadable', { path: rel, code: err.code ?? err.message }))
|
|
349
|
-
return { ok: true, action: 'warn', path: rel, message: msg('msg.readFailed') }
|
|
350
|
-
}
|
|
351
|
-
|
|
352
|
-
const current = extractBlock(text)
|
|
353
|
-
// 마커가 없는 기존 파일은 갱신 대상이 아니다. 블록을 처음 붙이는 것은
|
|
354
|
-
// 생성 경로(ensureBlocks)의 일이고, 갱신이 append까지 하면 사용자가
|
|
355
|
-
// 일부러 지운 블록을 되살리게 된다.
|
|
356
|
-
if (current === null) {
|
|
357
|
-
log(t('log.block.missing', { path: rel }))
|
|
358
|
-
return { ok: true, action: 'drift', path: rel, message: msg('msg.noManagedBlock') }
|
|
359
|
-
}
|
|
360
|
-
|
|
361
|
-
if (hashBody(current) === wanted) return { ok: true, action: 'skip', path: rel, hash: wanted }
|
|
362
|
-
|
|
363
|
-
if (hashBody(current) !== managed[key] && !force) {
|
|
364
|
-
log(t('log.block.userEdited', { path: rel }))
|
|
365
|
-
return { ok: true, action: 'drift', path: rel }
|
|
366
|
-
}
|
|
367
|
-
|
|
368
|
-
const target = repoPathStrict(root, rel)
|
|
369
|
-
log(t('log.block.update', { path: rel }))
|
|
370
|
-
if (!dryRun) {
|
|
371
|
-
// 마커를 포함한 구간을 통째로 새 블록으로 바꾼다. 앞뒤 사용자 본문은
|
|
372
|
-
// 손대지 않으므로 줄바꿈 스타일도 그대로 남는다.
|
|
373
|
-
const begin = text.indexOf(BEGIN_MARKER)
|
|
374
|
-
const end = text.indexOf(END_MARKER, begin) + END_MARKER.length
|
|
375
|
-
const next = text.slice(0, begin) + normalizeBody(block).trimEnd() + text.slice(end)
|
|
376
|
-
writeFileSync(target, next, { encoding: 'utf8' })
|
|
377
|
-
}
|
|
378
|
-
return { ok: true, action: 'update', path: rel, hash: wanted }
|
|
379
|
-
})
|
|
380
|
-
}
|
|
1
|
+
// 매니페스트 선언을 실제 파일시스템 변경으로 옮기는 실행기.
|
|
2
|
+
// 추가 전용 — 여기에는 삭제 경로가 없다.
|
|
3
|
+
import { lstatSync, mkdirSync, writeFileSync, readFileSync, appendFileSync } from 'node:fs'
|
|
4
|
+
import { dirname } from 'node:path'
|
|
5
|
+
import { repoPath, repoPathStrict } from '../context.mjs'
|
|
6
|
+
import { ensureGitignoreEntries } from '../gitignore.mjs'
|
|
7
|
+
import { createT, msg } from '../i18n/index.mjs'
|
|
8
|
+
import { hashBody, normalizeBody } from './text.mjs'
|
|
9
|
+
import { BEGIN_MARKER, END_MARKER, extractBlock, managedKey } from './record.mjs'
|
|
10
|
+
import { configureAdapter } from './adapter.mjs'
|
|
11
|
+
|
|
12
|
+
// existsSync는 깨진 심볼릭 링크에 false를 반환한다. 그대로 쓰면 사용자가 만든
|
|
13
|
+
// 링크를 덮어쓰게 되므로 lstat으로 "항목이 있는가"를 본다.
|
|
14
|
+
export function pathExists(target) {
|
|
15
|
+
try {
|
|
16
|
+
lstatSync(target)
|
|
17
|
+
return true
|
|
18
|
+
} catch {
|
|
19
|
+
return false
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
// normalizeBody는 text.mjs가 단일 출처다 — writeText(새 파일)와 ensureBlocks의
|
|
24
|
+
// 덧붙이기(기존 파일)가 쓰는 정규화와 해시 판정이 갈리면, 갓 쓴 파일조차
|
|
25
|
+
// 드리프트로 보고된다.
|
|
26
|
+
|
|
27
|
+
function writeText(file, text) {
|
|
28
|
+
const body = normalizeBody(text)
|
|
29
|
+
mkdirSync(dirname(file), { recursive: true })
|
|
30
|
+
writeFileSync(file, body, { encoding: 'utf8' })
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function ensureDirs(root, dirs, { dryRun = false, log, t = createT('en') }) {
|
|
34
|
+
return dirs.map((rel) => {
|
|
35
|
+
// 존재 확인은 어휘적 경로로 한다 — 만들지 않을 것이면 지켜야 할 쓰기도 없다.
|
|
36
|
+
if (pathExists(repoPath(root, rel))) return { ok: true, action: 'skip', path: rel }
|
|
37
|
+
// 실제로 만드는 경로만 엄격 검사한다(링크를 통한 저장소 이탈 차단).
|
|
38
|
+
// 검사가 던지면 하지 않은 동작을 보고하지 않도록 로그보다 먼저 수행한다.
|
|
39
|
+
const target = repoPathStrict(root, rel)
|
|
40
|
+
log(t('log.dir.create', { path: rel }))
|
|
41
|
+
if (!dryRun) mkdirSync(target, { recursive: true })
|
|
42
|
+
return { ok: true, action: 'create', path: rel }
|
|
43
|
+
})
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function ensureFiles(root, files, { dryRun = false, log, t = createT('en') }) {
|
|
47
|
+
return files.map(({ path: rel, template }) => {
|
|
48
|
+
// 깨진 링크도 lstat으로는 존재한다 — 내용을 보지 않고 그대로 보존한다.
|
|
49
|
+
if (pathExists(repoPath(root, rel))) {
|
|
50
|
+
log(t('log.file.keep', { path: rel }))
|
|
51
|
+
return { ok: true, action: 'keep', path: rel }
|
|
52
|
+
}
|
|
53
|
+
// 검사가 던지면 하지 않은 동작을 보고하지 않도록 로그보다 먼저 수행한다.
|
|
54
|
+
const target = repoPathStrict(root, rel)
|
|
55
|
+
log(t('log.file.create', { path: rel }))
|
|
56
|
+
if (!dryRun) writeText(target, template)
|
|
57
|
+
return { ok: true, action: 'create', path: rel }
|
|
58
|
+
})
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export function ensureBlocks(root, blocks, { dryRun = false, log, t = createT('en') }) {
|
|
62
|
+
return blocks.map(({ path: rel, block }) => {
|
|
63
|
+
// 존재 확인은 어휘적 경로로 한다 — 만들지 않을 것이면 지켜야 할 쓰기도 없다.
|
|
64
|
+
if (!pathExists(repoPath(root, rel))) {
|
|
65
|
+
// 실제로 만드는 경로만 엄격 검사한다(링크를 통한 저장소 이탈 차단).
|
|
66
|
+
// 검사가 던지면 하지 않은 동작을 보고하지 않도록 로그보다 먼저 수행한다.
|
|
67
|
+
const target = repoPathStrict(root, rel)
|
|
68
|
+
log(t('log.file.create', { path: rel }))
|
|
69
|
+
if (!dryRun) writeText(target, block)
|
|
70
|
+
return { ok: true, action: 'create', path: rel }
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const target = repoPath(root, rel)
|
|
74
|
+
let text
|
|
75
|
+
try {
|
|
76
|
+
text = readFileSync(target, 'utf8')
|
|
77
|
+
} catch (err) {
|
|
78
|
+
log(t('log.warn.unreadable', { path: rel, code: err.code ?? err.message }))
|
|
79
|
+
return { ok: true, action: 'warn', path: rel, message: msg('msg.readFailed') }
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
if (text.includes(BEGIN_MARKER)) {
|
|
83
|
+
log(t('log.block.keep', { path: rel }))
|
|
84
|
+
return { ok: true, action: 'skip', path: rel }
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// 생성 분기와 같은 형태로: 검사는 dry-run 여부와 무관하게 항상 하고,
|
|
88
|
+
// 실제 쓰기만 !dryRun으로 막는다. dry-run 안에 검사를 가두면 저장소 밖으로
|
|
89
|
+
// 이탈하는 기존 파일에 대해 오류 없이 append 예정이라고 보고하게 된다.
|
|
90
|
+
// 검사가 던지면 하지 않은 동작을 보고하지 않도록 로그보다 먼저 수행한다.
|
|
91
|
+
const strictTarget = repoPathStrict(root, rel)
|
|
92
|
+
log(t('log.block.add', { path: rel }))
|
|
93
|
+
if (!dryRun) {
|
|
94
|
+
// 기존 마지막 줄을 닫고 빈 줄 하나를 띄운 뒤 블록을 붙인다.
|
|
95
|
+
const separator = text.endsWith('\n') ? '\n' : '\n\n'
|
|
96
|
+
appendFileSync(strictTarget, separator + normalizeBody(block), { encoding: 'utf8' })
|
|
97
|
+
}
|
|
98
|
+
return { ok: true, action: 'append', path: rel }
|
|
99
|
+
})
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// 원본 setup-agents.ps1의 Add-GitignoreEntries·setup-agents.sh의
|
|
103
|
+
// ensure_gitignore_entries가 항목을 추가할 때 앞에 넣는 안내 주석.
|
|
104
|
+
// 헤더가 이미 있으면 다시 넣지 않는다 — 원본과 글자 단위로 같아야 한다.
|
|
105
|
+
const IGNORE_HEADER = '# agent-kit: local skill adapters (do not commit duplicated skills)'
|
|
106
|
+
|
|
107
|
+
// 부정 항목(`!path`)의 부모 디렉터리 전체를 제외하는 패턴이 이미 있으면
|
|
108
|
+
// git 규칙상 그 부정 항목은 조용히 무효가 된다(F-3) — 부모가 제외되면
|
|
109
|
+
// git이 그 안을 들여다보지 않아 자식을 되살릴 수 없다. `.vscode/*`처럼
|
|
110
|
+
// 내용물만 제외하는 형태는 무해하다. 손으로 쓴 `.gitignore`·Node/Python
|
|
111
|
+
// 템플릿에서 흔한 두 형태(`.vscode`, `.vscode/`)만 검사한다 — gitignore
|
|
112
|
+
// 전체 문법을 구현하지 않고 실제로 부딪히는 모양만 잡는다. 위치는 무관하다:
|
|
113
|
+
// git은 부정 항목의 순서와 무관하게 부모 제외를 우선한다.
|
|
114
|
+
function warnBlockedNegations(lines, entries, log, t) {
|
|
115
|
+
const warnings = []
|
|
116
|
+
for (const entry of entries) {
|
|
117
|
+
if (!entry.startsWith('!')) continue
|
|
118
|
+
const negated = entry.slice(1)
|
|
119
|
+
const parent = negated.split('/').slice(0, -1).join('/')
|
|
120
|
+
if (parent && (lines.has(parent) || lines.has(`${parent}/`))) {
|
|
121
|
+
log(t('log.warn.ignoreShadowed', { parent, entry }))
|
|
122
|
+
warnings.push({ ok: true, action: 'warn', path: entry, message: msg('log.warn.ignoreShadowed', { parent, entry }) })
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
return warnings
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
export function ensureIgnore(root, entries, { dryRun = false, log, t = createT('en') }) {
|
|
129
|
+
// ensureDirs/ensureFiles/ensureBlocks와 달리 경로가 '.gitignore' 하나로 고정돼
|
|
130
|
+
// 있고, 함수 자체의 목적이 "항목을 보장한다"는 쓰기 의도이므로 존재 확인과
|
|
131
|
+
// 쓰기 판단을 나누지 않고 맨 앞에서 한 번만 엄격 검사한다. ensureGitignoreEntries
|
|
132
|
+
// (lib/gitignore.mjs)도 같은 검사를 하지만, 그쪽을 부르지 않는 dry-run 경로에서도
|
|
133
|
+
// 이탈을 거부해야 하므로 여기서 먼저 확인한다.
|
|
134
|
+
const target = repoPathStrict(root, '.gitignore')
|
|
135
|
+
const text = pathExists(target) ? readFileSync(target, 'utf8') : ''
|
|
136
|
+
const lines = new Set(text.split(/\r?\n/))
|
|
137
|
+
const missing = entries.filter((e) => !lines.has(e))
|
|
138
|
+
// 이미 있는 항목이든 새로 추가하는 항목이든, 기존 .gitignore가 부모
|
|
139
|
+
// 디렉터리를 통째로 제외하고 있으면 매 실행마다 경고한다 — 사용자가
|
|
140
|
+
// 직접 고치기 전까지는 계속 알려야 "조용한 실패"가 아니게 된다.
|
|
141
|
+
const warnings = warnBlockedNegations(lines, entries, log, t)
|
|
142
|
+
|
|
143
|
+
if (missing.length === 0) {
|
|
144
|
+
log(t('log.ignore.keep', { entries: entries.join(', ') }))
|
|
145
|
+
return [...entries.map((e) => ({ ok: true, action: 'skip', path: e })), ...warnings]
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
log(t('log.ignore.add', { entries: missing.join(', ') }))
|
|
149
|
+
if (!dryRun) {
|
|
150
|
+
// 헤더가 아직 없을 때만 항목들보다 먼저 넣는다. ensureGitignoreEntries는
|
|
151
|
+
// 넘긴 항목 중 파일에 없는 줄만 추가하므로, 헤더도 그 목록의 맨 앞에
|
|
152
|
+
// 끼워 넣으면 "없을 때만 추가, 있으면 그대로"가 자연히 성립한다.
|
|
153
|
+
const toAdd = lines.has(IGNORE_HEADER) ? missing : [IGNORE_HEADER, ...missing]
|
|
154
|
+
ensureGitignoreEntries(root, toAdd)
|
|
155
|
+
}
|
|
156
|
+
return [...missing.map((e) => ({ ok: true, action: 'append', path: e })), ...warnings]
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// 줄 주석(//)과 블록 주석(/* */)을 건너뛰며 유의미한 문자를 하나씩 넘겨준다.
|
|
160
|
+
// findRootBrace(루트 '{' 탐색)와 isEmptyObjectBody(빈 객체 판정, F-1)가 같은
|
|
161
|
+
// 스캐너를 공유한다 — 판정 기준이 둘로 갈리면 한쪽만 고치는 회귀가 생기기 쉽다.
|
|
162
|
+
// 한계: 문자열 리터럴을 모른다. `"https://x"` 안의 `//`도 줄 주석으로 본다.
|
|
163
|
+
// 현재 두 소비자는 루트 '{' 이전과 그 바로 뒤 한 글자만 보므로 문자열 안까지
|
|
164
|
+
// 들어가지 않아 영향이 없다. 다른 위치에서 재사용하려면 문자열 인식을 먼저 더해야 한다.
|
|
165
|
+
function* scanSignificant(text) {
|
|
166
|
+
let inBlock = false
|
|
167
|
+
let offset = 0
|
|
168
|
+
|
|
169
|
+
for (const line of text.split('\n')) {
|
|
170
|
+
let i = 0
|
|
171
|
+
while (i < line.length) {
|
|
172
|
+
if (inBlock) {
|
|
173
|
+
const end = line.indexOf('*/', i)
|
|
174
|
+
if (end === -1) { i = line.length; break }
|
|
175
|
+
inBlock = false
|
|
176
|
+
i = end + 2
|
|
177
|
+
continue
|
|
178
|
+
}
|
|
179
|
+
if (line[i] === '/' && line[i + 1] === '/') break
|
|
180
|
+
if (line[i] === '/' && line[i + 1] === '*') { inBlock = true; i += 2; continue }
|
|
181
|
+
yield { char: line[i], offset: offset + i }
|
|
182
|
+
i++
|
|
183
|
+
}
|
|
184
|
+
offset += line.length + 1 // split이 제거한 개행 1자
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
// JSONC인 .vscode/settings.json은 파일 첫머리에 주석이 오는 일이 흔하고,
|
|
189
|
+
// 그 주석 안의 중괄호에 속으면 주석 한가운데에 키를 끼워 넣게 된다.
|
|
190
|
+
function findRootBrace(text) {
|
|
191
|
+
for (const { char, offset } of scanSignificant(text)) {
|
|
192
|
+
if (char === '{') return offset
|
|
193
|
+
}
|
|
194
|
+
return -1
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
// rest(루트 '{' 다음 텍스트)에서 공백을 건너뛴 첫 유의미 문자가 '}'인지 본다.
|
|
198
|
+
// trimStart만으로는 `{ // 안내\n}` 처럼 첫 항목이 주석인 빈 객체를 항목 있는
|
|
199
|
+
// 객체로 오판해 후행 콤마를 남긴다(F-1) — 주석도 whitespace와 함께 건너뛰어야
|
|
200
|
+
// 진짜 "다음 항목이 있는가"를 알 수 있다.
|
|
201
|
+
function isEmptyObjectBody(rest) {
|
|
202
|
+
for (const { char } of scanSignificant(rest)) {
|
|
203
|
+
if (/\s/.test(char)) continue
|
|
204
|
+
return char === '}'
|
|
205
|
+
}
|
|
206
|
+
return true // 닫는 중괄호를 못 찾은 경우(도달 불가에 가깝다) — 방어적으로 empty 취급
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
// 기존 JSON 파일을 보존한 채 최상위 키 하나를 보장한다.
|
|
210
|
+
// 부트스트랩은 외부 의존성을 쓸 수 없어(jsonc-parser는 설치기 경로 전용)
|
|
211
|
+
// 파싱·재직렬화 대신 텍스트 삽입으로 처리한다. 재직렬화하지 않으므로
|
|
212
|
+
// 사용자의 주석·들여쓰기·줄바꿈이 그대로 남는다.
|
|
213
|
+
export function ensureJsonKeys(root, entries, { dryRun = false, log, t = createT('en') }) {
|
|
214
|
+
return entries.map(({ path: rel, key, value }) => {
|
|
215
|
+
const pair = `${JSON.stringify(key)}: ${JSON.stringify(value)}`
|
|
216
|
+
|
|
217
|
+
// 존재 확인은 어휘적 경로로 한다 — 만들지 않을 것이면 지켜야 할 쓰기도 없다.
|
|
218
|
+
if (!pathExists(repoPath(root, rel))) {
|
|
219
|
+
// 검사가 던지면 하지 않은 동작을 보고하지 않도록 로그보다 먼저 수행한다.
|
|
220
|
+
const target = repoPathStrict(root, rel)
|
|
221
|
+
log(t('log.file.create', { path: rel }))
|
|
222
|
+
if (!dryRun) writeText(target, `{\n ${pair}\n}`)
|
|
223
|
+
return { ok: true, action: 'create', path: rel }
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
let text
|
|
227
|
+
try {
|
|
228
|
+
text = readFileSync(repoPath(root, rel), 'utf8')
|
|
229
|
+
} catch (err) {
|
|
230
|
+
log(t('log.warn.unreadable', { path: rel, code: err.code ?? err.message }))
|
|
231
|
+
return { ok: true, action: 'warn', path: rel, message: msg('msg.readFailed') }
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
// 주석 안에 있어도 건드리지 않는다 — 사용자가 언급한 키를 스크립트가
|
|
235
|
+
// 되살리지 않는 편이 "기존 설정을 덮어쓰지 않는다"는 원칙에 맞는다.
|
|
236
|
+
// 문자열 포함 검사라 키 이름이 어떤 값 안에 들어 있어도 건너뛴다. 보수적인
|
|
237
|
+
// 쪽으로 틀리는 것(넣지 않음)이라 파일을 망가뜨리지는 않는다. 정확히 하려면
|
|
238
|
+
// 최상위 키만 보는 파서가 필요한데, 부트스트랩은 의존성을 쓸 수 없다.
|
|
239
|
+
if (text.includes(JSON.stringify(key))) {
|
|
240
|
+
log(t('log.json.keep', { path: rel, key }))
|
|
241
|
+
return { ok: true, action: 'skip', path: rel }
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
const brace = findRootBrace(text)
|
|
245
|
+
if (brace === -1) {
|
|
246
|
+
log(t('log.warn.noRootObject', { path: rel }))
|
|
247
|
+
return { ok: true, action: 'warn', path: rel, message: msg('msg.noRootObject') }
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
const strictTarget = repoPathStrict(root, rel)
|
|
251
|
+
log(t('log.json.add', { path: rel, key }))
|
|
252
|
+
if (!dryRun) {
|
|
253
|
+
const rest = text.slice(brace + 1)
|
|
254
|
+
// 빈 객체면 콤마 없이, 뒤에 항목이 있으면 콤마를 붙여 유효한 JSON을 유지한다.
|
|
255
|
+
const empty = isEmptyObjectBody(rest)
|
|
256
|
+
// 파일의 우세 줄바꿈을 따른다 — CRLF 파일에 삽입한 줄만 LF로 섞이는 것을 막는다.
|
|
257
|
+
const eol = text.includes('\r\n') ? '\r\n' : '\n'
|
|
258
|
+
// 중괄호 사이가 공백뿐이면 그 공백을 우리 줄로 대체한다. 그대로 두면
|
|
259
|
+
// `{\n}` 같은 파일에 빈 줄이 하나 남는다. 주석이 들어 있는 빈 객체는
|
|
260
|
+
// 건드리지 않는다 — 주석의 들여쓰기를 지켜야 한다.
|
|
261
|
+
const body = empty && /^\s*\}/.test(rest) ? rest.replace(/^\s*/, '') : rest
|
|
262
|
+
const inserted = `${eol} ${pair}${empty ? eol : ','}`
|
|
263
|
+
writeFileSync(strictTarget, text.slice(0, brace + 1) + inserted + body, { encoding: 'utf8' })
|
|
264
|
+
}
|
|
265
|
+
return { ok: true, action: 'insert', path: rel }
|
|
266
|
+
})
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
// flow.mjs와 update.mjs가 같은 실패 격리를 쓴다 — 어댑터 하나가 실패해도
|
|
270
|
+
// 나머지를 계속해야 한다.
|
|
271
|
+
export function configureAdapterSafe(root, entry, ctx) {
|
|
272
|
+
try {
|
|
273
|
+
return configureAdapter(root, entry, ctx)
|
|
274
|
+
} catch (err) {
|
|
275
|
+
// err.message는 항상 영어다(LocalizedError 또는 원시 Error) — 여기서는
|
|
276
|
+
// 구조화하지 않고 그대로 옮긴다. flow.mjs·bootstrap.flow.test.mjs가
|
|
277
|
+
// 이 사실에 기대어 실패 메시지를 검증한다.
|
|
278
|
+
return { ok: false, action: 'link', path: entry.path, message: err.message }
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
// 갱신 경로. 생성 경로(ensureFiles·ensureBlocks)와 분리한 이유는 계약이
|
|
283
|
+
// 반대이기 때문이다 — 생성은 "있으면 손대지 않는다", 갱신은 "우리가 쓴
|
|
284
|
+
// 그대로면 바꾼다". 한 함수에 두 계약을 넣으면 어느 쪽도 읽히지 않는다.
|
|
285
|
+
//
|
|
286
|
+
// managed[key]가 우리가 마지막으로 쓴 내용의 해시다.
|
|
287
|
+
// 현재 == managed → 우리가 쓴 그대로 → 교체
|
|
288
|
+
// 현재 != managed → 사용자가 고쳤다 → 건드리지 않는다(force면 교체)
|
|
289
|
+
// managed 없음/null → 출처 불명 → 건드리지 않는다(force면 교체)
|
|
290
|
+
// 파일 없음 → 생성 (새 도구 지원이 이 경로로 들어온다)
|
|
291
|
+
function decide(current, recorded, force) {
|
|
292
|
+
if (current === null) return 'create'
|
|
293
|
+
if (recorded && hashBody(current) === recorded) return 'update'
|
|
294
|
+
return force ? 'update' : 'drift'
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
export function updateFiles(root, files, { managed = {}, dryRun = false, force = false, log, t = createT('en') }) {
|
|
298
|
+
return files.map(({ path: rel, template }) => {
|
|
299
|
+
const lexical = repoPath(root, rel)
|
|
300
|
+
const exists = pathExists(lexical)
|
|
301
|
+
let current = null
|
|
302
|
+
if (exists) {
|
|
303
|
+
try {
|
|
304
|
+
current = readFileSync(lexical, 'utf8')
|
|
305
|
+
} catch (err) {
|
|
306
|
+
log(t('log.warn.unreadable', { path: rel, code: err.code ?? err.message }))
|
|
307
|
+
return { ok: true, action: 'warn', path: rel, message: msg('msg.readFailed') }
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
const wanted = hashBody(template)
|
|
312
|
+
// 이미 새 템플릿과 같으면 쓰지 않는다 — 무의미한 mtime 변경과
|
|
313
|
+
// "갱신 N건" 과대 보고를 막는다.
|
|
314
|
+
if (current !== null && hashBody(current) === wanted) {
|
|
315
|
+
return { ok: true, action: 'skip', path: rel, hash: wanted }
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
const verdict = decide(current, managed[rel], force)
|
|
319
|
+
if (verdict === 'drift') {
|
|
320
|
+
log(t('log.file.userEdited', { path: rel }))
|
|
321
|
+
return { ok: true, action: 'drift', path: rel }
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
// 검사가 던지면 하지 않은 동작을 보고하지 않도록 로그보다 먼저 수행한다.
|
|
325
|
+
const target = repoPathStrict(root, rel)
|
|
326
|
+
log(verdict === 'create' ? t('log.file.create', { path: rel }) : t('log.file.update', { path: rel }))
|
|
327
|
+
if (!dryRun) writeText(target, template)
|
|
328
|
+
return { ok: true, action: verdict, path: rel, hash: wanted }
|
|
329
|
+
})
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
export function updateBlocks(root, blocks, { managed = {}, dryRun = false, force = false, log, t = createT('en') }) {
|
|
333
|
+
return blocks.map(({ path: rel, block }) => {
|
|
334
|
+
const key = managedKey(rel, true)
|
|
335
|
+
const wanted = hashBody(extractBlock(block) ?? block)
|
|
336
|
+
|
|
337
|
+
if (!pathExists(repoPath(root, rel))) {
|
|
338
|
+
const target = repoPathStrict(root, rel)
|
|
339
|
+
log(t('log.file.create', { path: rel }))
|
|
340
|
+
if (!dryRun) writeText(target, block)
|
|
341
|
+
return { ok: true, action: 'create', path: rel, hash: wanted }
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
let text
|
|
345
|
+
try {
|
|
346
|
+
text = readFileSync(repoPath(root, rel), 'utf8')
|
|
347
|
+
} catch (err) {
|
|
348
|
+
log(t('log.warn.unreadable', { path: rel, code: err.code ?? err.message }))
|
|
349
|
+
return { ok: true, action: 'warn', path: rel, message: msg('msg.readFailed') }
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
const current = extractBlock(text)
|
|
353
|
+
// 마커가 없는 기존 파일은 갱신 대상이 아니다. 블록을 처음 붙이는 것은
|
|
354
|
+
// 생성 경로(ensureBlocks)의 일이고, 갱신이 append까지 하면 사용자가
|
|
355
|
+
// 일부러 지운 블록을 되살리게 된다.
|
|
356
|
+
if (current === null) {
|
|
357
|
+
log(t('log.block.missing', { path: rel }))
|
|
358
|
+
return { ok: true, action: 'drift', path: rel, message: msg('msg.noManagedBlock') }
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
if (hashBody(current) === wanted) return { ok: true, action: 'skip', path: rel, hash: wanted }
|
|
362
|
+
|
|
363
|
+
if (hashBody(current) !== managed[key] && !force) {
|
|
364
|
+
log(t('log.block.userEdited', { path: rel }))
|
|
365
|
+
return { ok: true, action: 'drift', path: rel }
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
const target = repoPathStrict(root, rel)
|
|
369
|
+
log(t('log.block.update', { path: rel }))
|
|
370
|
+
if (!dryRun) {
|
|
371
|
+
// 마커를 포함한 구간을 통째로 새 블록으로 바꾼다. 앞뒤 사용자 본문은
|
|
372
|
+
// 손대지 않으므로 줄바꿈 스타일도 그대로 남는다.
|
|
373
|
+
const begin = text.indexOf(BEGIN_MARKER)
|
|
374
|
+
const end = text.indexOf(END_MARKER, begin) + END_MARKER.length
|
|
375
|
+
const next = text.slice(0, begin) + normalizeBody(block).trimEnd() + text.slice(end)
|
|
376
|
+
writeFileSync(target, next, { encoding: 'utf8' })
|
|
377
|
+
}
|
|
378
|
+
return { ok: true, action: 'update', path: rel, hash: wanted }
|
|
379
|
+
})
|
|
380
|
+
}
|