@rch4com/agent-setup 1.0.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.
- package/LICENSE +21 -0
- package/README.md +49 -0
- package/install.mjs +83 -0
- package/lib/args.mjs +266 -0
- package/lib/bootstrap/adapter.mjs +95 -0
- package/lib/bootstrap/apply.mjs +269 -0
- package/lib/bootstrap/flow.mjs +54 -0
- package/lib/bootstrap/manifest.mjs +74 -0
- package/lib/bootstrap/templates.mjs +148 -0
- package/lib/catalog.mjs +145 -0
- package/lib/claude-plugins.mjs +64 -0
- package/lib/clis.mjs +102 -0
- package/lib/context.mjs +65 -0
- package/lib/deps.mjs +19 -0
- package/lib/design-md/cache/awesome-design-md/airbnb/DESIGN.md +545 -0
- package/lib/design-md/cache/awesome-design-md/airtable/DESIGN.md +554 -0
- package/lib/design-md/cache/awesome-design-md/apple/DESIGN.md +562 -0
- package/lib/design-md/cache/awesome-design-md/binance/DESIGN.md +634 -0
- package/lib/design-md/cache/awesome-design-md/bmw/DESIGN.md +544 -0
- package/lib/design-md/cache/awesome-design-md/bmw-m/DESIGN.md +503 -0
- package/lib/design-md/cache/awesome-design-md/bugatti/DESIGN.md +454 -0
- package/lib/design-md/cache/awesome-design-md/cal/DESIGN.md +542 -0
- package/lib/design-md/cache/awesome-design-md/claude/DESIGN.md +589 -0
- package/lib/design-md/cache/awesome-design-md/clay/DESIGN.md +541 -0
- package/lib/design-md/cache/awesome-design-md/clickhouse/DESIGN.md +544 -0
- package/lib/design-md/cache/awesome-design-md/cohere/DESIGN.md +451 -0
- package/lib/design-md/cache/awesome-design-md/coinbase/DESIGN.md +570 -0
- package/lib/design-md/cache/awesome-design-md/composio/DESIGN.md +506 -0
- package/lib/design-md/cache/awesome-design-md/cursor/DESIGN.md +537 -0
- package/lib/design-md/cache/awesome-design-md/dell-1996/DESIGN.md +632 -0
- package/lib/design-md/cache/awesome-design-md/elevenlabs/DESIGN.md +504 -0
- package/lib/design-md/cache/awesome-design-md/expo/DESIGN.md +526 -0
- package/lib/design-md/cache/awesome-design-md/ferrari/DESIGN.md +531 -0
- package/lib/design-md/cache/awesome-design-md/figma/DESIGN.md +578 -0
- package/lib/design-md/cache/awesome-design-md/framer/DESIGN.md +544 -0
- package/lib/design-md/cache/awesome-design-md/hashicorp/DESIGN.md +575 -0
- package/lib/design-md/cache/awesome-design-md/hp/DESIGN.md +670 -0
- package/lib/design-md/cache/awesome-design-md/ibm/DESIGN.md +550 -0
- package/lib/design-md/cache/awesome-design-md/intercom/DESIGN.md +546 -0
- package/lib/design-md/cache/awesome-design-md/kraken/DESIGN.md +125 -0
- package/lib/design-md/cache/awesome-design-md/lamborghini/DESIGN.md +288 -0
- package/lib/design-md/cache/awesome-design-md/linear.app/DESIGN.md +548 -0
- package/lib/design-md/cache/awesome-design-md/lovable/DESIGN.md +298 -0
- package/lib/design-md/cache/awesome-design-md/mastercard/DESIGN.md +365 -0
- package/lib/design-md/cache/awesome-design-md/meta/DESIGN.md +683 -0
- package/lib/design-md/cache/awesome-design-md/minimax/DESIGN.md +746 -0
- package/lib/design-md/cache/awesome-design-md/mintlify/DESIGN.md +852 -0
- package/lib/design-md/cache/awesome-design-md/miro/DESIGN.md +825 -0
- package/lib/design-md/cache/awesome-design-md/mistral.ai/DESIGN.md +773 -0
- package/lib/design-md/cache/awesome-design-md/mongodb/DESIGN.md +767 -0
- package/lib/design-md/cache/awesome-design-md/nike/DESIGN.md +575 -0
- package/lib/design-md/cache/awesome-design-md/nintendo-2001/DESIGN.md +649 -0
- package/lib/design-md/cache/awesome-design-md/notion/DESIGN.md +821 -0
- package/lib/design-md/cache/awesome-design-md/nvidia/DESIGN.md +640 -0
- package/lib/design-md/cache/awesome-design-md/ollama/DESIGN.md +539 -0
- package/lib/design-md/cache/awesome-design-md/opencode.ai/DESIGN.md +521 -0
- package/lib/design-md/cache/awesome-design-md/pinterest/DESIGN.md +597 -0
- package/lib/design-md/cache/awesome-design-md/playstation/DESIGN.md +661 -0
- package/lib/design-md/cache/awesome-design-md/posthog/DESIGN.md +690 -0
- package/lib/design-md/cache/awesome-design-md/raycast/DESIGN.md +669 -0
- package/lib/design-md/cache/awesome-design-md/renault/DESIGN.md +589 -0
- package/lib/design-md/cache/awesome-design-md/replicate/DESIGN.md +616 -0
- package/lib/design-md/cache/awesome-design-md/resend/DESIGN.md +585 -0
- package/lib/design-md/cache/awesome-design-md/revolut/DESIGN.md +636 -0
- package/lib/design-md/cache/awesome-design-md/runwayml/DESIGN.md +244 -0
- package/lib/design-md/cache/awesome-design-md/sanity/DESIGN.md +357 -0
- package/lib/design-md/cache/awesome-design-md/sentry/DESIGN.md +551 -0
- package/lib/design-md/cache/awesome-design-md/shopify/DESIGN.md +516 -0
- package/lib/design-md/cache/awesome-design-md/slack/DESIGN.md +482 -0
- package/lib/design-md/cache/awesome-design-md/spacex/DESIGN.md +363 -0
- package/lib/design-md/cache/awesome-design-md/spotify/DESIGN.md +246 -0
- package/lib/design-md/cache/awesome-design-md/starbucks/DESIGN.md +580 -0
- package/lib/design-md/cache/awesome-design-md/stripe/DESIGN.md +487 -0
- package/lib/design-md/cache/awesome-design-md/supabase/DESIGN.md +462 -0
- package/lib/design-md/cache/awesome-design-md/superhuman/DESIGN.md +448 -0
- package/lib/design-md/cache/awesome-design-md/tesla/DESIGN.md +286 -0
- package/lib/design-md/cache/awesome-design-md/theverge/DESIGN.md +339 -0
- package/lib/design-md/cache/awesome-design-md/together.ai/DESIGN.md +633 -0
- package/lib/design-md/cache/awesome-design-md/uber/DESIGN.md +636 -0
- package/lib/design-md/cache/awesome-design-md/vercel/DESIGN.md +736 -0
- package/lib/design-md/cache/awesome-design-md/vodafone/DESIGN.md +538 -0
- package/lib/design-md/cache/awesome-design-md/voltagent/DESIGN.md +521 -0
- package/lib/design-md/cache/awesome-design-md/warp/DESIGN.md +526 -0
- package/lib/design-md/cache/awesome-design-md/webflow/DESIGN.md +588 -0
- package/lib/design-md/cache/awesome-design-md/wired/DESIGN.md +497 -0
- package/lib/design-md/cache/awesome-design-md/wise/DESIGN.md +544 -0
- package/lib/design-md/cache/awesome-design-md/x.ai/DESIGN.md +465 -0
- package/lib/design-md/cache/awesome-design-md/zapier/DESIGN.md +537 -0
- package/lib/design-md/cache/feelanet/faux/design.md +288 -0
- package/lib/design-md/cache/feelanet/faux-teams/design.md +382 -0
- package/lib/design-md/catalog.json +454 -0
- package/lib/design-md/catalog.mjs +268 -0
- package/lib/design-md/flow.mjs +166 -0
- package/lib/design-md/open.mjs +50 -0
- package/lib/design-md/providers/awesome-design-md.mjs +114 -0
- package/lib/design-md/providers/index.mjs +9 -0
- package/lib/design-md/scan.mjs +291 -0
- package/lib/engine.mjs +44 -0
- package/lib/gitignore.mjs +18 -0
- package/lib/items/.omc/state/sessions/a859aff7-59d9-4ff6-afc6-8cafe4471dc4/pre-tool-advisory-throttle.json +14 -0
- package/lib/items/mcp.codebase-memory.mjs +6 -0
- package/lib/items/mcp.notion.mjs +6 -0
- package/lib/items/mcp.supabase.mjs +6 -0
- package/lib/items/mcp.vercel.mjs +6 -0
- package/lib/items/plugin.bkit.mjs +7 -0
- package/lib/items/plugin.mattpocock-skills.mjs +8 -0
- package/lib/items/plugin.superpowers.mjs +7 -0
- package/lib/items/skill.gsd.mjs +24 -0
- package/lib/items/skill.gstack.mjs +39 -0
- package/lib/jsonfile.mjs +40 -0
- package/lib/tomlfile.mjs +60 -0
- package/lib/tui/render.mjs +192 -0
- package/lib/tui/rows.mjs +206 -0
- package/lib/tui/run.mjs +257 -0
- package/lib/tui/state.mjs +188 -0
- package/package.json +36 -0
|
@@ -0,0 +1,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
|
+
|
|
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
|
+
}
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
import { scan, planChanges, apply } from '../engine.mjs'
|
|
2
|
+
import { loadCatalog, saveCatalog, buildItems, allEntries, sha256, resolveTokens, netFetch, CATALOG_PATH } from './catalog.mjs'
|
|
3
|
+
import { PROVIDERS } from './providers/index.mjs'
|
|
4
|
+
import { discoverSources, extraDirsFromEnv } from './scan.mjs'
|
|
5
|
+
import { makeOpener, openPreview } from './open.mjs'
|
|
6
|
+
|
|
7
|
+
const STATUS_LABEL = { installed: '설치됨', partial: '일부', absent: '미설치' }
|
|
8
|
+
|
|
9
|
+
function report(results, log) {
|
|
10
|
+
const ACTION = { install: '설치', complete: '보완', uninstall: '제거' }
|
|
11
|
+
for (const r of results) {
|
|
12
|
+
log(` ${r.ok ? '✔' : '✖'} ${ACTION[r.action]} ${r.item.label}${r.message ? ` — ${r.message}` : ''}`)
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
// ── 비대화형/공용 코어 ─────────────────────────────────────────────
|
|
17
|
+
|
|
18
|
+
function printList(states, log) {
|
|
19
|
+
const byProvider = new Map()
|
|
20
|
+
for (const s of states) {
|
|
21
|
+
if (!byProvider.has(s.item.providerId)) byProvider.set(s.item.providerId, [])
|
|
22
|
+
byProvider.get(s.item.providerId).push(s)
|
|
23
|
+
}
|
|
24
|
+
for (const [pid, group] of byProvider) {
|
|
25
|
+
log(`[${pid}${group[0].item.local ? ' · 로컬' : ''}]`)
|
|
26
|
+
for (const s of group) {
|
|
27
|
+
log(` ${STATUS_LABEL[s.status].padEnd(4)} ${s.item.name} — ${s.item.label} [${s.item.designCategory}]`)
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// 미리보기는 관대하게 해석한다: 알 수 없거나 중복된 토큰은 예외 없이 안내하고 건너뛴다.
|
|
33
|
+
function openPreviews(items, tokensStr, opener, log) {
|
|
34
|
+
for (const tok of tokensStr.split(',').map((s) => s.trim()).filter(Boolean)) {
|
|
35
|
+
const slash = tok.indexOf('/')
|
|
36
|
+
const matches = slash >= 0
|
|
37
|
+
? items.filter((i) => i.providerId === tok.slice(0, slash) && i.name === tok.slice(slash + 1))
|
|
38
|
+
: items.filter((i) => i.name === tok)
|
|
39
|
+
if (matches.length === 0) { log(` 알 수 없는 항목: ${tok}`); continue }
|
|
40
|
+
if (matches.length > 1) {
|
|
41
|
+
log(` 중복된 이름 '${tok}' — 제공자를 지정하세요: ${matches.map((m) => `${m.providerId}/${m.name}`).join(', ')}`)
|
|
42
|
+
continue
|
|
43
|
+
}
|
|
44
|
+
openPreview(opener, matches[0], log)
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// 보이는 집합(visibleStates) 안에서만 diff/적용한다.
|
|
49
|
+
async function applyVisible(root, visibleStates, selectedIds, { dryRun, log }) {
|
|
50
|
+
const changes = planChanges(visibleStates, selectedIds)
|
|
51
|
+
if (changes.length === 0) { log('변경할 항목이 없습니다.'); return [] }
|
|
52
|
+
const results = await apply(root, changes, { dryRun, log })
|
|
53
|
+
report(results, log)
|
|
54
|
+
return results
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
async function installedItems(root, items) {
|
|
58
|
+
const out = []
|
|
59
|
+
for (const item of items) {
|
|
60
|
+
if ((await item.detect({ root })).status === 'installed') out.push(item)
|
|
61
|
+
}
|
|
62
|
+
return out
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export async function refreshCatalog({ dryRun, fetchImpl, log, catalogFile = CATALOG_PATH }) {
|
|
66
|
+
const next = { updatedAt: new Date().toISOString(), providers: {} }
|
|
67
|
+
for (const provider of PROVIDERS) {
|
|
68
|
+
try {
|
|
69
|
+
const entries = await provider.fetchCatalog(fetchImpl)
|
|
70
|
+
next.providers[provider.id] = { label: provider.label, entries }
|
|
71
|
+
log(` ${provider.label}: ${entries.length}개`)
|
|
72
|
+
} catch (err) {
|
|
73
|
+
log(` ${provider.label}: 실패 — ${err.message}`)
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
const total = allEntries(next).length
|
|
77
|
+
if (total === 0) { log('가져온 항목이 없어 카탈로그를 갱신하지 않습니다.'); return { total: 0 } }
|
|
78
|
+
if (!dryRun) saveCatalog(next, catalogFile)
|
|
79
|
+
log(dryRun ? ` [dry-run] ${total}개로 갱신 예정` : `카탈로그 갱신됨: ${total}개`)
|
|
80
|
+
return { total }
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export async function updateInstalled(root, items, { dryRun, log }) {
|
|
84
|
+
const installed = await installedItems(root, items)
|
|
85
|
+
if (installed.length === 0) { log('설치된 design.md가 없습니다.'); return }
|
|
86
|
+
for (const item of installed) {
|
|
87
|
+
try {
|
|
88
|
+
await item.install({ root, dryRun, fresh: true })
|
|
89
|
+
log(` ✔ 업데이트 ${item.label}`)
|
|
90
|
+
} catch (err) {
|
|
91
|
+
log(` ✖ 업데이트 ${item.label} — ${err.message}`)
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export async function findStale(root, items, { log }) {
|
|
97
|
+
const installed = await installedItems(root, items)
|
|
98
|
+
const stale = []
|
|
99
|
+
for (const item of installed) {
|
|
100
|
+
const local = item.localText({ root })
|
|
101
|
+
if (local == null) continue
|
|
102
|
+
let remote
|
|
103
|
+
try { remote = await item.remoteText() } catch { remote = null }
|
|
104
|
+
if (remote == null) { log(` ? ${item.label}: 원본 확인 실패`); continue }
|
|
105
|
+
if (sha256(local) !== sha256(remote)) stale.push(item)
|
|
106
|
+
}
|
|
107
|
+
return stale
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// ── 진입점 ────────────────────────────────────────────────────────
|
|
111
|
+
|
|
112
|
+
export async function runDesign(root, opts = {}) {
|
|
113
|
+
const {
|
|
114
|
+
dryRun = false,
|
|
115
|
+
list = false,
|
|
116
|
+
set = null,
|
|
117
|
+
sync = null,
|
|
118
|
+
preview = null,
|
|
119
|
+
fetchImpl = netFetch,
|
|
120
|
+
log = console.log,
|
|
121
|
+
designDirs = [],
|
|
122
|
+
env = process.env,
|
|
123
|
+
catalogFile = CATALOG_PATH,
|
|
124
|
+
} = opts
|
|
125
|
+
const opener = opts.opener ?? makeOpener(dryRun, log)
|
|
126
|
+
|
|
127
|
+
const catalog = loadCatalog(catalogFile)
|
|
128
|
+
// 디렉터리 스캔이 먼저다 — 프로바이더 등록 없이도 사내 정의가 목록에 오른다.
|
|
129
|
+
const sources = opts.sources ?? discoverSources({
|
|
130
|
+
...(opts.bundleDir ? { bundleDir: opts.bundleDir } : {}),
|
|
131
|
+
extraDirs: [...extraDirsFromEnv(env), ...designDirs],
|
|
132
|
+
log,
|
|
133
|
+
})
|
|
134
|
+
const items = buildItems(catalog, { fetchImpl, sources })
|
|
135
|
+
|
|
136
|
+
if (list) {
|
|
137
|
+
printList(await scan(root, items), log)
|
|
138
|
+
return
|
|
139
|
+
}
|
|
140
|
+
if (preview != null) {
|
|
141
|
+
openPreviews(items, preview, opener, log)
|
|
142
|
+
return
|
|
143
|
+
}
|
|
144
|
+
if (sync != null) {
|
|
145
|
+
await runSync(root, items, { op: sync, dryRun, fetchImpl, log, catalogFile })
|
|
146
|
+
return
|
|
147
|
+
}
|
|
148
|
+
if (set != null) {
|
|
149
|
+
const selected = new Set(resolveTokens(items, set).map((i) => i.id))
|
|
150
|
+
await applyVisible(root, await scan(root, items), selected, { dryRun, log })
|
|
151
|
+
return
|
|
152
|
+
}
|
|
153
|
+
printList(await scan(root, items), log)
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
async function runSync(root, items, { op: chosen, dryRun, fetchImpl, log, catalogFile }) {
|
|
157
|
+
if (chosen === 'catalog') { await refreshCatalog({ dryRun, fetchImpl, log, catalogFile }); return }
|
|
158
|
+
if (chosen === 'installed') { await updateInstalled(root, items, { dryRun, log }); return }
|
|
159
|
+
if (chosen === 'stale') {
|
|
160
|
+
const stale = await findStale(root, items, { log })
|
|
161
|
+
if (stale.length === 0) { log('모든 설치본이 최신입니다.'); return }
|
|
162
|
+
log(`오래된 항목 ${stale.length}개: ${stale.map((i) => i.name).join(', ')}`)
|
|
163
|
+
return
|
|
164
|
+
}
|
|
165
|
+
log(`알 수 없는 동기화 작업: ${chosen}`)
|
|
166
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { execFileSync } from 'node:child_process'
|
|
2
|
+
import { existsSync } from 'node:fs'
|
|
3
|
+
|
|
4
|
+
// 미리보기 대상은 신뢰 경계 밖이다 — 이름이 원격 README 파싱이나 디렉터리
|
|
5
|
+
// 스캔에서 온다. http(s) URL이거나 실제로 존재하는 로컬 경로일 때만 OS에
|
|
6
|
+
// 넘긴다. `javascript:`처럼 스킴만 바꾼 대상이 기본 앱으로 열리지 않게 한다.
|
|
7
|
+
export function isOpenableTarget(target) {
|
|
8
|
+
const text = String(target ?? '')
|
|
9
|
+
// eslint-disable-next-line no-control-regex
|
|
10
|
+
if (!text || /[\x00-\x1f\x7f]/.test(text)) return false
|
|
11
|
+
if (/^https?:\/\//i.test(text)) return true
|
|
12
|
+
return existsSync(text)
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
// OS 기본 브라우저/앱으로 target(URL 또는 파일)을 연다. 주입 가능(테스트 대체).
|
|
16
|
+
export function makeOpener(dryRun, log = console.log) {
|
|
17
|
+
return (target) => {
|
|
18
|
+
if (dryRun) {
|
|
19
|
+
log(` [dry-run] open ${target}`)
|
|
20
|
+
return { ok: true }
|
|
21
|
+
}
|
|
22
|
+
if (!isOpenableTarget(target)) return { ok: false, output: '열 수 있는 대상이 아닙니다' }
|
|
23
|
+
// Windows에서 `cmd /c start`를 쓰지 않는다 — Node는 셸 없이 cmd를 부를 때
|
|
24
|
+
// 공백 없는 인자를 quote하지 않고, cmd는 `&`·`|`를 명령 구분자로 읽는다.
|
|
25
|
+
// 그러면 이름에 섞인 `&`가 곧바로 명령 실행이 된다. rundll32는 셸을 거치지
|
|
26
|
+
// 않아 인자가 그대로 전달되고, URL과 로컬 파일을 모두 기본 앱으로 연다.
|
|
27
|
+
const [cmd, args] =
|
|
28
|
+
process.platform === 'win32' ? ['rundll32', ['url.dll,FileProtocolHandler', target]] :
|
|
29
|
+
process.platform === 'darwin' ? ['open', [target]] :
|
|
30
|
+
['xdg-open', [target]]
|
|
31
|
+
try {
|
|
32
|
+
execFileSync(cmd, args, { stdio: 'ignore' })
|
|
33
|
+
return { ok: true }
|
|
34
|
+
} catch (err) {
|
|
35
|
+
return { ok: false, output: String(err.message) }
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// 항목의 미리보기를 연다: 웹 URL이 있으면 그 페이지, 로컬 항목은 원본 파일을 기본 앱으로.
|
|
41
|
+
export function openPreview(opener, item, log = console.log) {
|
|
42
|
+
const target = item.webUrl ?? item.previewPath ?? null
|
|
43
|
+
if (!target) {
|
|
44
|
+
log(` ${item.label}: 미리보기 URL이 없습니다.`)
|
|
45
|
+
return { ok: false }
|
|
46
|
+
}
|
|
47
|
+
const r = opener(target)
|
|
48
|
+
if (!r.ok) log(` ${item.label}: 열기 실패 — 직접 여세요: ${target}`)
|
|
49
|
+
return r
|
|
50
|
+
}
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
// awesome-design-md (VoltAgent) 프로바이더.
|
|
2
|
+
// 카탈로그: README `## Collection`의 `### 카테고리` + 링크 목록을 파싱한다.
|
|
3
|
+
// 링크 URL `getdesign.md/<name>/design-md`의 <name>이 곧 GitHub 폴더명이다.
|
|
4
|
+
// 파일: 폴더에는 DESIGN.md만 있다(preview.html 없음). 렌더된 미리보기는 웹페이지.
|
|
5
|
+
import { existsSync, readFileSync } from 'node:fs'
|
|
6
|
+
import { join, dirname } from 'node:path'
|
|
7
|
+
import { fileURLToPath } from 'node:url'
|
|
8
|
+
|
|
9
|
+
// lib/design-md/cache/<name>/DESIGN.md — 인스톨러에 동봉된 오프라인 번들.
|
|
10
|
+
const BUNDLE_DIR = join(dirname(fileURLToPath(import.meta.url)), '..', 'cache')
|
|
11
|
+
|
|
12
|
+
const REPO = 'VoltAgent/awesome-design-md'
|
|
13
|
+
const BRANCH = 'main'
|
|
14
|
+
const RAW_BASE = `https://raw.githubusercontent.com/${REPO}/${BRANCH}/design-md`
|
|
15
|
+
const README_URL = `https://raw.githubusercontent.com/${REPO}/${BRANCH}/README.md`
|
|
16
|
+
const TREE_URL = `https://api.github.com/repos/${REPO}/git/trees/${BRANCH}?recursive=1`
|
|
17
|
+
const UNCATEGORIZED = '기타'
|
|
18
|
+
|
|
19
|
+
const ENTRY_RE = /^-\s*\[\*\*(.+?)\*\*\]\(\s*https?:\/\/getdesign\.md\/([^/)]+)\/design-md\s*\)\s*-\s*(.*)$/
|
|
20
|
+
|
|
21
|
+
// README `## Collection` 블록만 대상으로 카테고리·항목을 뽑는다.
|
|
22
|
+
export function parseReadme(md) {
|
|
23
|
+
const lines = md.split(/\r?\n/)
|
|
24
|
+
const entries = []
|
|
25
|
+
let inCollection = false
|
|
26
|
+
let category = UNCATEGORIZED
|
|
27
|
+
for (const line of lines) {
|
|
28
|
+
const h2 = /^##\s+(.*)$/.exec(line)
|
|
29
|
+
if (h2) {
|
|
30
|
+
inCollection = /^collection\b/i.test(h2[1].trim())
|
|
31
|
+
continue
|
|
32
|
+
}
|
|
33
|
+
if (!inCollection) continue
|
|
34
|
+
const h3 = /^###\s+(.*)$/.exec(line)
|
|
35
|
+
if (h3) {
|
|
36
|
+
category = h3[1].trim()
|
|
37
|
+
continue
|
|
38
|
+
}
|
|
39
|
+
const m = ENTRY_RE.exec(line.trim())
|
|
40
|
+
if (m) {
|
|
41
|
+
entries.push({ name: m[2].trim(), label: m[1].trim(), category, description: m[3].trim() })
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
return entries
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// tree API에서 design-md/<name>/ 폴더명 집합을 뽑는다(orphan 보강용).
|
|
48
|
+
export function parseTreeNames(treeJson) {
|
|
49
|
+
const names = new Set()
|
|
50
|
+
for (const e of treeJson?.tree ?? []) {
|
|
51
|
+
const m = /^design-md\/([^/]+)\//.exec(e.path)
|
|
52
|
+
if (m) names.add(m[1])
|
|
53
|
+
}
|
|
54
|
+
return names
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export const awesomeDesignMd = {
|
|
58
|
+
id: 'awesome-design-md',
|
|
59
|
+
label: 'awesome-design-md (VoltAgent)',
|
|
60
|
+
files: ['DESIGN.md'],
|
|
61
|
+
|
|
62
|
+
fileUrl(name, file = 'DESIGN.md') {
|
|
63
|
+
return `${RAW_BASE}/${encodeURIComponent(name)}/${file}`
|
|
64
|
+
},
|
|
65
|
+
|
|
66
|
+
// fileUrl과 같이 인코딩한다 — name은 원격 README에서 오므로, 그대로 이어
|
|
67
|
+
// 붙이면 URL이 아닌 문자가 미리보기 대상 문자열에 섞인다.
|
|
68
|
+
webUrl(name) {
|
|
69
|
+
return `https://getdesign.md/${encodeURIComponent(name)}/design-md`
|
|
70
|
+
},
|
|
71
|
+
|
|
72
|
+
bundlePath(name, file = 'DESIGN.md') {
|
|
73
|
+
return join(BUNDLE_DIR, this.id, name, file)
|
|
74
|
+
},
|
|
75
|
+
|
|
76
|
+
// 동봉 번들에서 읽는다(없으면 null). 오프라인 설치용.
|
|
77
|
+
bundledText(name, file = 'DESIGN.md') {
|
|
78
|
+
if (/[\\/]/.test(name)) return null
|
|
79
|
+
const p = this.bundlePath(name, file)
|
|
80
|
+
return existsSync(p) ? readFileSync(p, 'utf8') : null
|
|
81
|
+
},
|
|
82
|
+
|
|
83
|
+
async fetchFile(fetchImpl, name, file = 'DESIGN.md') {
|
|
84
|
+
const res = await fetchImpl(this.fileUrl(name, file))
|
|
85
|
+
if (!res.ok) return null
|
|
86
|
+
return res.text()
|
|
87
|
+
},
|
|
88
|
+
|
|
89
|
+
// README 파싱(주) + tree orphan 보강(선택). 이름순 정렬 반환.
|
|
90
|
+
async fetchCatalog(fetchImpl) {
|
|
91
|
+
const readmeRes = await fetchImpl(README_URL)
|
|
92
|
+
if (!readmeRes.ok) throw new Error(`README 가져오기 실패: HTTP ${readmeRes.status}`)
|
|
93
|
+
const entries = parseReadme(await readmeRes.text())
|
|
94
|
+
const seen = new Set(entries.map((e) => e.name))
|
|
95
|
+
|
|
96
|
+
try {
|
|
97
|
+
const treeRes = await fetchImpl(TREE_URL, { headers: { 'User-Agent': 'agent-installer' } })
|
|
98
|
+
if (treeRes.ok) {
|
|
99
|
+
for (const name of parseTreeNames(await treeRes.json())) {
|
|
100
|
+
if (!seen.has(name)) {
|
|
101
|
+
entries.push({ name, label: name, category: UNCATEGORIZED, description: '' })
|
|
102
|
+
seen.add(name)
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
} catch {
|
|
107
|
+
// tree 실패는 무시 — README만으로도 카탈로그는 유효하다.
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
return entries.sort((a, b) => a.name.localeCompare(b.name))
|
|
111
|
+
},
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
export default awesomeDesignMd
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
// design.md 프로바이더 레지스트리 — 소스 확장 지점.
|
|
2
|
+
// 새 소스 추가 = 프로바이더 모듈 1개 작성 + 여기 등록.
|
|
3
|
+
import { awesomeDesignMd } from './awesome-design-md.mjs'
|
|
4
|
+
|
|
5
|
+
export const PROVIDERS = [awesomeDesignMd]
|
|
6
|
+
|
|
7
|
+
export function getProvider(id) {
|
|
8
|
+
return PROVIDERS.find((p) => p.id === id)
|
|
9
|
+
}
|