@hubbiparts/uikit 0.1.0 → 0.2.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.
@@ -0,0 +1,244 @@
1
+ #!/usr/bin/env node
2
+ // hubbiparts-uikit — project context, catalog and maintenance helper for @hubbiparts/uikit.
3
+ // Zero dependencies on purpose: it must run from node_modules in any consumer project.
4
+ import { execFileSync } from 'node:child_process'
5
+ import fs from 'node:fs'
6
+ import path from 'node:path'
7
+ import { fileURLToPath } from 'node:url'
8
+
9
+ const PACKAGE_NAME = '@hubbiparts/uikit'
10
+ const SKILL_NAME = 'hubbiparts-uikit'
11
+ const packageRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..')
12
+ const componentsDir = path.join(packageRoot, 'dist', 'components', 'ui')
13
+
14
+ const readJson = (file) => {
15
+ try {
16
+ return JSON.parse(fs.readFileSync(file, 'utf8'))
17
+ } catch {
18
+ return null
19
+ }
20
+ }
21
+
22
+ /** Parses the `export { A, B, }` statement emitted by vite-plugin-dts. */
23
+ export const parseExportNames = (declarationSource) => {
24
+ const names = new Set()
25
+ for (const match of declarationSource.matchAll(/export\s*\{([^}]*)\}/g)) {
26
+ for (const raw of match[1].split(',')) {
27
+ const name = raw.trim().replace(/^type\s+/, '').split(/\s+as\s+/).pop()?.trim()
28
+ if (name) names.add(name)
29
+ }
30
+ }
31
+ for (const match of declarationSource.matchAll(/export\s+(?:declare\s+)?(?:function|const|type|interface)\s+([A-Za-z0-9_]+)/g)) {
32
+ names.add(match[1])
33
+ }
34
+ return [...names].sort()
35
+ }
36
+
37
+ const loadCatalog = () => {
38
+ if (!fs.existsSync(componentsDir)) {
39
+ throw new Error(`Build output not found at ${componentsDir}. Reinstall ${PACKAGE_NAME} (or run \`npm run build\` inside the kit repository).`)
40
+ }
41
+ return fs
42
+ .readdirSync(componentsDir)
43
+ .filter((file) => file.endsWith('.d.ts') && file !== 'index.d.ts')
44
+ .map((file) => {
45
+ const slug = file.replace(/\.d\.ts$/, '')
46
+ const source = fs.readFileSync(path.join(componentsDir, file), 'utf8')
47
+ return { slug, exports: parseExportNames(source) }
48
+ })
49
+ .sort((a, b) => a.slug.localeCompare(b.slug))
50
+ }
51
+
52
+ const latestPublishedVersion = () => {
53
+ try {
54
+ return execFileSync('npm', ['view', PACKAGE_NAME, 'version'], { encoding: 'utf8', timeout: 15000, stdio: ['ignore', 'pipe', 'ignore'] }).trim()
55
+ } catch {
56
+ return null
57
+ }
58
+ }
59
+
60
+ const detectPackageManager = (cwd) => {
61
+ if (fs.existsSync(path.join(cwd, 'pnpm-lock.yaml'))) return 'pnpm'
62
+ if (fs.existsSync(path.join(cwd, 'bun.lockb')) || fs.existsSync(path.join(cwd, 'bun.lock'))) return 'bun'
63
+ if (fs.existsSync(path.join(cwd, 'yarn.lock'))) return 'yarn'
64
+ return 'npm'
65
+ }
66
+
67
+ const SOURCE_EXTENSIONS = new Set(['.ts', '.tsx', '.js', '.jsx', '.css', '.scss'])
68
+ const IGNORED_DIRS = new Set(['node_modules', 'dist', 'build', '.next', 'coverage', '.git', 'storybook-static', 'dist-playground'])
69
+
70
+ const walkSources = (dir, visit, depth = 0) => {
71
+ if (depth > 8 || !fs.existsSync(dir)) return
72
+ for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
73
+ if (entry.isDirectory()) {
74
+ if (!IGNORED_DIRS.has(entry.name) && !entry.name.startsWith('.')) walkSources(path.join(dir, entry.name), visit, depth + 1)
75
+ } else if (SOURCE_EXTENSIONS.has(path.extname(entry.name))) {
76
+ visit(path.join(dir, entry.name))
77
+ }
78
+ }
79
+ }
80
+
81
+ const projectInfo = (cwd, { checkLatest }) => {
82
+ const manifest = readJson(path.join(cwd, 'package.json'))
83
+ if (!manifest) throw new Error(`No package.json found in ${cwd}. Run this command from the consumer project root.`)
84
+ const deps = { ...manifest.dependencies, ...manifest.devDependencies, ...manifest.peerDependencies }
85
+ const installedManifest = readJson(path.join(cwd, 'node_modules', ...PACKAGE_NAME.split('/'), 'package.json'))
86
+ const isKitRepository = manifest.name === PACKAGE_NAME
87
+ const catalog = loadCatalog()
88
+ const catalogSlugs = new Set(catalog.map((item) => item.slug))
89
+
90
+ const srcDir = fs.existsSync(path.join(cwd, 'src')) ? path.join(cwd, 'src') : cwd
91
+ let styleImportedIn = null
92
+ const importingFiles = []
93
+ walkSources(srcDir, (file) => {
94
+ const content = fs.readFileSync(file, 'utf8')
95
+ if (!content.includes(PACKAGE_NAME)) return
96
+ const relative = path.relative(cwd, file)
97
+ if (content.includes(`${PACKAGE_NAME}/style.css`)) styleImportedIn ??= relative
98
+ if (/from\s+['"]@hubbiparts\/uikit['"]/.test(content)) importingFiles.push(relative)
99
+ })
100
+
101
+ // Local shadcn copies that duplicate kit components are migration candidates.
102
+ const localUiDirs = ['src/components/ui', 'components/ui', 'src/shared/components/ui'].map((dir) => path.join(cwd, dir)).filter((dir) => fs.existsSync(dir))
103
+ const localDuplicates = localUiDirs.flatMap((dir) =>
104
+ fs
105
+ .readdirSync(dir)
106
+ .filter((file) => /\.(tsx|jsx)$/.test(file))
107
+ .map((file) => file.replace(/\.(tsx|jsx)$/, ''))
108
+ .filter((slug) => catalogSlugs.has(slug))
109
+ .map((slug) => path.relative(cwd, path.join(dir, `${slug}.tsx`))),
110
+ )
111
+
112
+ const tailwind = deps.tailwindcss ?? null
113
+ const tailwindMajor = tailwind ? Number(String(tailwind).replace(/^[^0-9]*/, '').split('.')[0]) : null
114
+ const installedVersion = isKitRepository ? manifest.version : installedManifest?.version ?? null
115
+ const latestVersion = checkLatest ? latestPublishedVersion() : null
116
+
117
+ return {
118
+ package: PACKAGE_NAME,
119
+ isKitRepository,
120
+ declaredRange: deps[PACKAGE_NAME] ?? null,
121
+ installedVersion,
122
+ latestVersion,
123
+ updateAvailable: Boolean(installedVersion && latestVersion && installedVersion !== latestVersion),
124
+ packageManager: detectPackageManager(cwd),
125
+ framework: deps.next ? 'next' : deps['@remix-run/react'] || deps['react-router'] ? 'react-router' : deps.vite ? 'vite' : 'unknown',
126
+ isRSC: Boolean(deps.next && (fs.existsSync(path.join(cwd, 'app')) || fs.existsSync(path.join(cwd, 'src', 'app')))),
127
+ react: deps.react ?? null,
128
+ tailwind: { version: tailwind, major: Number.isFinite(tailwindMajor) ? tailwindMajor : null },
129
+ iconLibrary: 'lucide-react',
130
+ primitiveBase: 'radix',
131
+ styleImportedIn,
132
+ importingFiles: importingFiles.slice(0, 50),
133
+ importingFilesCount: importingFiles.length,
134
+ localDuplicates,
135
+ components: catalog.map((item) => item.slug),
136
+ }
137
+ }
138
+
139
+ const printHelp = () => {
140
+ console.log(`Usage: npx hubbiparts-uikit <command> [options]
141
+
142
+ Commands:
143
+ info [--json] [--offline] Project context: versions, framework, Tailwind, CSS import, local duplicates
144
+ list [--json] Components shipped by the installed kit version and their exports
145
+ docs <slug...> Public exports and TypeScript signatures for components
146
+ outdated [--json] Compare the installed version with the latest published version
147
+ skill install [--dir <path>] [--copy]
148
+ Install the agent skill into the project (default: .agents/skills, symlink)
149
+
150
+ Examples:
151
+ npx hubbiparts-uikit info --json
152
+ npx hubbiparts-uikit docs sidebar dialog
153
+ npx hubbiparts-uikit skill install --dir .claude/skills`)
154
+ }
155
+
156
+ const installSkill = (cwd, args) => {
157
+ const dirFlag = args.indexOf('--dir')
158
+ const targetBase = path.resolve(cwd, dirFlag >= 0 && args[dirFlag + 1] ? args[dirFlag + 1] : '.agents/skills')
159
+ const source = path.join(packageRoot, 'skills', SKILL_NAME)
160
+ const target = path.join(targetBase, SKILL_NAME)
161
+ if (!fs.existsSync(path.join(source, 'SKILL.md'))) throw new Error(`Skill source not found at ${source}.`)
162
+ fs.mkdirSync(targetBase, { recursive: true })
163
+ if (fs.existsSync(target) || fs.lstatSync(target, { throwIfNoEntry: false })) {
164
+ const stat = fs.lstatSync(target)
165
+ if (!stat.isSymbolicLink()) throw new Error(`${target} already exists and is not a symlink. Remove it or pass a different --dir.`)
166
+ fs.unlinkSync(target)
167
+ }
168
+ if (args.includes('--copy')) {
169
+ fs.cpSync(source, target, { recursive: true })
170
+ console.log(`Copied skill to ${path.relative(cwd, target)} (re-run after upgrading the kit to refresh it).`)
171
+ } else {
172
+ fs.symlinkSync(path.relative(targetBase, source), target, 'dir')
173
+ console.log(`Linked ${path.relative(cwd, target)} -> ${path.relative(targetBase, source)} (tracks the installed kit version).`)
174
+ }
175
+ console.log(`Reference it from agent configs as: ${path.relative(cwd, path.join(target, 'SKILL.md'))}`)
176
+ }
177
+
178
+ const main = (argv) => {
179
+ const [command, ...args] = argv
180
+ const json = args.includes('--json')
181
+ const cwd = process.cwd()
182
+
183
+ switch (command) {
184
+ case 'info': {
185
+ const info = projectInfo(cwd, { checkLatest: !args.includes('--offline') })
186
+ if (json) console.log(JSON.stringify(info, null, 2))
187
+ else {
188
+ for (const [key, value] of Object.entries(info)) {
189
+ if (key === 'components') console.log(`components: ${value.length} (${value.join(', ')})`)
190
+ else console.log(`${key}: ${typeof value === 'object' && value !== null ? JSON.stringify(value) : value}`)
191
+ }
192
+ }
193
+ return
194
+ }
195
+ case 'list': {
196
+ const catalog = loadCatalog()
197
+ if (json) console.log(JSON.stringify(catalog, null, 2))
198
+ else for (const item of catalog) console.log(`${item.slug.padEnd(18)} ${item.exports.join(', ')}`)
199
+ return
200
+ }
201
+ case 'docs': {
202
+ const slugs = args.filter((arg) => !arg.startsWith('--'))
203
+ if (slugs.length === 0) throw new Error('Pass at least one component slug, e.g. `docs button dialog`.')
204
+ for (const slug of slugs) {
205
+ const file = path.join(componentsDir, `${slug}.d.ts`)
206
+ if (!fs.existsSync(file)) {
207
+ console.log(`## ${slug}\nNot shipped by ${PACKAGE_NAME}. Run \`npx hubbiparts-uikit list\` for the catalog.\n`)
208
+ continue
209
+ }
210
+ console.log(`## ${slug}\nimport { ${parseExportNames(fs.readFileSync(file, 'utf8')).join(', ')} } from '${PACKAGE_NAME}'\nshadcn reference: https://ui.shadcn.com/docs/components/radix/${slug}\n\n\`\`\`ts\n${fs.readFileSync(file, 'utf8').trim()}\n\`\`\`\n`)
211
+ }
212
+ return
213
+ }
214
+ case 'outdated': {
215
+ const info = projectInfo(cwd, { checkLatest: true })
216
+ const result = { installed: info.installedVersion, latest: info.latestVersion, updateAvailable: info.updateAvailable, declaredRange: info.declaredRange }
217
+ if (json) console.log(JSON.stringify(result, null, 2))
218
+ else if (!info.latestVersion) console.log('Could not reach the npm registry; latest version unknown.')
219
+ else console.log(info.updateAvailable ? `Update available: ${info.installedVersion} -> ${info.latestVersion}` : `Up to date (${info.installedVersion}).`)
220
+ return
221
+ }
222
+ case 'skill':
223
+ if (args[0] !== 'install') throw new Error('Usage: skill install [--dir <path>] [--copy]')
224
+ installSkill(cwd, args.slice(1))
225
+ return
226
+ case undefined:
227
+ case 'help':
228
+ case '--help':
229
+ case '-h':
230
+ printHelp()
231
+ return
232
+ default:
233
+ throw new Error(`Unknown command "${command}". Run \`npx hubbiparts-uikit help\`.`)
234
+ }
235
+ }
236
+
237
+ if (process.argv[1] && fs.realpathSync(process.argv[1]) === fileURLToPath(import.meta.url)) {
238
+ try {
239
+ main(process.argv.slice(2))
240
+ } catch (error) {
241
+ console.error(`hubbiparts-uikit: ${error instanceof Error ? error.message : String(error)}`)
242
+ process.exitCode = 1
243
+ }
244
+ }