@t3q-ui/design-system 0.0.0-stage → 1.56.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/CHANGELOG.md +1985 -0
- package/LICENSE.md +88 -0
- package/README.md +464 -2
- package/cli/doctor.mjs +200 -0
- package/cli/lib.mjs +425 -0
- package/cli/setup.mjs +181 -0
- package/cli/t3q-design.mjs +78 -0
- package/dist/chunk-IPF3K4BS.js +4274 -0
- package/dist/chunk-IPF3K4BS.js.map +1 -0
- package/dist/chunk-MHAO45AL.cjs +4427 -0
- package/dist/chunk-MHAO45AL.cjs.map +1 -0
- package/dist/fonts.css +12 -0
- package/dist/index-Ci1Wh5sA.d.cts +253 -0
- package/dist/index-Ci1Wh5sA.d.ts +253 -0
- package/dist/index.cjs +10998 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.css +332 -0
- package/dist/index.css.map +1 -0
- package/dist/index.d.cts +2554 -0
- package/dist/index.d.ts +2554 -0
- package/dist/index.js +10354 -0
- package/dist/index.js.map +1 -0
- package/dist/patterns/index.cjs +885 -0
- package/dist/patterns/index.cjs.map +1 -0
- package/dist/patterns/index.css +265 -0
- package/dist/patterns/index.css.map +1 -0
- package/dist/patterns/index.d.cts +373 -0
- package/dist/patterns/index.d.ts +373 -0
- package/dist/patterns/index.js +873 -0
- package/dist/patterns/index.js.map +1 -0
- package/dist/styles.css +2014 -0
- package/package.json +212 -4
- package/skills/tds/SKILL.md +91 -0
- package/skills/tds/examples/dashboard-page.tsx +344 -0
- package/skills/tds/examples/detail-page.tsx +388 -0
- package/skills/tds/examples/form-page.tsx +460 -0
- package/skills/tds/examples/list-page.tsx +275 -0
- package/skills/tds/reference/components.md +1424 -0
- package/skills/tds/rules.md +28 -0
package/cli/doctor.mjs
ADDED
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* t3q-design doctor — 설치·연결·규칙 상태를 검사하고 실패마다 조치를 알려 준다.
|
|
3
|
+
* ✓ ok · ! warn(실패 아님) · ✗ fail(종료 코드 1). 네트워크 불가는 warn — 사내망 밖에서도 나머지는 점검된다.
|
|
4
|
+
* "오래됨" 은 버전이 아니라 내용 비교다 (규칙이 안 바뀐 릴리즈에서 헛경고 없음).
|
|
5
|
+
*/
|
|
6
|
+
import fs from 'node:fs'
|
|
7
|
+
import path from 'node:path'
|
|
8
|
+
|
|
9
|
+
import {
|
|
10
|
+
checkMcp,
|
|
11
|
+
checkRegistry,
|
|
12
|
+
CODEX_CONFIG,
|
|
13
|
+
CODEX_SKILL_DEST,
|
|
14
|
+
codexMcpUrlOf,
|
|
15
|
+
CSS_IMPORTS,
|
|
16
|
+
findCss,
|
|
17
|
+
hasFontsImport,
|
|
18
|
+
hasImportLine,
|
|
19
|
+
hasStylesImport,
|
|
20
|
+
major,
|
|
21
|
+
managedBlock,
|
|
22
|
+
MCP_SERVER,
|
|
23
|
+
mcpUrlOf,
|
|
24
|
+
MIN_NODE,
|
|
25
|
+
nodeMajor,
|
|
26
|
+
npmrcLines,
|
|
27
|
+
parseNpmrc,
|
|
28
|
+
PKG_NAME,
|
|
29
|
+
readInstalledVersion,
|
|
30
|
+
readText,
|
|
31
|
+
resolvePeerVersion,
|
|
32
|
+
RULES_FILE,
|
|
33
|
+
SKILL_DEST,
|
|
34
|
+
skillStale,
|
|
35
|
+
} from './lib.mjs'
|
|
36
|
+
|
|
37
|
+
const SETUP = 't3q-design setup'
|
|
38
|
+
/** 사내 점검(GITLAB_TOKEN·Bearer 확인)은 GitLab npm 레지스트리 주소(`…/api/v4/…/packages/npm/`)에만 맞다 — npmjs·미러 값은 건너뛴다 */
|
|
39
|
+
const GITLAB_NPM_RE = /\/api\/v4\/.+\/packages\/npm\/?$/
|
|
40
|
+
|
|
41
|
+
/** @returns {Promise<Array<{ status: 'ok'|'warn'|'fail'|'info', msg: string, fix?: string }>>} */
|
|
42
|
+
export async function doctor(cwd, { env = process.env, fetch: fetchImpl = globalThis.fetch } = {}) {
|
|
43
|
+
const results = []
|
|
44
|
+
const add = (status, msg, fix) => results.push(fix ? { status, msg, fix } : { status, msg })
|
|
45
|
+
const file = (rel) => path.join(cwd, rel)
|
|
46
|
+
const exists = (rel) => fs.existsSync(file(rel))
|
|
47
|
+
|
|
48
|
+
// 1. Node
|
|
49
|
+
const node = nodeMajor()
|
|
50
|
+
if (node >= MIN_NODE) add('ok', `Node ${process.versions.node}`)
|
|
51
|
+
else add('fail', `Node ${process.versions.node}`, `Node ${MIN_NODE} 이상 설치`)
|
|
52
|
+
|
|
53
|
+
// 2. 패키지 매니저 (락파일)
|
|
54
|
+
const pm = exists('pnpm-lock.yaml')
|
|
55
|
+
? 'pnpm'
|
|
56
|
+
: exists('package-lock.json')
|
|
57
|
+
? 'npm'
|
|
58
|
+
: exists('yarn.lock')
|
|
59
|
+
? 'yarn'
|
|
60
|
+
: null
|
|
61
|
+
if (pm) add('ok', `패키지 매니저 ${pm}`)
|
|
62
|
+
else add('warn', '락파일 없음', 'pnpm install')
|
|
63
|
+
|
|
64
|
+
// 3~5. 사내 레지스트리 — 프로젝트 .npmrc 에 @t3q-ui:registry 줄이 있을 때만 그 주소로 점검한다
|
|
65
|
+
const npmrc = parseNpmrc(readText(file('.npmrc')))
|
|
66
|
+
let latest = null
|
|
67
|
+
if (npmrc.registry == null) add('info', '.npmrc 에 @t3q-ui:registry 없음 — 기본 레지스트리(npmjs)에서 설치')
|
|
68
|
+
else if (!GITLAB_NPM_RE.test(npmrc.registry))
|
|
69
|
+
add('info', '.npmrc 의 @t3q-ui:registry 가 GitLab npm 레지스트리가 아님 — 토큰·레지스트리 점검 생략')
|
|
70
|
+
else {
|
|
71
|
+
if (npmrc.auth) add('ok', '.npmrc 레지스트리 2줄')
|
|
72
|
+
else
|
|
73
|
+
add(
|
|
74
|
+
'fail',
|
|
75
|
+
'.npmrc 에 레지스트리 토큰 줄 없음',
|
|
76
|
+
`${SETUP} --registry ${npmrc.registry} 또는 직접 추가:\n${npmrcLines(npmrc.registry).join('\n')}`,
|
|
77
|
+
)
|
|
78
|
+
const token = env.GITLAB_TOKEN
|
|
79
|
+
if (token) add('ok', 'GITLAB_TOKEN')
|
|
80
|
+
else add('fail', 'GITLAB_TOKEN 미설정', '토큰 발급 후 export GITLAB_TOKEN=… (docs/INTERNAL_SETUP.md)')
|
|
81
|
+
if (token) {
|
|
82
|
+
const r = await checkRegistry(npmrc.registry, token, fetchImpl)
|
|
83
|
+
if (r.status === 'ok') {
|
|
84
|
+
latest = r.latest
|
|
85
|
+
add('ok', `GitLab 레지스트리 접근${latest ? ` (최신 ${latest})` : ''}`)
|
|
86
|
+
} else if (r.status === 'auth')
|
|
87
|
+
add(
|
|
88
|
+
'fail',
|
|
89
|
+
`레지스트리 토큰 거부 (${r.detail})`,
|
|
90
|
+
'read_api·read_registry 스코프로 토큰 재발급',
|
|
91
|
+
)
|
|
92
|
+
else if (r.status === 'notfound')
|
|
93
|
+
add('fail', '레지스트리 404', 'GitLab 프로젝트 접근 권한 확인')
|
|
94
|
+
else add('warn', `레지스트리 접근 불가 (${r.detail})`, '사내망/VPN 연결 확인')
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// 6. 설치
|
|
99
|
+
const installed = readInstalledVersion(cwd)
|
|
100
|
+
if (!installed) {
|
|
101
|
+
add('fail', `${PKG_NAME} 미설치`, `pnpm add ${PKG_NAME}`)
|
|
102
|
+
return results
|
|
103
|
+
}
|
|
104
|
+
add('ok', `${PKG_NAME} ${installed}`)
|
|
105
|
+
|
|
106
|
+
// 7. 최신 버전
|
|
107
|
+
if (latest && latest !== installed)
|
|
108
|
+
add('warn', `업데이트 가능 ${installed} → ${latest}`, `pnpm up ${PKG_NAME} && ${SETUP}`)
|
|
109
|
+
|
|
110
|
+
// 8. peer
|
|
111
|
+
const react = resolvePeerVersion(cwd, 'react')
|
|
112
|
+
if (react) add('ok', `react ${react}`)
|
|
113
|
+
else add('fail', 'react 미해결', 'pnpm add react react-dom')
|
|
114
|
+
const tw = resolvePeerVersion(cwd, 'tailwindcss')
|
|
115
|
+
if (tw && major(tw) === 4) add('ok', `tailwindcss ${tw}`)
|
|
116
|
+
else if (tw) add('fail', `tailwindcss ${tw}`, 'TDS 는 Tailwind v4 필요 — README "Tailwind 설정"')
|
|
117
|
+
else add('fail', 'tailwindcss 미해결', 'pnpm add -D tailwindcss @tailwindcss/postcss postcss')
|
|
118
|
+
const ag = resolvePeerVersion(cwd, 'ag-grid-community')
|
|
119
|
+
if (ag && major(ag) >= 36) add('ok', `ag-grid-community ${ag}`)
|
|
120
|
+
else if (ag) add('warn', `ag-grid-community ${ag}`, '36 이상 권장 (33 은 axe critical 위반)')
|
|
121
|
+
else
|
|
122
|
+
add(
|
|
123
|
+
'warn',
|
|
124
|
+
'ag-grid-community 미해결',
|
|
125
|
+
'표(AgGridTable) 를 쓰면 pnpm add ag-grid-community ag-grid-react',
|
|
126
|
+
)
|
|
127
|
+
|
|
128
|
+
// 9. CSS
|
|
129
|
+
const { v4, v3 } = findCss(cwd)
|
|
130
|
+
if (v4.length === 0 && v3.length > 0)
|
|
131
|
+
add('fail', `Tailwind v3 CSS (${v3.join(', ')})`, 'v4 로 올린 뒤 setup')
|
|
132
|
+
else if (v4.length === 0)
|
|
133
|
+
add(
|
|
134
|
+
'fail',
|
|
135
|
+
"@import 'tailwindcss' 가 있는 CSS 없음",
|
|
136
|
+
`진입 CSS 에 추가:\n@import 'tailwindcss';\n${CSS_IMPORTS.join('\n')}`,
|
|
137
|
+
)
|
|
138
|
+
else {
|
|
139
|
+
const withStyles = v4.filter((rel) => hasStylesImport(readText(file(rel)) ?? ''))
|
|
140
|
+
if (withStyles.length === 0)
|
|
141
|
+
add(
|
|
142
|
+
'fail',
|
|
143
|
+
`CSS 에 TDS styles.css import 없음 (${v4.join(', ')})`,
|
|
144
|
+
v4.length === 1 ? SETUP : `하나 골라 추가:\n${CSS_IMPORTS[0]}`,
|
|
145
|
+
)
|
|
146
|
+
else {
|
|
147
|
+
add('ok', `CSS styles.css import (${withStyles.join(', ')})`)
|
|
148
|
+
if (!withStyles.some((rel) => hasFontsImport(readText(file(rel)) ?? '')))
|
|
149
|
+
add(
|
|
150
|
+
'warn',
|
|
151
|
+
'CSS 에 fonts.css import 없음',
|
|
152
|
+
`앱이 Pretendard 를 직접 로드하면(next/font 등) 무시. 아니면 추가:\n${CSS_IMPORTS[1]}`,
|
|
153
|
+
)
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
// 10·11. MCP
|
|
158
|
+
const url = mcpUrlOf(readText(file('.mcp.json')))
|
|
159
|
+
if (!url) add('info', `.mcp.json 에 ${MCP_SERVER} 없음 — Skill reference 로 동작`)
|
|
160
|
+
else {
|
|
161
|
+
add('ok', `.mcp.json ${MCP_SERVER} → ${url}`)
|
|
162
|
+
const m = await checkMcp(url, fetchImpl)
|
|
163
|
+
if (m.ok) add('ok', `Storybook MCP 응답${m.server ? ` (${m.server})` : ''}`)
|
|
164
|
+
else
|
|
165
|
+
add(
|
|
166
|
+
'warn',
|
|
167
|
+
`Storybook MCP 응답 없음 (${m.detail})`,
|
|
168
|
+
'사내망 필요. 로컬 Storybook 이면 t3q-design setup --mcp-url http://localhost:6006/mcp',
|
|
169
|
+
)
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
// 12. Skill
|
|
173
|
+
if (!exists(path.join(SKILL_DEST, 'SKILL.md'))) add('fail', `${SKILL_DEST}/ 없음`, SETUP)
|
|
174
|
+
else if (skillStale(file(SKILL_DEST)))
|
|
175
|
+
add('fail', `${SKILL_DEST}/ 오래됨 (패키지 ${installed} 와 다름)`, SETUP)
|
|
176
|
+
else add('ok', `${SKILL_DEST}/ (/tds)`)
|
|
177
|
+
|
|
178
|
+
// 12b. Codex 산출물 — 안 쓰는 팀도 있으므로 warn (setup 이 둘 다 쓴다)
|
|
179
|
+
if (!exists(path.join(CODEX_SKILL_DEST, 'SKILL.md')))
|
|
180
|
+
add('warn', `${CODEX_SKILL_DEST}/ 없음 (Codex 스킬)`, SETUP)
|
|
181
|
+
else if (skillStale(file(CODEX_SKILL_DEST)))
|
|
182
|
+
add('warn', `${CODEX_SKILL_DEST}/ 오래됨 (패키지 ${installed} 와 다름)`, SETUP)
|
|
183
|
+
else add('ok', `${CODEX_SKILL_DEST}/ (Codex 스킬)`)
|
|
184
|
+
const codexUrl = codexMcpUrlOf(readText(file(CODEX_CONFIG)))
|
|
185
|
+
if (!codexUrl) add('info', `${CODEX_CONFIG} 에 ${MCP_SERVER} 없음 — Skill reference 로 동작`)
|
|
186
|
+
else add('ok', `${CODEX_CONFIG} ${MCP_SERVER} → ${codexUrl}`)
|
|
187
|
+
|
|
188
|
+
// 13. AGENTS.md 블록
|
|
189
|
+
const block = managedBlock(readText(file('AGENTS.md')))
|
|
190
|
+
const rules = (readText(RULES_FILE) ?? '').trim()
|
|
191
|
+
if (!block) add('fail', 'AGENTS.md 에 TDS 블록 없음', SETUP)
|
|
192
|
+
else if (block.body.trim() !== rules) add('fail', 'AGENTS.md TDS 블록 오래됨', SETUP)
|
|
193
|
+
else add('ok', `AGENTS.md TDS 블록${block.version ? ` (v${block.version})` : ''}`)
|
|
194
|
+
|
|
195
|
+
// 14. CLAUDE.md
|
|
196
|
+
if (hasImportLine(readText(file('CLAUDE.md')))) add('ok', 'CLAUDE.md @AGENTS.md')
|
|
197
|
+
else add('fail', 'CLAUDE.md 에 @AGENTS.md 없음', SETUP)
|
|
198
|
+
|
|
199
|
+
return results
|
|
200
|
+
}
|
package/cli/lib.mjs
ADDED
|
@@ -0,0 +1,425 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* t3q-design CLI 의 상수와 순수 함수. setup.mjs / doctor.mjs 가 쓰고 cli.test.mjs 가 검증한다.
|
|
3
|
+
* 파일 쓰기는 writeIfChanged 하나로만 한다 (읽기 → 바꾸기 → 달라졌을 때만 쓰기).
|
|
4
|
+
*/
|
|
5
|
+
import fs from 'node:fs'
|
|
6
|
+
import { createRequire } from 'node:module'
|
|
7
|
+
import path from 'node:path'
|
|
8
|
+
import { fileURLToPath } from 'node:url'
|
|
9
|
+
|
|
10
|
+
export const PKG_NAME = '@t3q-ui/design-system'
|
|
11
|
+
/**
|
|
12
|
+
* 소비 레포 .npmrc 의 두 줄. 토큰 값이 아니라 `${GITLAB_TOKEN}` 참조를 쓴다 — 커밋해도 된다.
|
|
13
|
+
* 레지스트리 주소는 패키지에 두지 않는다 — 사내 설치만 `setup --registry <url>` 로 넘기고 (docs/INTERNAL_SETUP.md), 없으면 .npmrc 를 건드리지 않는다.
|
|
14
|
+
*/
|
|
15
|
+
export const npmrcLines = (registry) => [
|
|
16
|
+
`@t3q-ui:registry=${registry}`,
|
|
17
|
+
`//${registry.replace(/^https?:\/\//, '')}:_authToken=\${GITLAB_TOKEN}`,
|
|
18
|
+
]
|
|
19
|
+
export const MCP_SERVER = 'tds-storybook'
|
|
20
|
+
export const CSS_IMPORTS = [`@import '${PKG_NAME}/styles.css';`, `@import '${PKG_NAME}/fonts.css';`]
|
|
21
|
+
|
|
22
|
+
/** 패키지 루트. 레포에서 돌리든 node_modules 안에서 돌리든 이 파일 기준 `..` 이다 (bin 심링크는 Node 가 realpath 로 푼다). */
|
|
23
|
+
export const PKG_DIR = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..')
|
|
24
|
+
export const SKILL_SRC = path.join(PKG_DIR, 'skills', 'tds')
|
|
25
|
+
export const RULES_FILE = path.join(SKILL_SRC, 'rules.md')
|
|
26
|
+
export const SKILL_DEST = path.join('.claude', 'skills', 'tds')
|
|
27
|
+
/** Codex 는 스킬을 `.agents/skills/`, MCP 를 프로젝트 `.codex/config.toml`(Codex 가 trust 한 프로젝트만)에서 읽는다 — https://developers.openai.com/codex/mcp */
|
|
28
|
+
export const CODEX_SKILL_DEST = path.join('.agents', 'skills', 'tds')
|
|
29
|
+
export const CODEX_CONFIG = path.join('.codex', 'config.toml')
|
|
30
|
+
/** rules.md 는 AGENTS.md 블록이 되므로 Skill 폴더로는 복사하지 않는다 (소비 레포에 같은 내용 두 벌 금지). */
|
|
31
|
+
const SKILL_SKIP = new Set(['rules.md'])
|
|
32
|
+
|
|
33
|
+
export const MIN_NODE = 18
|
|
34
|
+
|
|
35
|
+
// ----------------------------------------------------------------------------
|
|
36
|
+
// 파일 유틸
|
|
37
|
+
// ----------------------------------------------------------------------------
|
|
38
|
+
|
|
39
|
+
export function readText(file) {
|
|
40
|
+
try {
|
|
41
|
+
return fs.readFileSync(file, 'utf8')
|
|
42
|
+
} catch {
|
|
43
|
+
return null
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function readJson(file) {
|
|
48
|
+
const text = readText(file)
|
|
49
|
+
if (text == null) return null
|
|
50
|
+
return JSON.parse(text)
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** 내용이 같으면 쓰지 않는다. 반환: 'unchanged' | 'written' */
|
|
54
|
+
export function writeIfChanged(file, next) {
|
|
55
|
+
if (readText(file) === next) return 'unchanged'
|
|
56
|
+
fs.mkdirSync(path.dirname(file), { recursive: true })
|
|
57
|
+
fs.writeFileSync(file, next)
|
|
58
|
+
return 'written'
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export const ownVersion = () => readJson(path.join(PKG_DIR, 'package.json'))?.version ?? 'unknown'
|
|
62
|
+
|
|
63
|
+
export const nodeMajor = () => Number(process.versions.node.split('.')[0])
|
|
64
|
+
|
|
65
|
+
// ----------------------------------------------------------------------------
|
|
66
|
+
// .npmrc
|
|
67
|
+
// ----------------------------------------------------------------------------
|
|
68
|
+
|
|
69
|
+
const splitLines = (text) => (text ?? '').split(/\r?\n/).map((l) => l.trim())
|
|
70
|
+
|
|
71
|
+
/** { registry: @t3q-ui:registry 의 값(없으면 null), auth: 그 레지스트리의 토큰 줄이 있는가 } */
|
|
72
|
+
export function parseNpmrc(text) {
|
|
73
|
+
const lines = splitLines(text)
|
|
74
|
+
const registry =
|
|
75
|
+
lines.find((l) => l.startsWith('@t3q-ui:registry='))?.slice('@t3q-ui:registry='.length) ?? null
|
|
76
|
+
const auth = registry != null && lines.some((l) => l.startsWith(npmrcLines(registry)[1].split('=')[0] + '='))
|
|
77
|
+
return { registry, auth }
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** 없는 줄만 뒤에 붙인다. 다른 값의 스코프 줄이 있으면 손대지 않는다. 반환 { text, conflict } */
|
|
81
|
+
export function ensureNpmrc(text, registry) {
|
|
82
|
+
const want = npmrcLines(registry)
|
|
83
|
+
const state = parseNpmrc(text)
|
|
84
|
+
if (state.registry != null && state.registry !== registry) return { text: text ?? '', conflict: true }
|
|
85
|
+
const missing = [state.registry ? null : want[0], state.auth ? null : want[1]].filter(Boolean)
|
|
86
|
+
if (missing.length === 0) return { text, conflict: false }
|
|
87
|
+
const head = text ? text.replace(/\s*$/, '') + '\n' : ''
|
|
88
|
+
return { text: head + missing.join('\n') + '\n', conflict: false }
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// ----------------------------------------------------------------------------
|
|
92
|
+
// AGENTS.md 관리 블록 · CLAUDE.md import
|
|
93
|
+
// ----------------------------------------------------------------------------
|
|
94
|
+
|
|
95
|
+
/** 관리 블록 마커. md = HTML 주석(AGENTS.md), toml = `#` 주석(.codex/config.toml — TOML 에서 `<!--` 는 문법 오류) */
|
|
96
|
+
const MARKERS = {
|
|
97
|
+
md: { open: '<!-- tds:start', close: '-->', end: '<!-- tds:end -->' },
|
|
98
|
+
toml: { open: '# tds:start', close: '', end: '# tds:end' },
|
|
99
|
+
}
|
|
100
|
+
const escapeRe = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
|
101
|
+
const blockRe = (kind) => {
|
|
102
|
+
const { open, close, end } = MARKERS[kind]
|
|
103
|
+
return new RegExp(
|
|
104
|
+
`${escapeRe(open)}([^\\n]*?)${escapeRe(close)}\\r?\\n([\\s\\S]*?)\\r?\\n${escapeRe(end)}`,
|
|
105
|
+
)
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** 블록이 있으면 { version, body } 없으면 null */
|
|
109
|
+
export function managedBlock(text, kind = 'md') {
|
|
110
|
+
const m = blockRe(kind).exec(text ?? '')
|
|
111
|
+
if (!m) return null
|
|
112
|
+
return { version: /v(\S+)/.exec(m[1])?.[1] ?? null, body: m[2] }
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** 블록을 만들거나(없으면 끝에 추가) 교체한다. 블록 밖 내용은 그대로. */
|
|
116
|
+
export function upsertManagedBlock(text, body, version, kind = 'md') {
|
|
117
|
+
const { open, close, end } = MARKERS[kind]
|
|
118
|
+
const block = `${[open, `v${version}`, close].filter(Boolean).join(' ')}\n${body.trim()}\n${end}`
|
|
119
|
+
if (managedBlock(text, kind)) return text.replace(blockRe(kind), () => block)
|
|
120
|
+
const head = text?.trim() ? text.replace(/\s*$/, '') + '\n\n' : ''
|
|
121
|
+
return head + block + '\n'
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
const IMPORT_RE = /^@AGENTS\.md\s*$/m
|
|
125
|
+
|
|
126
|
+
export const hasImportLine = (text) => IMPORT_RE.test(text ?? '')
|
|
127
|
+
|
|
128
|
+
/** CLAUDE.md 에 `@AGENTS.md` 한 줄을 보장한다. 있으면 그대로, 없으면 끝에 추가. */
|
|
129
|
+
export function ensureImportLine(text) {
|
|
130
|
+
if (hasImportLine(text)) return text
|
|
131
|
+
const head = text?.trim() ? text.replace(/\s*$/, '') + '\n\n' : ''
|
|
132
|
+
return head + '@AGENTS.md\n'
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// ----------------------------------------------------------------------------
|
|
136
|
+
// CSS 진입 파일
|
|
137
|
+
// ----------------------------------------------------------------------------
|
|
138
|
+
|
|
139
|
+
const PRUNE = new Set([
|
|
140
|
+
'node_modules',
|
|
141
|
+
'dist',
|
|
142
|
+
'build',
|
|
143
|
+
'out',
|
|
144
|
+
'.next',
|
|
145
|
+
'.git',
|
|
146
|
+
'coverage',
|
|
147
|
+
'storybook-static',
|
|
148
|
+
])
|
|
149
|
+
const TW4_RE = /^[ \t]*@import\s+["']tailwindcss["'][^\r\n]*$/m
|
|
150
|
+
const TW3_RE = /^[ \t]*@tailwind\s+base\b/m
|
|
151
|
+
const HAS_STYLES_RE = /@import\s+["']@t3q-ui\/design-system\/styles\.css["']/
|
|
152
|
+
const HAS_FONTS_RE = /@import\s+["']@t3q-ui\/design-system\/fonts\.css["']/
|
|
153
|
+
|
|
154
|
+
/** cwd 아래 .css 를 훑어 { v4: [`@import 'tailwindcss'` 가 있는 파일], v3: [`@tailwind base` 가 있는 파일] } (cwd 기준 상대경로) */
|
|
155
|
+
export function findCss(cwd) {
|
|
156
|
+
const v4 = []
|
|
157
|
+
const v3 = []
|
|
158
|
+
const walk = (dir) => {
|
|
159
|
+
let entries
|
|
160
|
+
try {
|
|
161
|
+
entries = fs.readdirSync(dir, { withFileTypes: true })
|
|
162
|
+
} catch {
|
|
163
|
+
return
|
|
164
|
+
}
|
|
165
|
+
for (const e of entries) {
|
|
166
|
+
const full = path.join(dir, e.name)
|
|
167
|
+
if (e.isDirectory()) {
|
|
168
|
+
// 숨김 폴더(.storybook·.template·.cache …)에 진입 CSS 를 두는 레포는 없다
|
|
169
|
+
if (!PRUNE.has(e.name) && !e.name.startsWith('.')) walk(full)
|
|
170
|
+
} else if (e.name.endsWith('.css')) {
|
|
171
|
+
const css = readText(full) ?? ''
|
|
172
|
+
if (TW4_RE.test(css)) v4.push(path.relative(cwd, full))
|
|
173
|
+
else if (TW3_RE.test(css)) v3.push(path.relative(cwd, full))
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
walk(cwd)
|
|
178
|
+
return { v4: v4.sort(), v3: v3.sort() }
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
export const hasStylesImport = (css) => HAS_STYLES_RE.test(css)
|
|
182
|
+
export const hasFontsImport = (css) => HAS_FONTS_RE.test(css)
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* `@import 'tailwindcss'` 줄 바로 아래에 TDS import 를 끼운다.
|
|
186
|
+
* - styles.css 가 없으면 넣는다. 그 파일에 TDS import 가 하나도 없던 새 레포면 fonts.css 도 같이 넣는다.
|
|
187
|
+
* - styles.css 가 이미 있으면 아무것도 안 넣는다 — 그 레포는 글꼴 로드 방식을 이미 정한 것이다 (dl-ui-v3: next/font).
|
|
188
|
+
* 바꿀 게 없으면 원문 그대로, tailwind import 줄이 없으면 null.
|
|
189
|
+
* (소비 쪽 `@source` 줄은 넣지 않는다 — dist/styles.css 가 이미 `@source "./**\/*.js"` 를 가진다. 2026-10-09 실측: 셀렉터 누락 0)
|
|
190
|
+
*/
|
|
191
|
+
export function insertCssImports(css) {
|
|
192
|
+
const m = TW4_RE.exec(css)
|
|
193
|
+
if (!m) return null
|
|
194
|
+
if (HAS_STYLES_RE.test(css)) return css
|
|
195
|
+
const missing = HAS_FONTS_RE.test(css) ? [CSS_IMPORTS[0]] : CSS_IMPORTS
|
|
196
|
+
const eol = css.includes('\r\n') ? '\r\n' : '\n'
|
|
197
|
+
const end = m.index + m[0].length
|
|
198
|
+
return css.slice(0, end) + eol + missing.join(eol) + css.slice(end)
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
// ----------------------------------------------------------------------------
|
|
202
|
+
// .mcp.json
|
|
203
|
+
// ----------------------------------------------------------------------------
|
|
204
|
+
|
|
205
|
+
/** 다른 서버는 보존하고 tds-storybook 만 넣거나 덮어쓴다. JSON 이 깨져 있으면 throw. */
|
|
206
|
+
export function mergeMcpJson(text, url) {
|
|
207
|
+
const json = text?.trim() ? JSON.parse(text) : {}
|
|
208
|
+
if (!json.mcpServers || typeof json.mcpServers !== 'object') json.mcpServers = {}
|
|
209
|
+
json.mcpServers[MCP_SERVER] = { type: 'http', url }
|
|
210
|
+
return JSON.stringify(json, null, 2) + '\n'
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
export const mcpUrlOf = (text) => {
|
|
214
|
+
try {
|
|
215
|
+
return JSON.parse(text ?? '')?.mcpServers?.[MCP_SERVER]?.url ?? null
|
|
216
|
+
} catch {
|
|
217
|
+
return null
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
const CODEX_TABLE = `[mcp_servers.${MCP_SERVER}]`
|
|
222
|
+
|
|
223
|
+
/** .codex/config.toml 관리 블록의 본문 — 원격 MCP 는 `url` 한 줄 */
|
|
224
|
+
export const codexMcpBody = (url) => `${CODEX_TABLE}\nurl = "${url}"`
|
|
225
|
+
|
|
226
|
+
/** TOML 본문에서 tds-storybook 테이블의 url (테이블 머리 뒤, 다음 테이블 전까지) — 없으면 null. 블록 안이든 밖이든 */
|
|
227
|
+
export function codexMcpUrlOf(text) {
|
|
228
|
+
let inTable = false
|
|
229
|
+
for (const raw of (text ?? '').split(/\r?\n/)) {
|
|
230
|
+
const line = raw.trim()
|
|
231
|
+
if (line.startsWith('[')) inTable = line === CODEX_TABLE
|
|
232
|
+
else if (inTable) {
|
|
233
|
+
const m = /^url\s*=\s*"([^"]*)"/.exec(line)
|
|
234
|
+
if (m) return m[1]
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
return null
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
/** 관리 블록 밖에 같은 테이블이 있으면 true — 테이블이 두 번이면 TOML 오류라 setup 은 손대지 않는다 */
|
|
241
|
+
export const codexTableOutsideBlock = (text) =>
|
|
242
|
+
(text ?? '')
|
|
243
|
+
.replace(blockRe('toml'), '')
|
|
244
|
+
.split(/\r?\n/)
|
|
245
|
+
.some((l) => l.trim() === CODEX_TABLE)
|
|
246
|
+
|
|
247
|
+
// ----------------------------------------------------------------------------
|
|
248
|
+
// 설치된 패키지 · peer
|
|
249
|
+
// ----------------------------------------------------------------------------
|
|
250
|
+
|
|
251
|
+
export const installedDir = (cwd) => path.join(cwd, 'node_modules', PKG_NAME)
|
|
252
|
+
|
|
253
|
+
export const readInstalledVersion = (cwd) => {
|
|
254
|
+
try {
|
|
255
|
+
return readJson(path.join(installedDir(cwd), 'package.json'))?.version ?? null
|
|
256
|
+
} catch {
|
|
257
|
+
return null
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
/**
|
|
262
|
+
* peer 의 설치 버전. cwd/node_modules/<peer> 가 아니라 **설치된 DS 패키지 위치에서** resolve 한다
|
|
263
|
+
* (pnpm 은 peer 를 가상 스토어에 두어 최상위에 없을 수 있다). 못 찾으면 null.
|
|
264
|
+
*/
|
|
265
|
+
export function resolvePeerVersion(cwd, name) {
|
|
266
|
+
let base
|
|
267
|
+
try {
|
|
268
|
+
base = fs.realpathSync(installedDir(cwd))
|
|
269
|
+
} catch {
|
|
270
|
+
return null
|
|
271
|
+
}
|
|
272
|
+
const req = createRequire(path.join(base, 'package.json'))
|
|
273
|
+
let file
|
|
274
|
+
try {
|
|
275
|
+
file = req.resolve(`${name}/package.json`)
|
|
276
|
+
} catch {
|
|
277
|
+
try {
|
|
278
|
+
file = req.resolve(name)
|
|
279
|
+
} catch {
|
|
280
|
+
return null
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
// exports 가 ./package.json 을 막는 패키지는 진입 파일에서 위로 올라가 이름이 맞는 package.json 을 찾는다
|
|
284
|
+
let dir = path.dirname(file)
|
|
285
|
+
for (let i = 0; i < 8; i++) {
|
|
286
|
+
const pkg = readJsonSafe(path.join(dir, 'package.json'))
|
|
287
|
+
if (pkg?.name === name) return pkg.version ?? null
|
|
288
|
+
const parent = path.dirname(dir)
|
|
289
|
+
if (parent === dir) break
|
|
290
|
+
dir = parent
|
|
291
|
+
}
|
|
292
|
+
return null
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
function readJsonSafe(file) {
|
|
296
|
+
try {
|
|
297
|
+
return readJson(file)
|
|
298
|
+
} catch {
|
|
299
|
+
return null
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
export const major = (version) => (version ? Number(String(version).split('.')[0]) : null)
|
|
304
|
+
|
|
305
|
+
// ----------------------------------------------------------------------------
|
|
306
|
+
// Skill 복사
|
|
307
|
+
// ----------------------------------------------------------------------------
|
|
308
|
+
|
|
309
|
+
/** 패키지 skills/tds 의 복사 대상 파일 (SKILL_SRC 기준 상대경로) */
|
|
310
|
+
export function skillFiles(src = SKILL_SRC) {
|
|
311
|
+
const out = []
|
|
312
|
+
const walk = (dir) => {
|
|
313
|
+
for (const e of fs.readdirSync(dir, { withFileTypes: true })) {
|
|
314
|
+
const full = path.join(dir, e.name)
|
|
315
|
+
if (e.isDirectory()) walk(full)
|
|
316
|
+
else if (!SKILL_SKIP.has(path.relative(src, full))) out.push(path.relative(src, full))
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
walk(src)
|
|
320
|
+
return out.sort()
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
/** 대상에 빠졌거나 내용이 다른 파일이 하나라도 있으면 true */
|
|
324
|
+
export function skillStale(dest, src = SKILL_SRC) {
|
|
325
|
+
return skillFiles(src).some(
|
|
326
|
+
(rel) => readText(path.join(dest, rel)) !== readText(path.join(src, rel)),
|
|
327
|
+
)
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
/** 반환: 'unchanged' | 'written' */
|
|
331
|
+
export function copySkill(dest, src = SKILL_SRC) {
|
|
332
|
+
let written = false
|
|
333
|
+
for (const rel of skillFiles(src)) {
|
|
334
|
+
if (writeIfChanged(path.join(dest, rel), readText(path.join(src, rel))) === 'written')
|
|
335
|
+
written = true
|
|
336
|
+
}
|
|
337
|
+
return written ? 'written' : 'unchanged'
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
// ----------------------------------------------------------------------------
|
|
341
|
+
// 네트워크 (fetch 주입 가능 — 테스트는 가짜 fetch)
|
|
342
|
+
// ----------------------------------------------------------------------------
|
|
343
|
+
|
|
344
|
+
// VPN 에서 패키지 목록(전 버전) 응답이 3초를 넘긴 적이 있다 (2026-10-09 dl-ui-v3 실측)
|
|
345
|
+
const TIMEOUT_MS = 8000
|
|
346
|
+
|
|
347
|
+
/**
|
|
348
|
+
* 레지스트리 인증·최신 버전 (dist-tags 엔드포인트). GitLab npm 엔드포인트는 `Authorization: Bearer <token>` 을 받는다.
|
|
349
|
+
* 반환 { status: 'ok', latest } | { status: 'auth' | 'notfound' | 'network', detail }
|
|
350
|
+
*/
|
|
351
|
+
export async function checkRegistry(registry, token, fetchImpl = globalThis.fetch, timeoutMs = TIMEOUT_MS) {
|
|
352
|
+
// 패키지 목록 전체(550KB, VPN 2~3초)가 아니라 dist-tags 만(0.1초). 인증 실패는 똑같이 401 (2026-10-09 실측)
|
|
353
|
+
const url = `${registry}-/package/${encodeURIComponent(PKG_NAME)}/dist-tags`
|
|
354
|
+
try {
|
|
355
|
+
const res = await fetchImpl(url, {
|
|
356
|
+
headers: { Authorization: `Bearer ${token}` },
|
|
357
|
+
signal: AbortSignal.timeout(timeoutMs),
|
|
358
|
+
})
|
|
359
|
+
if (res.status === 401 || res.status === 403)
|
|
360
|
+
return { status: 'auth', detail: `HTTP ${res.status}` }
|
|
361
|
+
if (res.status === 404) return { status: 'notfound', detail: 'HTTP 404' }
|
|
362
|
+
if (!res.ok) return { status: 'network', detail: `HTTP ${res.status}` }
|
|
363
|
+
const body = await res.json()
|
|
364
|
+
return { status: 'ok', latest: body?.latest ?? null }
|
|
365
|
+
} catch (e) {
|
|
366
|
+
return { status: 'network', detail: e?.message ?? String(e) }
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
/**
|
|
371
|
+
* MCP 서버가 응답하는지 — Streamable HTTP `initialize` 요청 하나로 확인한다.
|
|
372
|
+
* 공용 사이드카(@storybook/mcp)와 로컬 `storybook dev`(addon-mcp) 둘 다 같은 프로토콜이라 healthz 보다 범용이다.
|
|
373
|
+
* 응답은 JSON 또는 SSE(text/event-stream) 일 수 있다. 반환 { ok: true, server } | { ok: false, detail }
|
|
374
|
+
*/
|
|
375
|
+
export async function checkMcp(url, fetchImpl = globalThis.fetch, timeoutMs = TIMEOUT_MS) {
|
|
376
|
+
const body = {
|
|
377
|
+
jsonrpc: '2.0',
|
|
378
|
+
id: 1,
|
|
379
|
+
method: 'initialize',
|
|
380
|
+
params: {
|
|
381
|
+
protocolVersion: '2025-06-18',
|
|
382
|
+
capabilities: {},
|
|
383
|
+
clientInfo: { name: 't3q-design', version: ownVersion() },
|
|
384
|
+
},
|
|
385
|
+
}
|
|
386
|
+
try {
|
|
387
|
+
const res = await fetchImpl(url, {
|
|
388
|
+
method: 'POST',
|
|
389
|
+
headers: {
|
|
390
|
+
'Content-Type': 'application/json',
|
|
391
|
+
Accept: 'application/json, text/event-stream',
|
|
392
|
+
},
|
|
393
|
+
body: JSON.stringify(body),
|
|
394
|
+
signal: AbortSignal.timeout(timeoutMs),
|
|
395
|
+
})
|
|
396
|
+
if (!res.ok) return { ok: false, detail: `HTTP ${res.status}` }
|
|
397
|
+
const msg = parseMcpResponse(await res.text())
|
|
398
|
+
if (!msg?.result)
|
|
399
|
+
return { ok: false, detail: msg?.error?.message ?? 'MCP 응답이 아님 (서버 미배포?)' }
|
|
400
|
+
const info = msg.result.serverInfo
|
|
401
|
+
return { ok: true, server: info?.name ? `${info.name} ${info.version ?? ''}`.trim() : null }
|
|
402
|
+
} catch (e) {
|
|
403
|
+
return { ok: false, detail: e?.message ?? String(e) }
|
|
404
|
+
}
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
/** JSON 본문 또는 SSE(`data: {...}` 줄)에서 JSON-RPC 메시지 하나를 꺼낸다. 아니면 null */
|
|
408
|
+
export function parseMcpResponse(text) {
|
|
409
|
+
const t = (text ?? '').trim()
|
|
410
|
+
const candidates = t.startsWith('{')
|
|
411
|
+
? [t]
|
|
412
|
+
: t
|
|
413
|
+
.split(/\r?\n/)
|
|
414
|
+
.filter((l) => l.startsWith('data:'))
|
|
415
|
+
.map((l) => l.slice(5).trim())
|
|
416
|
+
for (const c of candidates) {
|
|
417
|
+
try {
|
|
418
|
+
const j = JSON.parse(c)
|
|
419
|
+
if (j?.jsonrpc === '2.0') return j
|
|
420
|
+
} catch {
|
|
421
|
+
/* 다음 후보 */
|
|
422
|
+
}
|
|
423
|
+
}
|
|
424
|
+
return null
|
|
425
|
+
}
|