@rch4com/agent-setup 1.1.0 → 1.4.0

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.
Files changed (56) hide show
  1. package/LICENSE +21 -21
  2. package/README.md +197 -55
  3. package/install.mjs +152 -101
  4. package/lib/args.mjs +276 -311
  5. package/lib/bootstrap/adapter.mjs +96 -95
  6. package/lib/bootstrap/apply.mjs +380 -379
  7. package/lib/bootstrap/flow.mjs +73 -70
  8. package/lib/bootstrap/record.mjs +154 -142
  9. package/lib/bootstrap/templates.mjs +148 -148
  10. package/lib/bootstrap/text.mjs +19 -19
  11. package/lib/catalog.mjs +213 -145
  12. package/lib/claude-plugins.mjs +64 -64
  13. package/lib/clis.mjs +102 -102
  14. package/lib/context.mjs +66 -65
  15. package/lib/deps.mjs +5 -7
  16. package/lib/design-md/cache/awesome-design-md/LICENSE.md +38 -0
  17. package/lib/design-md/catalog.json +454 -454
  18. package/lib/design-md/catalog.mjs +266 -268
  19. package/lib/design-md/flow.mjs +181 -166
  20. package/lib/design-md/open.mjs +51 -50
  21. package/lib/design-md/providers/awesome-design-md.mjs +124 -114
  22. package/lib/design-md/providers/index.mjs +9 -9
  23. package/lib/design-md/scan.mjs +14 -10
  24. package/lib/engine.mjs +48 -44
  25. package/lib/gitignore.mjs +18 -18
  26. package/lib/i18n/catalog/en.mjs +369 -0
  27. package/lib/i18n/catalog/ko.mjs +359 -0
  28. package/lib/i18n/detect.mjs +39 -0
  29. package/lib/i18n/index.mjs +93 -0
  30. package/lib/items/mcp.codebase-memory.mjs +6 -6
  31. package/lib/items/mcp.graphify.mjs +8 -0
  32. package/lib/items/mcp.headroom.mjs +8 -0
  33. package/lib/items/mcp.notion.mjs +6 -6
  34. package/lib/items/mcp.supabase.mjs +6 -6
  35. package/lib/items/mcp.vercel.mjs +6 -6
  36. package/lib/items/plugin.bkit.mjs +7 -7
  37. package/lib/items/plugin.ecc.mjs +11 -0
  38. package/lib/items/plugin.impeccable.mjs +14 -0
  39. package/lib/items/plugin.mattpocock-skills.mjs +2 -2
  40. package/lib/items/plugin.ponytail.mjs +104 -0
  41. package/lib/items/plugin.superpowers.mjs +7 -7
  42. package/lib/items/plugin.understand-anything.mjs +10 -0
  43. package/lib/items/skill.caveman.mjs +7 -0
  44. package/lib/items/skill.gsd.mjs +25 -24
  45. package/lib/items/skill.gstack.mjs +40 -39
  46. package/lib/items/skill.karpathy.mjs +7 -0
  47. package/lib/items/skill.taste.mjs +9 -0
  48. package/lib/jsonfile.mjs +40 -40
  49. package/lib/status.mjs +93 -92
  50. package/lib/tomlfile.mjs +60 -60
  51. package/lib/tui/render.mjs +41 -63
  52. package/lib/tui/rows.mjs +278 -206
  53. package/lib/tui/run.mjs +64 -28
  54. package/lib/update.mjs +85 -85
  55. package/lib/width.mjs +57 -0
  56. package/package.json +36 -36
