@rch4com/agent-setup 1.10.0 → 1.11.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.
Files changed (53) hide show
  1. package/LICENSE +21 -21
  2. package/README.md +199 -199
  3. package/install.mjs +163 -163
  4. package/lib/args.mjs +276 -276
  5. package/lib/bootstrap/adapter.mjs +96 -96
  6. package/lib/bootstrap/apply.mjs +380 -380
  7. package/lib/bootstrap/flow.mjs +140 -140
  8. package/lib/bootstrap/record.mjs +154 -154
  9. package/lib/bootstrap/templates.mjs +148 -148
  10. package/lib/bootstrap/text.mjs +19 -19
  11. package/lib/catalog.mjs +234 -234
  12. package/lib/claude-plugins.mjs +64 -64
  13. package/lib/clis.mjs +111 -111
  14. package/lib/context.mjs +66 -66
  15. package/lib/deps.mjs +17 -17
  16. package/lib/design-md/catalog.json +454 -454
  17. package/lib/design-md/catalog.mjs +266 -266
  18. package/lib/design-md/flow.mjs +192 -192
  19. package/lib/design-md/open.mjs +51 -51
  20. package/lib/design-md/providers/awesome-design-md.mjs +124 -124
  21. package/lib/design-md/providers/index.mjs +9 -9
  22. package/lib/design-md/scan.mjs +295 -295
  23. package/lib/engine.mjs +115 -115
  24. package/lib/gitignore.mjs +18 -18
  25. package/lib/i18n/catalog/en.mjs +431 -426
  26. package/lib/i18n/catalog/ko.mjs +416 -411
  27. package/lib/i18n/detect.mjs +39 -39
  28. package/lib/i18n/index.mjs +93 -93
  29. package/lib/items/mcp.codebase-memory.mjs +6 -6
  30. package/lib/items/mcp.notion.mjs +6 -6
  31. package/lib/items/mcp.supabase.mjs +6 -6
  32. package/lib/items/mcp.vercel.mjs +6 -6
  33. package/lib/items/plugin.bkit.mjs +5 -3
  34. package/lib/items/plugin.ecc.mjs +17 -2
  35. package/lib/items/plugin.mattpocock-skills.mjs +18 -8
  36. package/lib/items/plugin.ponytail.mjs +106 -104
  37. package/lib/items/plugin.superpowers.mjs +7 -4
  38. package/lib/items/plugin.understand-anything.mjs +13 -2
  39. package/lib/items/skill.diagram-design.mjs +14 -0
  40. package/lib/items/skill.gsd.mjs +46 -44
  41. package/lib/items/skill.gstack.mjs +54 -54
  42. package/lib/jsonfile.mjs +40 -40
  43. package/lib/status.mjs +93 -93
  44. package/lib/tomlfile.mjs +60 -60
  45. package/lib/tui/detail.mjs +127 -127
  46. package/lib/tui/progress.mjs +160 -160
  47. package/lib/tui/render.mjs +268 -268
  48. package/lib/tui/rows.mjs +317 -317
  49. package/lib/tui/run.mjs +396 -396
  50. package/lib/tui/state.mjs +249 -249
  51. package/lib/update.mjs +85 -85
  52. package/lib/width.mjs +115 -115
  53. package/package.json +36 -36