@@ -1,268 +1,266 @@
1
- import { existsSync, mkdirSync, writeFileSync, readFileSync, rmSync } from 'node:fs'
2
- import { join, dirname } from 'node:path'
3
- import { fileURLToPath } from 'node:url'
4
- import { createHash } from 'node:crypto'
5
- import { repoPath, repoPathStrict } from '../context.mjs'
6
- import { PROVIDERS, getProvider } from './providers/index.mjs'
7
-
8
- const HERE = dirname(fileURLToPath(import.meta.url))
9
- export const CATALOG_PATH = join(HERE, 'catalog.json')
10
-
11
- export function loadCatalog(file = CATALOG_PATH) {
12
- if (!existsSync(file)) return { updatedAt: null, providers: {} }
13
- const text = readFileSync(file, 'utf8')
14
- if (!text.trim()) return { updatedAt: null, providers: {} }
15
- try {
16
- return JSON.parse(text)
17
- } catch (err) {
18
- // 원시 SyntaxError는 어느 파일이 문제인지도, 어떻게 고치는지도 알려 주지
19
- // 않는다. deps.mjs가 모듈 부재를 안내로 바꾸는 것과 같은 이유다.
20
- throw new Error(
21
- `design.md 카탈로그를 읽을 수 없습니다: ${file}\n${err.message}\n` +
22
- '`design --sync=catalog`로 다시 만들 수 있습니다.',
23
- )
24
- }
25
- }
26
-
27
- export function saveCatalog(catalog, file = CATALOG_PATH) {
28
- writeFileSync(file, JSON.stringify(catalog, null, 2) + '\n')
29
- }
30
-
31
- export function allEntries(catalog) {
32
- return Object.values(catalog.providers ?? {}).flatMap((p) => p.entries ?? [])
33
- }
34
-
35
- export function sha256(text) {
36
- return createHash('sha256').update(text, 'utf8').digest('hex')
37
- }
38
-
39
- // 응답 시간 제한. 맨 fetch에는 제한이 없어, 응답하지 않는 서버를 만나면
40
- // 동기화가 취소할 방법 없이 멈춘다 — 특히 TUI에서는 화면이 정지한 것처럼 보인다.
41
- export const FETCH_TIMEOUT_MS = 20000
42
-
43
- // 응답 본문 상한. DESIGN.md는 수십 KB, 가장 큰 응답인 tree API도 수 MB
44
- // 수준이다. 상한이 없으면 응답하는 서버가 무한 스트림을 흘려보낼 때
45
- // text()가 메모리를 다 쓸 때까지 읽는다 — 시간 제한은 이 경우를 막지 못한다
46
- // (데이터가 계속 오는 동안은 타임아웃이 걸리지 않는다).
47
- export const FETCH_MAX_BYTES = 8 * 1024 * 1024
48
-
49
- async function readCapped(res, limit) {
50
- // body가 없는 구현(테스트의 가짜 fetch 등)은 그대로 읽는다.
51
- if (!res.body?.getReader) return res.text()
52
- const reader = res.body.getReader()
53
- const decoder = new TextDecoder()
54
- let text = ''
55
- let total = 0
56
- for (;;) {
57
- const { done, value } = await reader.read()
58
- if (done) break
59
- total += value.byteLength
60
- if (total > limit) {
61
- await reader.cancel()
62
- throw new Error(`응답이 상한(${limit} 바이트)을 넘었습니다: ${res.url}`)
63
- }
64
- text += decoder.decode(value, { stream: true })
65
- }
66
- return text + decoder.decode()
67
- }
68
-
69
- // 기본 fetch에 위 두 제한을 건다. 주입된 fetchImpl(테스트·대체 구현)은
70
- // 건드리지 않는다.
71
- export async function netFetch(url, opts = {}) {
72
- // maxBytes는 우리 옵션이므로 fetch에 넘기지 않는다(기본값은 FETCH_MAX_BYTES).
73
- const { maxBytes = FETCH_MAX_BYTES, ...init } = opts
74
- const res = await fetch(url, { ...init, signal: init.signal ?? AbortSignal.timeout(FETCH_TIMEOUT_MS) })
75
- // 소비자는 ok·status·text()·json()만 쓴다. text()는 한 번만 읽을 수 있는
76
- // 스트림을 감싸므로 결과를 기억해 두 번 호출에도 견디게 한다.
77
- let body = null
78
- const text = () => (body ??= readCapped(res, maxBytes))
79
- return {
80
- ok: res.ok,
81
- status: res.status,
82
- url: res.url,
83
- headers: res.headers,
84
- text,
85
- json: async () => JSON.parse(await text()),
86
- }
87
- }
88
-
89
- // 경로 세그먼트 하나로 안전한 이름인지 본다. 한글 등 유니코드는 허용하되
90
- // 경로 구분자·상위 이동·Windows 금지 문자·제어문자는 막는다.
91
- // (name은 원격 README 파싱이나 디렉터리 스캔에서 오므로 신뢰 경계 밖이다.)
92
- export function isSafeSegment(text) {
93
- const value = String(text ?? '')
94
- // 선행·후행 점은 숨김 파일이자 Windows에서 다루기 어려운 이름이다('.', '..' 포함).
95
- if (!value || value.startsWith('.') || value.endsWith('.')) return false
96
- // eslint-disable-next-line no-control-regex
97
- return !/[\\/:*?"<>|\x00-\x1f]/.test(value)
98
- }
99
-
100
- // 라벨·카테고리·설명은 원격 README나 DESIGN.md 본문에서 온다. 목록과 TUI가
101
- // 이 값을 터미널에 그대로 찍으므로 제어문자를 걷어낸다 — ANSI 이스케이프가
102
- // 남아 있으면 화면을 지우거나 커서를 옮겨 목록을 위장할 수 있다.
103
- // C0/C1 제어문자에 더해 눈에 보이지 않으면서 표시 순서를 바꾸는 유니코드
104
- // 서식문자도 함께 지운다: bidi 오버라이드·isolate(U+202A~202E, U+2066~2069)는
105
- // 라벨을 거꾸로 그려 다른 항목처럼 보이게 만들고, 폭 없는 문자(U+200B~200F,
106
- // U+FEFF, U+00AD)는 이름이 같아 보이는 서로 다른 항목을 만든다.
107
- export function stripControl(text) {
108
- return String(text ?? '')
109
- // eslint-disable-next-line no-control-regex
110
- .replace(/[\x00-\x1f\x7f-\x9f\u00ad\u061c\u200b-\u200f\u202a-\u202e\u2060-\u2064\u2066-\u206f\ufeff]+/g, ' ')
111
- .trim()
112
- }
113
-
114
- // 설치·번들 경로는 제공자별로 스코프한다: design-md/<provider>/<name>/DESIGN.md.
115
- // 동명 항목이 여러 제공자에 있어도 충돌 없이 공존한다.
116
- // 읽기는 어휘적 경로면 충분하다. 실제로 만들거나 지우는 경로만 링크를 통한
117
- // 저장소 이탈까지 검사한다 — bootstrap의 apply.mjs와 같은 규칙이다.
118
- // design-md가 저장소 밖을 가리키는 링크일 때 uninstall의 재귀 삭제가
119
- // 그 너머까지 닿는 것을 막는다.
120
- function designPaths(root, providerId, name, { strict = false } = {}) {
121
- if (!isSafeSegment(providerId) || !isSafeSegment(name)) {
122
- throw new Error(`잘못된 design.md 식별자: ${providerId}/${name}`)
123
- }
124
- const rel = `design-md/${providerId}/${name}`
125
- const dir = strict ? repoPathStrict(root, rel) : repoPath(root, rel)
126
- return { dir, file: join(dir, 'DESIGN.md') }
127
- }
128
-
129
- // 카탈로그 엔트리 1개를 item 인터페이스(engine.mjs 호환)로 변환한다.
130
- // provider와 fetchImpl을 클로저로 잡아 install/detect가 그것을 사용한다.
131
- export function defineDesignMd(entry, provider, { fetchImpl }) {
132
- const { name, label, category, description } = entry
133
- const providerId = provider.id
134
- return {
135
- id: `design.${providerId}.${name}`,
136
- category: 'design',
137
- providerId,
138
- local: provider.local === true, // 디렉터리 스캔으로 찾은 로컬(사내) 정의
139
- name,
140
- // 화면에 나가는 세 값만 정화한다. name은 isSafeSegment가 이미 걸렀고
141
- // 경로·URL에 쓰이므로 여기서 바꾸면 안 된다.
142
- label: stripControl(label) || name,
143
- designCategory: stripControl(category),
144
- description: stripControl(description),
145
- webUrl: provider.webUrl(name),
146
- // 로컬(디렉터리) 항목은 웹 페이지가 없다 — 미리보기는 원본 파일을 연다.
147
- previewPath: provider.local === true ? provider.fileUrl(name) : null,
148
-
149
- async detect({ root }) {
150
- return { status: existsSync(designPaths(root, providerId, name).file) ? 'installed' : 'absent' }
151
- },
152
-
153
- // fresh=false: 동봉 번들 우선(오프라인) → 없으면 네트워크.
154
- // fresh=true: 번들 우회, 항상 네트워크 최신(업데이트/동기화용).
155
- async install({ root, dryRun, fresh = false, log = () => {} }) {
156
- // dry-run은 네트워크도 쓰기도 하지 않고 예정 동작만 알린다.
157
- if (dryRun) {
158
- log(` [dry-run] design-md/${providerId}/${name}/DESIGN.md 작성`)
159
- return
160
- }
161
- let text = fresh ? null : provider.bundledText?.(name, 'DESIGN.md')
162
- if (text == null) text = await provider.fetchFile(fetchImpl, name, 'DESIGN.md')
163
- if (text == null) throw new Error(`${providerId}/${name}: DESIGN.md 다운로드 실패`)
164
- const { dir, file } = designPaths(root, providerId, name, { strict: true })
165
- mkdirSync(dir, { recursive: true })
166
- writeFileSync(file, text)
167
- },
168
-
169
- async uninstall({ root, dryRun, log = () => {} }) {
170
- if (dryRun) {
171
- log(` [dry-run] design-md/${providerId}/${name} 제거`)
172
- return
173
- }
174
- const { dir } = designPaths(root, providerId, name, { strict: true })
175
- if (existsSync(dir)) rmSync(dir, { recursive: true, force: true })
176
- },
177
-
178
- // 오래된 항목 감지용: 로컬과 원본 DESIGN.md 해시 비교.
179
- localText({ root }) {
180
- const { file } = designPaths(root, providerId, name)
181
- return existsSync(file) ? readFileSync(file, 'utf8') : null
182
- },
183
- async remoteText() {
184
- return provider.fetchFile(fetchImpl, name, 'DESIGN.md')
185
- },
186
- }
187
- }
188
-
189
- // 큐레이션된 카탈로그 값이 우선, 비어 있으면 스캔에서 얻은 값으로 채운다.
190
- function mergeEntry(scanned, curated) {
191
- if (!scanned) return curated
192
- const pick = (a, b) => (a != null && String(a).trim() !== '' ? a : b)
193
- return {
194
- ...scanned,
195
- ...curated,
196
- label: pick(curated.label, scanned.label),
197
- category: pick(curated.category, scanned.category),
198
- description: pick(curated.description, scanned.description),
199
- }
200
- }
201
-
202
- // 카탈로그 + 디렉터리 스캔 소스 → item 배열.
203
- // 디렉터리로 발견한 소스는 프로바이더 등록 없이도 목록에 오르고(사내 오프라인 정의),
204
- // 등록된 프로바이더가 있으면 그쪽(네트워크·웹 미리보기 지원)을 쓴다.
205
- // 카탈로그에만 있고 프로바이더도 스캔 결과도 없는 엔트리는 다룰 방법이 없어 건너뛴다.
206
- // id가 소스별로 유일하므로 동명 항목이 붕괴하지 않는다. (provider, name)순 정렬.
207
- export function buildItems(catalog, { fetchImpl, providers = PROVIDERS, sources = [] } = {}) {
208
- const registered = new Map(providers.map((p) => [p.id, p]))
209
- const blocks = new Map() // sourceId → { provider, entries: Map<name, entry> }
210
-
211
- const blockFor = (id, provider) => {
212
- let block = blocks.get(id)
213
- if (!block) {
214
- block = { provider, entries: new Map() }
215
- blocks.set(id, block)
216
- }
217
- return block
218
- }
219
-
220
- // 1) 디렉터리 스캔 — 등록 여부와 무관하게 목록에 올린다.
221
- for (const source of sources) {
222
- const provider = registered.get(source.id) ?? getProvider(source.id) ?? source.provider
223
- if (!provider) continue
224
- const block = blockFor(source.id, provider)
225
- for (const entry of source.entries ?? []) block.entries.set(entry.name, entry)
226
- }
227
-
228
- // 2) 카탈로그 — 라벨·카테고리·설명을 보강하고, 아직 없는 항목을 추가한다.
229
- for (const [providerId, catalogBlock] of Object.entries(catalog.providers ?? {})) {
230
- const provider = registered.get(providerId) ?? getProvider(providerId) ?? blocks.get(providerId)?.provider
231
- if (!provider) continue
232
- const block = blockFor(providerId, provider)
233
- for (const entry of catalogBlock.entries ?? []) {
234
- block.entries.set(entry.name, mergeEntry(block.entries.get(entry.name), entry))
235
- }
236
- }
237
-
238
- const items = []
239
- for (const block of blocks.values()) {
240
- for (const entry of block.entries.values()) {
241
- // 이름은 원격 README 파싱이나 디렉터리 스캔에서 오므로 신뢰 경계 밖이다.
242
- // 설치 경로도 미리보기 URL도 이 이름을 쓰는데, 경로 검사는 designPaths가
243
- // install 시점에만 한다. item이 만들어지는 이 한 곳에서 걸러 두면
244
- // 목록·미리보기를 포함한 모든 소비자가 함께 안전해진다.
245
- if (!isSafeSegment(entry.name)) continue
246
- items.push(defineDesignMd(entry, block.provider, { fetchImpl }))
247
- }
248
- }
249
- return items.sort((a, b) => a.providerId.localeCompare(b.providerId) || a.name.localeCompare(b.name))
250
- }
251
-
252
- // `--set`/`--preview` 토큰(`name` 또는 `provider/name`)을 item으로 해석한다.
253
- // 이름이 여러 제공자에 걸쳐 중복되면 제공자 지정을 요구한다.
254
- export function resolveTokens(items, tokensStr) {
255
- const out = []
256
- for (const tok of tokensStr.split(',').map((s) => s.trim()).filter(Boolean)) {
257
- const slash = tok.indexOf('/')
258
- const matches = slash >= 0
259
- ? items.filter((i) => i.providerId === tok.slice(0, slash) && i.name === tok.slice(slash + 1))
260
- : items.filter((i) => i.name === tok)
261
- if (matches.length === 0) throw new Error(`알 수 없는 항목: ${tok}`)
262
- if (matches.length > 1) {
263
- throw new Error(`중복된 이름 '${tok}' — 제공자를 지정하세요: ${matches.map((m) => `${m.providerId}/${m.name}`).join(', ')}`)
264
- }
265
- out.push(matches[0])
266
- }
267
- return out
268
- }
1
+ import { existsSync, mkdirSync, writeFileSync, readFileSync, rmSync } from 'node:fs'
2
+ import { join, dirname } from 'node:path'
3
+ import { fileURLToPath } from 'node:url'
4
+ import { createHash } from 'node:crypto'
5
+ import { repoPath, repoPathStrict } from '../context.mjs'
6
+ import { PROVIDERS, getProvider } from './providers/index.mjs'
7
+ import { createT, LocalizedError } from '../i18n/index.mjs'
8
+
9
+ const HERE = dirname(fileURLToPath(import.meta.url))
10
+ export const CATALOG_PATH = join(HERE, 'catalog.json')
11
+
12
+ export function loadCatalog(file = CATALOG_PATH) {
13
+ if (!existsSync(file)) return { updatedAt: null, providers: {} }
14
+ const text = readFileSync(file, 'utf8')
15
+ if (!text.trim()) return { updatedAt: null, providers: {} }
16
+ try {
17
+ return JSON.parse(text)
18
+ } catch (err) {
19
+ // 원시 SyntaxError는 어느 파일이 문제인지도, 어떻게 고치는지도 알려 주지
20
+ // 않는다. deps.mjs가 모듈 부재를 안내로 바꾸는 것과 같은 이유다.
21
+ throw new LocalizedError('error.catalogUnreadable', { path: file, message: err.message })
22
+ }
23
+ }
24
+
25
+ export function saveCatalog(catalog, file = CATALOG_PATH) {
26
+ writeFileSync(file, JSON.stringify(catalog, null, 2) + '\n')
27
+ }
28
+
29
+ export function allEntries(catalog) {
30
+ return Object.values(catalog.providers ?? {}).flatMap((p) => p.entries ?? [])
31
+ }
32
+
33
+ export function sha256(text) {
34
+ return createHash('sha256').update(text, 'utf8').digest('hex')
35
+ }
36
+
37
+ // 응답 시간 제한. 맨 fetch에는 제한이 없어, 응답하지 않는 서버를 만나면
38
+ // 동기화가 취소할 방법 없이 멈춘다 — 특히 TUI에서는 화면이 정지한 것처럼 보인다.
39
+ export const FETCH_TIMEOUT_MS = 20000
40
+
41
+ // 응답 본문 상한. DESIGN.md는 수십 KB, 가장 큰 응답인 tree API도 수 MB
42
+ // 수준이다. 상한이 없으면 응답하는 서버가 무한 스트림을 흘려보낼 때
43
+ // text()가 메모리를 다 쓸 때까지 읽는다 — 시간 제한은 이 경우를 막지 못한다
44
+ // (데이터가 계속 오는 동안은 타임아웃이 걸리지 않는다).
45
+ export const FETCH_MAX_BYTES = 8 * 1024 * 1024
46
+
47
+ async function readCapped(res, limit) {
48
+ // body가 없는 구현(테스트의 가짜 fetch 등)은 그대로 읽는다.
49
+ if (!res.body?.getReader) return res.text()
50
+ const reader = res.body.getReader()
51
+ const decoder = new TextDecoder()
52
+ let text = ''
53
+ let total = 0
54
+ for (;;) {
55
+ const { done, value } = await reader.read()
56
+ if (done) break
57
+ total += value.byteLength
58
+ if (total > limit) {
59
+ await reader.cancel()
60
+ throw new LocalizedError('error.responseTooLarge', { limit, url: res.url })
61
+ }
62
+ text += decoder.decode(value, { stream: true })
63
+ }
64
+ return text + decoder.decode()
65
+ }
66
+
67
+ // 기본 fetch에 위 두 제한을 건다. 주입된 fetchImpl(테스트·대체 구현)은
68
+ // 건드리지 않는다.
69
+ export async function netFetch(url, opts = {}) {
70
+ // maxBytes는 우리 옵션이므로 fetch에 넘기지 않는다(기본값은 FETCH_MAX_BYTES).
71
+ const { maxBytes = FETCH_MAX_BYTES, ...init } = opts
72
+ const res = await fetch(url, { ...init, signal: init.signal ?? AbortSignal.timeout(FETCH_TIMEOUT_MS) })
73
+ // 소비자는 ok·status·text()·json()만 쓴다. text()는 한 번만 읽을 수 있는
74
+ // 스트림을 감싸므로 결과를 기억해 두 번 호출에도 견디게 한다.
75
+ let body = null
76
+ const text = () => (body ??= readCapped(res, maxBytes))
77
+ return {
78
+ ok: res.ok,
79
+ status: res.status,
80
+ url: res.url,
81
+ headers: res.headers,
82
+ text,
83
+ json: async () => JSON.parse(await text()),
84
+ }
85
+ }
86
+
87
+ // 경로 세그먼트 하나로 안전한 이름인지 본다. 한글 등 유니코드는 허용하되
88
+ // 경로 구분자·상위 이동·Windows 금지 문자·제어문자는 막는다.
89
+ // (name은 원격 README 파싱이나 디렉터리 스캔에서 오므로 신뢰 경계 밖이다.)
90
+ export function isSafeSegment(text) {
91
+ const value = String(text ?? '')
92
+ // 선행·후행 점은 숨김 파일이자 Windows에서 다루기 어려운 이름이다('.', '..' 포함).
93
+ if (!value || value.startsWith('.') || value.endsWith('.')) return false
94
+ // eslint-disable-next-line no-control-regex
95
+ return !/[\\/:*?"<>|\x00-\x1f]/.test(value)
96
+ }
97
+
98
+ // 라벨·카테고리·설명은 원격 README나 DESIGN.md 본문에서 온다. 목록과 TUI가
99
+ // 이 값을 터미널에 그대로 찍으므로 제어문자를 걷어낸다 — ANSI 이스케이프가
100
+ // 남아 있으면 화면을 지우거나 커서를 옮겨 목록을 위장할 수 있다.
101
+ // C0/C1 제어문자에 더해 눈에 보이지 않으면서 표시 순서를 바꾸는 유니코드
102
+ // 서식문자도 함께 지운다: bidi 오버라이드·isolate(U+202A~202E, U+2066~2069)는
103
+ // 라벨을 거꾸로 그려 다른 항목처럼 보이게 만들고, 폭 없는 문자(U+200B~200F,
104
+ // U+FEFF, U+00AD)는 이름이 같아 보이는 서로 다른 항목을 만든다.
105
+ export function stripControl(text) {
106
+ return String(text ?? '')
107
+ // eslint-disable-next-line no-control-regex
108
+ .replace(/[\x00-\x1f\x7f-\x9f\u00ad\u061c\u200b-\u200f\u202a-\u202e\u2060-\u2064\u2066-\u206f\ufeff]+/g, ' ')
109
+ .trim()
110
+ }
111
+
112
+ // 설치·번들 경로는 제공자별로 스코프한다: design-md/<provider>/<name>/DESIGN.md.
113
+ // 동명 항목이 여러 제공자에 있어도 충돌 없이 공존한다.
114
+ // 읽기는 어휘적 경로면 충분하다. 실제로 만들거나 지우는 경로만 링크를 통한
115
+ // 저장소 이탈까지 검사한다 — bootstrap의 apply.mjs와 같은 규칙이다.
116
+ // design-md가 저장소 밖을 가리키는 링크일 때 uninstall의 재귀 삭제가
117
+ // 그 너머까지 닿는 것을 막는다.
118
+ function designPaths(root, providerId, name, { strict = false } = {}) {
119
+ if (!isSafeSegment(providerId) || !isSafeSegment(name)) {
120
+ throw new LocalizedError('error.badDesignId', { provider: providerId, name })
121
+ }
122
+ const rel = `design-md/${providerId}/${name}`
123
+ const dir = strict ? repoPathStrict(root, rel) : repoPath(root, rel)
124
+ return { dir, file: join(dir, 'DESIGN.md') }
125
+ }
126
+
127
+ // 카탈로그 엔트리 1개를 item 인터페이스(engine.mjs 호환)로 변환한다.
128
+ // provider와 fetchImpl을 클로저로 잡아 install/detect가 그것을 사용한다.
129
+ export function defineDesignMd(entry, provider, { fetchImpl }) {
130
+ const { name, label, category, description } = entry
131
+ const providerId = provider.id
132
+ return {
133
+ id: `design.${providerId}.${name}`,
134
+ category: 'design',
135
+ providerId,
136
+ local: provider.local === true, // 디렉터리 스캔으로 찾은 로컬(사내) 정의
137
+ name,
138
+ // 화면에 나가는 세 값만 정화한다. name은 isSafeSegment가 이미 걸렀고
139
+ // 경로·URL에 쓰이므로 여기서 바꾸면 안 된다.
140
+ label: stripControl(label) || name,
141
+ designCategory: stripControl(category),
142
+ description: stripControl(description),
143
+ webUrl: provider.webUrl(name),
144
+ // 로컬(디렉터리) 항목은 웹 페이지가 없다 — 미리보기는 원본 파일을 연다.
145
+ previewPath: provider.local === true ? provider.fileUrl(name) : null,
146
+
147
+ async detect({ root }) {
148
+ return { status: existsSync(designPaths(root, providerId, name).file) ? 'installed' : 'absent' }
149
+ },
150
+
151
+ // fresh=false: 동봉 번들 우선(오프라인) → 없으면 네트워크.
152
+ // fresh=true: 번들 우회, 항상 네트워크 최신(업데이트/동기화용).
153
+ async install({ root, dryRun, fresh = false, log = () => {}, t = createT('en') }) {
154
+ // dry-run은 네트워크도 쓰기도 하지 않고 예정 동작만 알린다.
155
+ if (dryRun) {
156
+ log(t('design.write', { provider: providerId, name }))
157
+ return
158
+ }
159
+ let text = fresh ? null : provider.bundledText?.(name, 'DESIGN.md')
160
+ if (text == null) text = await provider.fetchFile(fetchImpl, name, 'DESIGN.md')
161
+ if (text == null) throw new LocalizedError('error.designDownload', { provider: providerId, name })
162
+ const { dir, file } = designPaths(root, providerId, name, { strict: true })
163
+ mkdirSync(dir, { recursive: true })
164
+ writeFileSync(file, text)
165
+ },
166
+
167
+ async uninstall({ root, dryRun, log = () => {}, t = createT('en') }) {
168
+ if (dryRun) {
169
+ log(t('design.remove', { provider: providerId, name }))
170
+ return
171
+ }
172
+ const { dir } = designPaths(root, providerId, name, { strict: true })
173
+ if (existsSync(dir)) rmSync(dir, { recursive: true, force: true })
174
+ },
175
+
176
+ // 오래된 항목 감지용: 로컬과 원본 DESIGN.md 해시 비교.
177
+ localText({ root }) {
178
+ const { file } = designPaths(root, providerId, name)
179
+ return existsSync(file) ? readFileSync(file, 'utf8') : null
180
+ },
181
+ async remoteText() {
182
+ return provider.fetchFile(fetchImpl, name, 'DESIGN.md')
183
+ },
184
+ }
185
+ }
186
+
187
+ // 큐레이션된 카탈로그 값이 우선, 비어 있으면 스캔에서 얻은 값으로 채운다.
188
+ function mergeEntry(scanned, curated) {
189
+ if (!scanned) return curated
190
+ const pick = (a, b) => (a != null && String(a).trim() !== '' ? a : b)
191
+ return {
192
+ ...scanned,
193
+ ...curated,
194
+ label: pick(curated.label, scanned.label),
195
+ category: pick(curated.category, scanned.category),
196
+ description: pick(curated.description, scanned.description),
197
+ }
198
+ }
199
+
200
+ // 카탈로그 + 디렉터리 스캔 소스 → item 배열.
201
+ // 디렉터리로 발견한 소스는 프로바이더 등록 없이도 목록에 오르고(사내 오프라인 정의),
202
+ // 등록된 프로바이더가 있으면 그쪽(네트워크·웹 미리보기 지원)을 쓴다.
203
+ // 카탈로그에만 있고 프로바이더도 스캔 결과도 없는 엔트리는 다룰 방법이 없어 건너뛴다.
204
+ // id가 소스별로 유일하므로 동명 항목이 붕괴하지 않는다. (provider, name)순 정렬.
205
+ export function buildItems(catalog, { fetchImpl, providers = PROVIDERS, sources = [] } = {}) {
206
+ const registered = new Map(providers.map((p) => [p.id, p]))
207
+ const blocks = new Map() // sourceId → { provider, entries: Map<name, entry> }
208
+
209
+ const blockFor = (id, provider) => {
210
+ let block = blocks.get(id)
211
+ if (!block) {
212
+ block = { provider, entries: new Map() }
213
+ blocks.set(id, block)
214
+ }
215
+ return block
216
+ }
217
+
218
+ // 1) 디렉터리 스캔 — 등록 여부와 무관하게 목록에 올린다.
219
+ for (const source of sources) {
220
+ const provider = registered.get(source.id) ?? getProvider(source.id) ?? source.provider
221
+ if (!provider) continue
222
+ const block = blockFor(source.id, provider)
223
+ for (const entry of source.entries ?? []) block.entries.set(entry.name, entry)
224
+ }
225
+
226
+ // 2) 카탈로그 — 라벨·카테고리·설명을 보강하고, 아직 없는 항목을 추가한다.
227
+ for (const [providerId, catalogBlock] of Object.entries(catalog.providers ?? {})) {
228
+ const provider = registered.get(providerId) ?? getProvider(providerId) ?? blocks.get(providerId)?.provider
229
+ if (!provider) continue
230
+ const block = blockFor(providerId, provider)
231
+ for (const entry of catalogBlock.entries ?? []) {
232
+ block.entries.set(entry.name, mergeEntry(block.entries.get(entry.name), entry))
233
+ }
234
+ }
235
+
236
+ const items = []
237
+ for (const block of blocks.values()) {
238
+ for (const entry of block.entries.values()) {
239
+ // 이름은 원격 README 파싱이나 디렉터리 스캔에서 오므로 신뢰 경계 밖이다.
240
+ // 설치 경로도 미리보기 URL도 이 이름을 쓰는데, 경로 검사는 designPaths가
241
+ // install 시점에만 한다. item이 만들어지는 이 한 곳에서 걸러 두면
242
+ // 목록·미리보기를 포함한 모든 소비자가 함께 안전해진다.
243
+ if (!isSafeSegment(entry.name)) continue
244
+ items.push(defineDesignMd(entry, block.provider, { fetchImpl }))
245
+ }
246
+ }
247
+ return items.sort((a, b) => a.providerId.localeCompare(b.providerId) || a.name.localeCompare(b.name))
248
+ }
249
+
250
+ // `--set`/`--preview` 토큰(`name` 또는 `provider/name`)을 item으로 해석한다.
251
+ // 이름이 여러 제공자에 걸쳐 중복되면 제공자 지정을 요구한다.
252
+ export function resolveTokens(items, tokensStr) {
253
+ const out = []
254
+ for (const tok of tokensStr.split(',').map((s) => s.trim()).filter(Boolean)) {
255
+ const slash = tok.indexOf('/')
256
+ const matches = slash >= 0
257
+ ? items.filter((i) => i.providerId === tok.slice(0, slash) && i.name === tok.slice(slash + 1))
258
+ : items.filter((i) => i.name === tok)
259
+ if (matches.length === 0) throw new LocalizedError('error.unknownItem', { id: tok })
260
+ if (matches.length > 1) {
261
+ throw new LocalizedError('error.ambiguousDesignId', { token: tok, options: matches.map((m) => `${m.providerId}/${m.name}`).join(', ') })
262
+ }
263
+ out.push(matches[0])
264
+ }
265
+ return out
266
+ }