@@ -1,295 +1,295 @@
1
- // design.md 디렉터리 스캔 — 프로바이더 등록 없이 **디렉터리 구조만으로** 항목을 찾는다.
2
- // 번들 캐시(lib/design-md/cache/<소스>/…)에 폴더를 넣거나, 외부 경로를
3
- // AGENT_INSTALLER_DESIGN_MD_DIRS 환경변수/`--design-dir`로 지정하면 그대로 목록에 오른다.
4
- // 사내에서 오프라인으로 정의한 DESIGN.md를 코드 수정 없이 포함하기 위한 경로다.
5
- import { existsSync, statSync, readdirSync, readFileSync, openSync, readSync, closeSync } from 'node:fs'
6
- import { join, dirname, basename, resolve, delimiter } from 'node:path'
7
- import { fileURLToPath } from 'node:url'
8
- import { StringDecoder } from 'node:string_decoder'
9
- import { isSafeSegment } from './catalog.mjs'
10
- import { PROVIDERS } from './providers/index.mjs'
11
- import { createT } from '../i18n/index.mjs'
12
-
13
- const HERE = dirname(fileURLToPath(import.meta.url))
14
- export const BUNDLE_DIR = join(HERE, 'cache')
15
-
16
- const DESIGN_FILE = 'DESIGN.md'
17
- const DESIGN_FILE_RE = /^design\.md$/i
18
- const MAX_DEPTH = 5 // <소스>/<카테고리…>/<이름>/DESIGN.md — 과도한 순회 방지
19
- const META_BYTES = 4096 // 메타데이터는 앞부분만 읽는다(전체 로드 회피)
20
- // getdesign.md 형식은 디자인 토큰 전체가 frontmatter라 수 KB에 이른다.
21
- // 닫는 구분자가 첫 창을 넘어가면 이만큼까지 늘려 다시 읽는다.
22
- const META_BYTES_MAX = 65536
23
- const MAX_DESCRIPTION = 200
24
- const SKIP_DIRS = new Set(['node_modules', '.git', '.svn'])
25
-
26
- const FM_OPEN = /^---[ \t]*\r?\n/
27
- const FM_CLOSE = /\r?\n---[ \t]*(\r?\n|$)/
28
-
29
- // 카테고리는 정렬 키이자 그룹 헤더다. 표시 문자열을 그대로 쓰면 번역하는
30
- // 순간 정렬과 그룹 묶기가 어긋나므로 id를 두고 표시할 때만 번역한다.
31
- // tui/rows.mjs의 CATCH_ALL_CATEGORY와 같은 값이어야 한다.
32
- export const BUNDLE_CATEGORY = '__other'
33
- export const LOCAL_CATEGORY = '__local'
34
-
35
- // 설치 경로 `design-md/<소스>/<이름>/`에 쓰이므로 경로에 위험한 문자만 걷어낸다.
36
- // 한글 등 유니코드 이름은 그대로 살린다 — 사내 디렉터리명이 대개 그렇다.
37
- export function sanitizeId(text) {
38
- const cleaned = String(text)
39
- .replace(/[\\/:*?"<>|\x00-\x1f]+/g, '-') // 경로 구분자·Windows 금지 문자·제어문자
40
- .replace(/\s+/g, '-')
41
- .replace(/^[-.]+|[-.]+$/g, '') // 선행·후행 점은 Windows에서 문제가 된다
42
- .replace(/-{2,}/g, '-')
43
- return cleaned && cleaned !== '.' && cleaned !== '..' ? cleaned : 'local'
44
- }
45
-
46
- // `--design-dir` / 환경변수 항목: `<경로>` 또는 `<소스id>=<경로>`.
47
- // id에는 경로 구분자가 올 수 없으므로 `D:\a=b` 같은 경로를 id로 오인하지 않는다.
48
- export function parseDirSpec(spec) {
49
- const text = String(spec).trim()
50
- const m = /^([^=\\/]+)=(.+)$/.exec(text)
51
- const dir = resolve(m ? m[2].trim() : text)
52
- return { id: m ? m[1].trim() : basename(dir), dir }
53
- }
54
-
55
- // 환경변수의 경로 목록. 구분자는 OS 기본(Windows `;`, POSIX `:`).
56
- export function extraDirsFromEnv(env = process.env) {
57
- const raw = env.AGENT_INSTALLER_DESIGN_MD_DIRS
58
- if (!raw) return []
59
- return raw.split(delimiter).map((s) => s.trim()).filter(Boolean)
60
- }
61
-
62
- // 앞부분만 읽는다. 경계에서 멀티바이트 문자가 잘려도 대체 문자가 남지 않도록
63
- // StringDecoder로 불완전 시퀀스를 버린다.
64
- function readHead(file, bytes = META_BYTES) {
65
- const fd = openSync(file, 'r')
66
- try {
67
- const buf = Buffer.alloc(bytes)
68
- const n = readSync(fd, buf, 0, bytes, 0)
69
- return new StringDecoder('utf8').write(buf.subarray(0, n))
70
- } finally {
71
- closeSync(fd)
72
- }
73
- }
74
-
75
- // frontmatter가 첫 창 안에서 닫히지 않으면 한 번 더 크게 읽는다.
76
- function readMetaText(file) {
77
- const head = readHead(file)
78
- if (!FM_OPEN.test(head) || FM_CLOSE.test(head)) return head
79
- return readHead(file, META_BYTES_MAX)
80
- }
81
-
82
- // DESIGN.md 본문에서 라벨·설명·카테고리를 뽑는다.
83
- // frontmatter(있으면)가 우선, 없으면 첫 제목 → 라벨, 첫 문단 → 설명.
84
- export function parseDesignMeta(text) {
85
- const meta = {}
86
- let body = String(text ?? '')
87
-
88
- const open = FM_OPEN.exec(body)
89
- if (open) {
90
- const rest = body.slice(open[0].length)
91
- const close = FM_CLOSE.exec(rest)
92
- // 닫는 구분자를 못 찾으면(읽기 창보다 긴 frontmatter) 본문 후보를 비워
93
- // `version: alpha` 같은 frontmatter 줄이 설명으로 새어 나가지 않게 한다.
94
- const block = close ? rest.slice(0, close.index) : rest
95
- body = close ? rest.slice(close.index + close[0].length) : ''
96
-
97
- const fm = {}
98
- for (const line of block.split(/\r?\n/)) {
99
- // 최상위 키만 본다 — 들여쓴 줄은 디자인 토큰 같은 중첩 값이다.
100
- const kv = /^([A-Za-z_][\w-]*)[ \t]*:[ \t]*(.*)$/.exec(line)
101
- if (!kv) continue
102
- const key = kv[1].toLowerCase()
103
- const value = kv[2].trim().replace(/^["']|["']$/g, '').trim()
104
- if (value) fm[key] ??= value
105
- }
106
- meta.label ??= fm.title || fm.label || fm.name || undefined
107
- meta.category ??= fm.category
108
- meta.description ??= (fm.description || fm.summary)?.slice(0, MAX_DESCRIPTION)
109
- for (const key of Object.keys(meta)) if (meta[key] === undefined) delete meta[key]
110
- }
111
-
112
- let sawHeading = false
113
- let fence = null // 코드블록 안에서는 어떤 줄도 설명으로 쓰지 않는다
114
- for (const raw of body.split(/\r?\n/)) {
115
- const line = raw.trim()
116
- if (!line) continue
117
- const fenceMark = /^(```+|~~~+)/.exec(line)
118
- if (fenceMark) {
119
- if (fence && line.startsWith(fence)) fence = null
120
- else if (!fence) fence = fenceMark[1]
121
- continue
122
- }
123
- if (fence) continue
124
- const heading = /^#{1,3}\s+(.+)$/.exec(line)
125
- if (heading) {
126
- if (!sawHeading) {
127
- meta.label ??= heading[1].trim()
128
- sawHeading = true
129
- }
130
- continue
131
- }
132
- // 주석·표·구분선·목록·인용은 설명으로 쓰지 않는다.
133
- if (/^(<!--|<|\||-{3,}|={3,}|[-*+]\s|\d+\.\s|>)/.test(line)) continue
134
- meta.description ??= line.slice(0, MAX_DESCRIPTION)
135
- if (meta.label && meta.description) break
136
- }
137
- return meta
138
- }
139
-
140
- // 디렉터리 1회 읽기로 하위 폴더와 design 파일을 함께 얻는다.
141
- // 읽을 수 없는 디렉터리(권한·경합)는 목록을 통째로 실패시키지 않고 건너뛴다.
142
- // 심볼릭 링크는 따라가지 않는다(순환 방지) — isDirectory()가 링크에는 false다.
143
- // 파일명은 대소문자를 가리지 않는다: 사내 문서는 `design.md`로 쓰는 경우가 많고,
144
- // 대소문자를 구분하는 파일시스템(Linux·macOS)에서 통째로 누락되면 안 된다.
145
- function readDir(dir) {
146
- try {
147
- if (!existsSync(dir) || !statSync(dir).isDirectory()) return { dirs: [], designFile: null }
148
- const dirents = readdirSync(dir, { withFileTypes: true })
149
- const designs = dirents
150
- .filter((d) => d.isFile() && DESIGN_FILE_RE.test(d.name))
151
- .map((d) => d.name)
152
- .sort() // 여러 표기가 공존하면 결정적으로 고른다
153
- return {
154
- dirs: dirents
155
- .filter((d) => d.isDirectory() && !d.name.startsWith('.') && !SKIP_DIRS.has(d.name))
156
- .map((d) => d.name)
157
- .sort(),
158
- designFile: designs.includes(DESIGN_FILE) ? DESIGN_FILE : (designs[0] ?? null),
159
- }
160
- } catch {
161
- return { dirs: [], designFile: null }
162
- }
163
- }
164
-
165
- function subdirs(dir) {
166
- return readDir(dir).dirs
167
- }
168
-
169
- // 디렉터리 트리에서 DESIGN.md를 찾아 엔트리로 만든다.
170
- // `<루트>/<카테고리…>/<이름>/DESIGN.md` — DESIGN.md가 있는 폴더가 곧 항목(리프)이다.
171
- // 루트 바로 아래 DESIGN.md가 있으면 루트 폴더명이 항목 이름이 된다.
172
- export function scanDesignDir(dir, { defaultCategory = LOCAL_CATEGORY, maxDepth = MAX_DEPTH, log, t = createT('en') } = {}) {
173
- const root = resolve(dir)
174
- const entries = []
175
- const seen = new Set()
176
-
177
- // 설치 경로가 `design-md/<소스>/<이름>/`이라 소스 안에서 이름이 유일해야 한다.
178
- // 카테고리가 다른 동명 항목(웹/버튼, 모바일/버튼)은 카테고리를 접두사로 붙여 살린다.
179
- const uniqueName = (name, segs) => {
180
- if (!seen.has(name)) return name
181
- const prefixed = sanitizeId([...segs.slice(0, -1), name].join('-'))
182
- let candidate = prefixed
183
- for (let n = 2; seen.has(candidate); n++) candidate = `${prefixed}-${n}`
184
- if (log) log(t('design.nameClash', { picked: candidate, path: segs.join('/') }))
185
- return candidate
186
- }
187
-
188
- const visit = (current, segs, depth) => {
189
- const { dirs, designFile } = readDir(current)
190
- if (designFile) {
191
- const file = join(current, designFile)
192
- const found = segs.length > 0 ? segs[segs.length - 1] : basename(root)
193
- if (found && isSafeSegment(found)) {
194
- const name = uniqueName(found, segs)
195
- seen.add(name)
196
- let meta = {}
197
- try {
198
- meta = parseDesignMeta(readMetaText(file))
199
- } catch {
200
- // 읽기 실패는 메타데이터 없이 진행 — 목록에서 빠지지 않게 한다.
201
- }
202
- entries.push({
203
- name,
204
- label: meta.label || found,
205
- category: meta.category || segs.slice(0, -1).join(' / ') || defaultCategory,
206
- description: meta.description || '',
207
- file,
208
- })
209
- }
210
- return // 항목 폴더 안쪽은 더 내려가지 않는다
211
- }
212
- if (depth >= maxDepth) return
213
- for (const sub of dirs) visit(join(current, sub), [...segs, sub], depth + 1)
214
- }
215
-
216
- visit(root, [], 0)
217
- return entries.sort((a, b) => a.name.localeCompare(b.name))
218
- }
219
-
220
- // 스캔한 디렉터리를 프로바이더 인터페이스로 감싼다.
221
- // 네트워크 원본이 없으므로 로컬 파일이 곧 최신본이고, 웹 미리보기 URL은 없다.
222
- export function makeDirProvider({ id, label, dir, entries }) {
223
- const files = new Map(entries.map((e) => [e.name, e.file]))
224
- return {
225
- id,
226
- label,
227
- dir,
228
- local: true,
229
- files: [DESIGN_FILE],
230
-
231
- fileUrl(name) {
232
- return files.get(name) ?? null
233
- },
234
-
235
- webUrl() {
236
- return null // 로컬 정의는 getdesign.md 페이지가 없다
237
- },
238
-
239
- bundledText(name) {
240
- const file = files.get(name)
241
- if (!file || !existsSync(file)) return null
242
- return readFileSync(file, 'utf8')
243
- },
244
-
245
- // fresh 업데이트도 같은 로컬 파일을 읽는다 — 디렉터리가 곧 원본이다.
246
- async fetchFile(fetchImpl, name) {
247
- return this.bundledText(name)
248
- },
249
-
250
- async fetchCatalog() {
251
- return entries.map(({ file, ...rest }) => rest)
252
- },
253
- }
254
- }
255
-
256
- // 번들 캐시 하위 디렉터리 + 지정한 외부 경로를 소스 목록으로 만든다.
257
- // 반환: [{ id, dir, label, bundled, entries, provider }]
258
- export function discoverSources({ bundleDir = BUNDLE_DIR, extraDirs = [], reservedIds, log, t = createT('en') } = {}) {
259
- const sources = []
260
- const usedIds = new Set()
261
- // 등록된 프로바이더 id는 번들 캐시 전용이다. 외부 경로가 그 id를 차지하면
262
- // 로컬 파일 대신 네트워크로 나가버리므로 접미사로 비켜 간다.
263
- const reserved = new Set(reservedIds ?? PROVIDERS.map((p) => p.id))
264
-
265
- const add = ({ id, dir, bundled, defaultCategory }) => {
266
- const entries = scanDesignDir(dir, { defaultCategory, log, t })
267
- if (entries.length === 0) {
268
- if (!bundled && log) log(t('design.noDesignFile', { path: dir }))
269
- return
270
- }
271
- const base = sanitizeId(id)
272
- const taken = (candidate) => usedIds.has(candidate) || (!bundled && reserved.has(candidate))
273
- let uid = base
274
- for (let n = 2; taken(uid); n++) uid = `${base}-${n}`
275
- if (uid !== base && log) log(t('design.sourceIdClash', { picked: uid, path: dir }))
276
- usedIds.add(uid)
277
- const label = bundled ? uid : t('design.localSuffix', { id: uid })
278
- sources.push({ id: uid, dir, label, bundled, entries, provider: makeDirProvider({ id: uid, label, dir, entries }) })
279
- }
280
-
281
- for (const name of subdirs(bundleDir)) {
282
- // 캐시에 직접 넣은 사내 소스는 등록 프로바이더가 없다 — 기본 카테고리를 달리 준다.
283
- const defaultCategory = reserved.has(sanitizeId(name)) ? BUNDLE_CATEGORY : LOCAL_CATEGORY
284
- add({ id: name, dir: join(bundleDir, name), bundled: true, defaultCategory })
285
- }
286
- for (const spec of extraDirs) {
287
- const { id, dir } = parseDirSpec(spec)
288
- if (!existsSync(dir)) {
289
- if (log) log(t('design.pathMissing', { path: dir }))
290
- continue
291
- }
292
- add({ id, dir, bundled: false, defaultCategory: LOCAL_CATEGORY })
293
- }
294
- return sources
295
- }
1
+ // design.md 디렉터리 스캔 — 프로바이더 등록 없이 **디렉터리 구조만으로** 항목을 찾는다.
2
+ // 번들 캐시(lib/design-md/cache/<소스>/…)에 폴더를 넣거나, 외부 경로를
3
+ // AGENT_INSTALLER_DESIGN_MD_DIRS 환경변수/`--design-dir`로 지정하면 그대로 목록에 오른다.
4
+ // 사내에서 오프라인으로 정의한 DESIGN.md를 코드 수정 없이 포함하기 위한 경로다.
5
+ import { existsSync, statSync, readdirSync, readFileSync, openSync, readSync, closeSync } from 'node:fs'
6
+ import { join, dirname, basename, resolve, delimiter } from 'node:path'
7
+ import { fileURLToPath } from 'node:url'
8
+ import { StringDecoder } from 'node:string_decoder'
9
+ import { isSafeSegment } from './catalog.mjs'
10
+ import { PROVIDERS } from './providers/index.mjs'
11
+ import { createT } from '../i18n/index.mjs'
12
+
13
+ const HERE = dirname(fileURLToPath(import.meta.url))
14
+ export const BUNDLE_DIR = join(HERE, 'cache')
15
+
16
+ const DESIGN_FILE = 'DESIGN.md'
17
+ const DESIGN_FILE_RE = /^design\.md$/i
18
+ const MAX_DEPTH = 5 // <소스>/<카테고리…>/<이름>/DESIGN.md — 과도한 순회 방지
19
+ const META_BYTES = 4096 // 메타데이터는 앞부분만 읽는다(전체 로드 회피)
20
+ // getdesign.md 형식은 디자인 토큰 전체가 frontmatter라 수 KB에 이른다.
21
+ // 닫는 구분자가 첫 창을 넘어가면 이만큼까지 늘려 다시 읽는다.
22
+ const META_BYTES_MAX = 65536
23
+ const MAX_DESCRIPTION = 200
24
+ const SKIP_DIRS = new Set(['node_modules', '.git', '.svn'])
25
+
26
+ const FM_OPEN = /^---[ \t]*\r?\n/
27
+ const FM_CLOSE = /\r?\n---[ \t]*(\r?\n|$)/
28
+
29
+ // 카테고리는 정렬 키이자 그룹 헤더다. 표시 문자열을 그대로 쓰면 번역하는
30
+ // 순간 정렬과 그룹 묶기가 어긋나므로 id를 두고 표시할 때만 번역한다.
31
+ // tui/rows.mjs의 CATCH_ALL_CATEGORY와 같은 값이어야 한다.
32
+ export const BUNDLE_CATEGORY = '__other'
33
+ export const LOCAL_CATEGORY = '__local'
34
+
35
+ // 설치 경로 `design-md/<소스>/<이름>/`에 쓰이므로 경로에 위험한 문자만 걷어낸다.
36
+ // 한글 등 유니코드 이름은 그대로 살린다 — 사내 디렉터리명이 대개 그렇다.
37
+ export function sanitizeId(text) {
38
+ const cleaned = String(text)
39
+ .replace(/[\\/:*?"<>|\x00-\x1f]+/g, '-') // 경로 구분자·Windows 금지 문자·제어문자
40
+ .replace(/\s+/g, '-')
41
+ .replace(/^[-.]+|[-.]+$/g, '') // 선행·후행 점은 Windows에서 문제가 된다
42
+ .replace(/-{2,}/g, '-')
43
+ return cleaned && cleaned !== '.' && cleaned !== '..' ? cleaned : 'local'
44
+ }
45
+
46
+ // `--design-dir` / 환경변수 항목: `<경로>` 또는 `<소스id>=<경로>`.
47
+ // id에는 경로 구분자가 올 수 없으므로 `D:\a=b` 같은 경로를 id로 오인하지 않는다.
48
+ export function parseDirSpec(spec) {
49
+ const text = String(spec).trim()
50
+ const m = /^([^=\\/]+)=(.+)$/.exec(text)
51
+ const dir = resolve(m ? m[2].trim() : text)
52
+ return { id: m ? m[1].trim() : basename(dir), dir }
53
+ }
54
+
55
+ // 환경변수의 경로 목록. 구분자는 OS 기본(Windows `;`, POSIX `:`).
56
+ export function extraDirsFromEnv(env = process.env) {
57
+ const raw = env.AGENT_INSTALLER_DESIGN_MD_DIRS
58
+ if (!raw) return []
59
+ return raw.split(delimiter).map((s) => s.trim()).filter(Boolean)
60
+ }
61
+
62
+ // 앞부분만 읽는다. 경계에서 멀티바이트 문자가 잘려도 대체 문자가 남지 않도록
63
+ // StringDecoder로 불완전 시퀀스를 버린다.
64
+ function readHead(file, bytes = META_BYTES) {
65
+ const fd = openSync(file, 'r')
66
+ try {
67
+ const buf = Buffer.alloc(bytes)
68
+ const n = readSync(fd, buf, 0, bytes, 0)
69
+ return new StringDecoder('utf8').write(buf.subarray(0, n))
70
+ } finally {
71
+ closeSync(fd)
72
+ }
73
+ }
74
+
75
+ // frontmatter가 첫 창 안에서 닫히지 않으면 한 번 더 크게 읽는다.
76
+ function readMetaText(file) {
77
+ const head = readHead(file)
78
+ if (!FM_OPEN.test(head) || FM_CLOSE.test(head)) return head
79
+ return readHead(file, META_BYTES_MAX)
80
+ }
81
+
82
+ // DESIGN.md 본문에서 라벨·설명·카테고리를 뽑는다.
83
+ // frontmatter(있으면)가 우선, 없으면 첫 제목 → 라벨, 첫 문단 → 설명.
84
+ export function parseDesignMeta(text) {
85
+ const meta = {}
86
+ let body = String(text ?? '')
87
+
88
+ const open = FM_OPEN.exec(body)
89
+ if (open) {
90
+ const rest = body.slice(open[0].length)
91
+ const close = FM_CLOSE.exec(rest)
92
+ // 닫는 구분자를 못 찾으면(읽기 창보다 긴 frontmatter) 본문 후보를 비워
93
+ // `version: alpha` 같은 frontmatter 줄이 설명으로 새어 나가지 않게 한다.
94
+ const block = close ? rest.slice(0, close.index) : rest
95
+ body = close ? rest.slice(close.index + close[0].length) : ''
96
+
97
+ const fm = {}
98
+ for (const line of block.split(/\r?\n/)) {
99
+ // 최상위 키만 본다 — 들여쓴 줄은 디자인 토큰 같은 중첩 값이다.
100
+ const kv = /^([A-Za-z_][\w-]*)[ \t]*:[ \t]*(.*)$/.exec(line)
101
+ if (!kv) continue
102
+ const key = kv[1].toLowerCase()
103
+ const value = kv[2].trim().replace(/^["']|["']$/g, '').trim()
104
+ if (value) fm[key] ??= value
105
+ }
106
+ meta.label ??= fm.title || fm.label || fm.name || undefined
107
+ meta.category ??= fm.category
108
+ meta.description ??= (fm.description || fm.summary)?.slice(0, MAX_DESCRIPTION)
109
+ for (const key of Object.keys(meta)) if (meta[key] === undefined) delete meta[key]
110
+ }
111
+
112
+ let sawHeading = false
113
+ let fence = null // 코드블록 안에서는 어떤 줄도 설명으로 쓰지 않는다
114
+ for (const raw of body.split(/\r?\n/)) {
115
+ const line = raw.trim()
116
+ if (!line) continue
117
+ const fenceMark = /^(```+|~~~+)/.exec(line)
118
+ if (fenceMark) {
119
+ if (fence && line.startsWith(fence)) fence = null
120
+ else if (!fence) fence = fenceMark[1]
121
+ continue
122
+ }
123
+ if (fence) continue
124
+ const heading = /^#{1,3}\s+(.+)$/.exec(line)
125
+ if (heading) {
126
+ if (!sawHeading) {
127
+ meta.label ??= heading[1].trim()
128
+ sawHeading = true
129
+ }
130
+ continue
131
+ }
132
+ // 주석·표·구분선·목록·인용은 설명으로 쓰지 않는다.
133
+ if (/^(<!--|<|\||-{3,}|={3,}|[-*+]\s|\d+\.\s|>)/.test(line)) continue
134
+ meta.description ??= line.slice(0, MAX_DESCRIPTION)
135
+ if (meta.label && meta.description) break
136
+ }
137
+ return meta
138
+ }
139
+
140
+ // 디렉터리 1회 읽기로 하위 폴더와 design 파일을 함께 얻는다.
141
+ // 읽을 수 없는 디렉터리(권한·경합)는 목록을 통째로 실패시키지 않고 건너뛴다.
142
+ // 심볼릭 링크는 따라가지 않는다(순환 방지) — isDirectory()가 링크에는 false다.
143
+ // 파일명은 대소문자를 가리지 않는다: 사내 문서는 `design.md`로 쓰는 경우가 많고,
144
+ // 대소문자를 구분하는 파일시스템(Linux·macOS)에서 통째로 누락되면 안 된다.
145
+ function readDir(dir) {
146
+ try {
147
+ if (!existsSync(dir) || !statSync(dir).isDirectory()) return { dirs: [], designFile: null }
148
+ const dirents = readdirSync(dir, { withFileTypes: true })
149
+ const designs = dirents
150
+ .filter((d) => d.isFile() && DESIGN_FILE_RE.test(d.name))
151
+ .map((d) => d.name)
152
+ .sort() // 여러 표기가 공존하면 결정적으로 고른다
153
+ return {
154
+ dirs: dirents
155
+ .filter((d) => d.isDirectory() && !d.name.startsWith('.') && !SKIP_DIRS.has(d.name))
156
+ .map((d) => d.name)
157
+ .sort(),
158
+ designFile: designs.includes(DESIGN_FILE) ? DESIGN_FILE : (designs[0] ?? null),
159
+ }
160
+ } catch {
161
+ return { dirs: [], designFile: null }
162
+ }
163
+ }
164
+
165
+ function subdirs(dir) {
166
+ return readDir(dir).dirs
167
+ }
168
+
169
+ // 디렉터리 트리에서 DESIGN.md를 찾아 엔트리로 만든다.
170
+ // `<루트>/<카테고리…>/<이름>/DESIGN.md` — DESIGN.md가 있는 폴더가 곧 항목(리프)이다.
171
+ // 루트 바로 아래 DESIGN.md가 있으면 루트 폴더명이 항목 이름이 된다.
172
+ export function scanDesignDir(dir, { defaultCategory = LOCAL_CATEGORY, maxDepth = MAX_DEPTH, log, t = createT('en') } = {}) {
173
+ const root = resolve(dir)
174
+ const entries = []
175
+ const seen = new Set()
176
+
177
+ // 설치 경로가 `design-md/<소스>/<이름>/`이라 소스 안에서 이름이 유일해야 한다.
178
+ // 카테고리가 다른 동명 항목(웹/버튼, 모바일/버튼)은 카테고리를 접두사로 붙여 살린다.
179
+ const uniqueName = (name, segs) => {
180
+ if (!seen.has(name)) return name
181
+ const prefixed = sanitizeId([...segs.slice(0, -1), name].join('-'))
182
+ let candidate = prefixed
183
+ for (let n = 2; seen.has(candidate); n++) candidate = `${prefixed}-${n}`
184
+ if (log) log(t('design.nameClash', { picked: candidate, path: segs.join('/') }))
185
+ return candidate
186
+ }
187
+
188
+ const visit = (current, segs, depth) => {
189
+ const { dirs, designFile } = readDir(current)
190
+ if (designFile) {
191
+ const file = join(current, designFile)
192
+ const found = segs.length > 0 ? segs[segs.length - 1] : basename(root)
193
+ if (found && isSafeSegment(found)) {
194
+ const name = uniqueName(found, segs)
195
+ seen.add(name)
196
+ let meta = {}
197
+ try {
198
+ meta = parseDesignMeta(readMetaText(file))
199
+ } catch {
200
+ // 읽기 실패는 메타데이터 없이 진행 — 목록에서 빠지지 않게 한다.
201
+ }
202
+ entries.push({
203
+ name,
204
+ label: meta.label || found,
205
+ category: meta.category || segs.slice(0, -1).join(' / ') || defaultCategory,
206
+ description: meta.description || '',
207
+ file,
208
+ })
209
+ }
210
+ return // 항목 폴더 안쪽은 더 내려가지 않는다
211
+ }
212
+ if (depth >= maxDepth) return
213
+ for (const sub of dirs) visit(join(current, sub), [...segs, sub], depth + 1)
214
+ }
215
+
216
+ visit(root, [], 0)
217
+ return entries.sort((a, b) => a.name.localeCompare(b.name))
218
+ }
219
+
220
+ // 스캔한 디렉터리를 프로바이더 인터페이스로 감싼다.
221
+ // 네트워크 원본이 없으므로 로컬 파일이 곧 최신본이고, 웹 미리보기 URL은 없다.
222
+ export function makeDirProvider({ id, label, dir, entries }) {
223
+ const files = new Map(entries.map((e) => [e.name, e.file]))
224
+ return {
225
+ id,
226
+ label,
227
+ dir,
228
+ local: true,
229
+ files: [DESIGN_FILE],
230
+
231
+ fileUrl(name) {
232
+ return files.get(name) ?? null
233
+ },
234
+
235
+ webUrl() {
236
+ return null // 로컬 정의는 getdesign.md 페이지가 없다
237
+ },
238
+
239
+ bundledText(name) {
240
+ const file = files.get(name)
241
+ if (!file || !existsSync(file)) return null
242
+ return readFileSync(file, 'utf8')
243
+ },
244
+
245
+ // fresh 업데이트도 같은 로컬 파일을 읽는다 — 디렉터리가 곧 원본이다.
246
+ async fetchFile(fetchImpl, name) {
247
+ return this.bundledText(name)
248
+ },
249
+
250
+ async fetchCatalog() {
251
+ return entries.map(({ file, ...rest }) => rest)
252
+ },
253
+ }
254
+ }
255
+
256
+ // 번들 캐시 하위 디렉터리 + 지정한 외부 경로를 소스 목록으로 만든다.
257
+ // 반환: [{ id, dir, label, bundled, entries, provider }]
258
+ export function discoverSources({ bundleDir = BUNDLE_DIR, extraDirs = [], reservedIds, log, t = createT('en') } = {}) {
259
+ const sources = []
260
+ const usedIds = new Set()
261
+ // 등록된 프로바이더 id는 번들 캐시 전용이다. 외부 경로가 그 id를 차지하면
262
+ // 로컬 파일 대신 네트워크로 나가버리므로 접미사로 비켜 간다.
263
+ const reserved = new Set(reservedIds ?? PROVIDERS.map((p) => p.id))
264
+
265
+ const add = ({ id, dir, bundled, defaultCategory }) => {
266
+ const entries = scanDesignDir(dir, { defaultCategory, log, t })
267
+ if (entries.length === 0) {
268
+ if (!bundled && log) log(t('design.noDesignFile', { path: dir }))
269
+ return
270
+ }
271
+ const base = sanitizeId(id)
272
+ const taken = (candidate) => usedIds.has(candidate) || (!bundled && reserved.has(candidate))
273
+ let uid = base
274
+ for (let n = 2; taken(uid); n++) uid = `${base}-${n}`
275
+ if (uid !== base && log) log(t('design.sourceIdClash', { picked: uid, path: dir }))
276
+ usedIds.add(uid)
277
+ const label = bundled ? uid : t('design.localSuffix', { id: uid })
278
+ sources.push({ id: uid, dir, label, bundled, entries, provider: makeDirProvider({ id: uid, label, dir, entries }) })
279
+ }
280
+
281
+ for (const name of subdirs(bundleDir)) {
282
+ // 캐시에 직접 넣은 사내 소스는 등록 프로바이더가 없다 — 기본 카테고리를 달리 준다.
283
+ const defaultCategory = reserved.has(sanitizeId(name)) ? BUNDLE_CATEGORY : LOCAL_CATEGORY
284
+ add({ id: name, dir: join(bundleDir, name), bundled: true, defaultCategory })
285
+ }
286
+ for (const spec of extraDirs) {
287
+ const { id, dir } = parseDirSpec(spec)
288
+ if (!existsSync(dir)) {
289
+ if (log) log(t('design.pathMissing', { path: dir }))
290
+ continue
291
+ }
292
+ add({ id, dir, bundled: false, defaultCategory: LOCAL_CATEGORY })
293
+ }
294
+ return sources
295
+ }