basuicn 0.1.4 → 0.1.6

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/scripts/ui-cli.ts CHANGED
@@ -1,760 +1,966 @@
1
- #!/usr/bin/env node
2
- import fs from 'fs';
3
- import path from 'path';
4
- import { execSync } from 'child_process';
5
-
6
- const REGISTRY_LOCAL = './registry.json';
7
- const REGISTRY_REMOTE = 'https://raw.githubusercontent.com/huy14032003/ui-component/main/registry.json';
8
-
9
- const log = (msg: string) => console.log(`[basuicn] ${msg}`);
10
- const warn = (msg: string) => console.warn(`[basuicn] WARN: ${msg}`);
11
- const error = (msg: string) => console.error(`[basuicn] ERROR: ${msg}`);
12
-
13
- const getTargetProjectDir = () => process.cwd();
14
-
15
- // ─── Registry ──────────────────────���─────────────────────────────���────────────
16
-
17
- interface RegistryFile { path: string; content: string }
18
- interface RegistryComponent {
19
- dependencies: string[];
20
- internalDependencies?: string[];
21
- files: RegistryFile[];
22
- }
23
- interface Registry {
24
- core?: { dependencies: string[]; files: RegistryFile[] };
25
- components: Record<string, RegistryComponent>;
26
- }
27
-
28
- const validateRegistry = (data: unknown): data is Registry => {
29
- if (!data || typeof data !== 'object') return false;
30
- const reg = data as Record<string, unknown>;
31
- return 'components' in reg && typeof reg.components === 'object' && reg.components !== null;
32
- };
33
-
34
- const getRegistry = async (isLocal: boolean): Promise<Registry> => {
35
- if (isLocal && fs.existsSync(REGISTRY_LOCAL)) {
36
- log('Using local registry...');
37
- try {
38
- const data = JSON.parse(fs.readFileSync(REGISTRY_LOCAL, 'utf-8'));
39
- if (!validateRegistry(data)) {
40
- error('Invalid local registry format missing "components" field.');
41
- process.exit(1);
42
- }
43
- return data;
44
- } catch (err) {
45
- error(`Failed to parse local registry: ${err instanceof Error ? err.message : err}`);
46
- process.exit(1);
47
- }
48
- }
49
-
50
- log('Fetching registry from remote...');
51
- try {
52
- const response = await fetch(REGISTRY_REMOTE);
53
- if (!response.ok) throw new Error(`HTTP ${response.status}`);
54
- const data = await response.json();
55
- if (!validateRegistry(data)) {
56
- error('Invalid remote registry format missing "components" field.');
57
- process.exit(1);
58
- }
59
- return data;
60
- } catch (err: unknown) {
61
- const message = err instanceof Error ? err.message : String(err);
62
- error(`Cannot fetch registry: ${message}`);
63
- process.exit(1);
64
- }
65
- };
66
-
67
- // ─── npm ──────────────────────────────────────────────────────────────────────
68
-
69
- const installNpmPackages = (packages: string[], cwd: string, dev = false) => {
70
- if (packages.length === 0) return;
71
-
72
- const pkgJsonPath = path.join(cwd, 'package.json');
73
- let toInstall = packages;
74
-
75
- if (fs.existsSync(pkgJsonPath)) {
76
- const pkg = JSON.parse(fs.readFileSync(pkgJsonPath, 'utf-8'));
77
- const allDeps = { ...pkg.dependencies, ...pkg.devDependencies };
78
- toInstall = packages.filter((p) => !allDeps[p]);
79
- }
80
-
81
- if (toInstall.length === 0) return;
82
-
83
- log(`Installing: ${toInstall.join(', ')}...`);
84
- const flag = dev ? '--save-dev' : '--save';
85
- try {
86
- execSync(`npm install ${toInstall.join(' ')} ${flag}`, { stdio: 'inherit', cwd });
87
- } catch (err) {
88
- error(`Failed to install packages: ${toInstall.join(', ')}. ${err instanceof Error ? err.message : ''}`);
89
- }
90
- };
91
-
92
- // ─── Packages ───────────────────────��─────────────────────────────��───────────
93
-
94
- /** Build/dev tooling installed as devDependencies */
95
- const VITE_DEV_PACKAGES = [
96
- 'tailwindcss',
97
- '@tailwindcss/vite',
98
- '@vitejs/plugin-react',
99
- '@types/node',
100
- ];
101
-
102
- /**
103
- * Runtime packages every project using these components needs.
104
- * Installed as regular dependencies.
105
- */
106
- const RUNTIME_PACKAGES = [
107
- '@base-ui/react',
108
- 'tailwind-variants',
109
- 'clsx',
110
- 'tailwind-merge',
111
- 'tailwindcss-animate',
112
- 'lucide-react',
113
- ];
114
-
115
- // ─── Vite config ────────────────────────────���──────────────────────────��──────
116
-
117
- const VITE_CONFIG_TEMPLATE = `import { defineConfig } from 'vite';
118
- import tailwindcss from '@tailwindcss/vite';
119
- import react from '@vitejs/plugin-react';
120
- import path from 'path';
121
-
122
- export default defineConfig({
123
- plugins: [tailwindcss(), react()],
124
- resolve: {
125
- alias: {
126
- '@': path.resolve(__dirname, './src'),
127
- '@lib': path.resolve(__dirname, './src/lib'),
128
- '@components': path.resolve(__dirname, './src/components'),
129
- '@assets': path.resolve(__dirname, './src/assets'),
130
- '@pages': path.resolve(__dirname, './src/pages'),
131
- '@styles': path.resolve(__dirname, './src/styles'),
132
- },
133
- },
134
- });
135
- `;
136
-
137
- const TSCONFIG_PATHS = {
138
- '@/*': ['./src/*'],
139
- '@lib/*': ['./src/lib/*'],
140
- '@components/*': ['./src/components/*'],
141
- '@assets/*': ['./src/assets/*'],
142
- '@pages/*': ['./src/pages/*'],
143
- '@styles/*': ['./src/styles/*'],
144
- };
145
-
146
- const setupViteConfig = (cwd: string) => {
147
- installNpmPackages(VITE_DEV_PACKAGES, cwd, true);
148
-
149
- const configTs = path.join(cwd, 'vite.config.ts');
150
- const configJs = path.join(cwd, 'vite.config.js');
151
-
152
- if (!fs.existsSync(configTs) && !fs.existsSync(configJs)) {
153
- fs.writeFileSync(configTs, VITE_CONFIG_TEMPLATE);
154
- log('Created vite.config.ts.');
155
- return;
156
- }
157
-
158
- const existingPath = fs.existsSync(configTs) ? configTs : configJs;
159
- let content = fs.readFileSync(existingPath, 'utf-8');
160
-
161
- const missingImports: string[] = [];
162
- if (!content.includes('@tailwindcss/vite')) missingImports.push("import tailwindcss from '@tailwindcss/vite';");
163
- if (!content.includes('@vitejs/plugin-react')) missingImports.push("import react from '@vitejs/plugin-react';");
164
- if (!content.includes("from 'path'") && !content.includes('from "path"')) missingImports.push("import path from 'path';");
165
-
166
- const missingPlugins: string[] = [];
167
- if (!content.includes('tailwindcss()')) missingPlugins.push('tailwindcss()');
168
- if (!content.includes('react()') && !content.includes('react({')) missingPlugins.push('react()');
169
-
170
- const hasAlias = content.includes('alias:') || content.includes("'@'") || content.includes('"@"');
171
-
172
- if (missingImports.length === 0 && missingPlugins.length === 0 && hasAlias) {
173
- log('vite.config already configured — skipping.');
174
- return;
175
- }
176
-
177
- if (missingImports.length > 0) {
178
- const importBlock = missingImports.join('\n');
179
- const allImports = [...content.matchAll(/^import\s.+$/gm)];
180
- if (allImports.length > 0) {
181
- const last = allImports[allImports.length - 1];
182
- const pos = last.index! + last[0].length;
183
- content = content.slice(0, pos) + '\n' + importBlock + content.slice(pos);
184
- } else {
185
- content = importBlock + '\n' + content;
186
- }
187
- }
188
-
189
- if (missingPlugins.length > 0) {
190
- const match = content.match(/plugins:\s*\[/);
191
- if (match && match.index !== undefined) {
192
- const pos = match.index + match[0].length;
193
- const after = content.slice(pos);
194
- const pluginLines = missingPlugins.map((p) => `\n ${p},`).join('');
195
- const needsNewline = after.length > 0 && after[0] !== '\n' && after[0] !== '\r';
196
- content = content.slice(0, pos) + pluginLines + (needsNewline ? '\n ' : '') + after;
197
- }
198
- }
199
-
200
- if (!hasAlias) {
201
- const aliasBlock = [
202
- ' resolve: {',
203
- ' alias: {',
204
- " '@': path.resolve(__dirname, './src'),",
205
- " '@lib': path.resolve(__dirname, './src/lib'),",
206
- " '@components': path.resolve(__dirname, './src/components'),",
207
- " '@assets': path.resolve(__dirname, './src/assets'),",
208
- " '@pages': path.resolve(__dirname, './src/pages'),",
209
- " '@styles': path.resolve(__dirname, './src/styles'),",
210
- ' },',
211
- ' },',
212
- ].join('\n');
213
-
214
- const pluginsStart = content.search(/plugins:\s*\[/);
215
- if (pluginsStart !== -1) {
216
- let depth = 0;
217
- let foundStart = false;
218
- for (let i = pluginsStart; i < content.length; i++) {
219
- if (content[i] === '[') { depth++; foundStart = true; }
220
- if (content[i] === ']') depth--;
221
- if (foundStart && depth === 0) {
222
- let lineEnd = content.indexOf('\n', i);
223
- if (lineEnd === -1) lineEnd = content.length;
224
- content = content.slice(0, lineEnd + 1) + aliasBlock + '\n' + content.slice(lineEnd + 1);
225
- break;
226
- }
227
- }
228
- }
229
- }
230
-
231
- fs.writeFileSync(existingPath, content);
232
- log(`Updated ${path.basename(existingPath)} with Tailwind + path aliases.`);
233
- };
234
-
235
- // ─── tsconfig ────────────────────────��────────────────────────────────���───────
236
-
237
- const setupTsConfig = (cwd: string) => {
238
- const candidates = ['tsconfig.app.json', 'tsconfig.json'];
239
-
240
- for (const candidate of candidates) {
241
- const configPath = path.join(cwd, candidate);
242
- if (!fs.existsSync(configPath)) continue;
243
-
244
- const raw = fs.readFileSync(configPath, 'utf-8');
245
-
246
- if (raw.includes('"@/*"') || raw.includes("'@/*'")) {
247
- log(`${candidate} already has path aliases — skipping.`);
248
- return;
249
- }
250
-
251
- try {
252
- const stripped = raw
253
- .replace(/\/\*[\s\S]*?\*\//g, '')
254
- .replace(/(^|[\s,{[\]])\/\/[^\n]*/g, '$1');
255
- const parsed = JSON.parse(stripped) as { compilerOptions?: Record<string, unknown> };
256
- if (!parsed.compilerOptions) parsed.compilerOptions = {};
257
- parsed.compilerOptions.baseUrl = '.';
258
- parsed.compilerOptions.paths = TSCONFIG_PATHS;
259
- fs.writeFileSync(configPath, JSON.stringify(parsed, null, 2));
260
- log(`Added path aliases to ${candidate}.`);
261
- } catch (err) {
262
- warn(`Could not auto-patch ${candidate}: ${err instanceof Error ? err.message : err}`);
263
- warn('Add these to compilerOptions manually:');
264
- console.log('\n "baseUrl": ".",');
265
- console.log(' "paths": {');
266
- for (const [alias, targets] of Object.entries(TSCONFIG_PATHS)) {
267
- console.log(` "${alias}": ["${targets[0]}"],`);
268
- }
269
- console.log(' }');
270
- console.log('');
271
- }
272
- return;
273
- }
274
-
275
- const newConfig = { compilerOptions: { baseUrl: '.', paths: TSCONFIG_PATHS } };
276
- fs.writeFileSync(path.join(cwd, 'tsconfig.json'), JSON.stringify(newConfig, null, 2));
277
- log('Created tsconfig.json with path aliases.');
278
- };
279
-
280
- // ─── Core files ────────────────────────────���─────────────────────────────��────
281
-
282
- /**
283
- * Copy core files (cn.ts, index.css, ThemeProvider, themes.ts) from registry.
284
- * Pass force=true on `init` to always overwrite — keeps core up to date.
285
- */
286
- const ensureCore = (
287
- registry: { core?: { dependencies: string[]; files: RegistryFile[] } },
288
- cwd: string,
289
- options: { force?: boolean } = {}
290
- ) => {
291
- const core = registry.core;
292
- if (!core) return;
293
-
294
- installNpmPackages(core.dependencies, cwd);
295
-
296
- for (const file of core.files) {
297
- const targetPath = path.join(cwd, file.path);
298
- const targetDir = path.dirname(targetPath);
299
-
300
- if (!fs.existsSync(targetDir)) fs.mkdirSync(targetDir, { recursive: true });
301
-
302
- if (fs.existsSync(targetPath) && !options.force) {
303
- log(`Core file exists (skipping): ${file.path}`);
304
- continue;
305
- }
306
-
307
- fs.writeFileSync(targetPath, file.content);
308
- log(`${fs.existsSync(targetPath) ? 'Updated' : 'Created'} core file: ${file.path}`);
309
- }
310
- };
311
-
312
- // ─── main.tsx patching ────────────────────────────────────────────────────────
313
-
314
- /**
315
- * Components that need additional setup in the main entry file.
316
- * Key = component name in registry, value = import + JSX to inject.
317
- */
318
- const MAIN_PATCH_COMPONENTS: Record<string, { import: string; jsx: string }> = {
319
- toast: {
320
- import: "import { Toaster } from '@/components/ui/toast/Toaster';",
321
- jsx: '<Toaster position="top-center" expand={true} richColors />',
322
- },
323
- };
324
-
325
- const MAIN_CANDIDATES = ['src/main.tsx', 'src/main.jsx', 'src/index.tsx', 'src/index.jsx'];
326
-
327
- const findMainFile = (cwd: string): string | null => {
328
- for (const c of MAIN_CANDIDATES) {
329
- const p = path.join(cwd, c);
330
- if (fs.existsSync(p)) return p;
331
- }
332
- return null;
333
- };
334
-
335
- const insertImport = (content: string, importLine: string): string => {
336
- // Don't add duplicate
337
- if (content.includes(importLine)) return content;
338
- const allImports = [...content.matchAll(/^import\s.+$/gm)];
339
- if (allImports.length > 0) {
340
- const last = allImports[allImports.length - 1];
341
- const pos = last.index! + last[0].length;
342
- return content.slice(0, pos) + '\n' + importLine + content.slice(pos);
343
- }
344
- return importLine + '\n' + content;
345
- };
346
-
347
- /**
348
- * Patches the main entry file to:
349
- * 1. Import src/styles/index.css (theme variables + Tailwind)
350
- * 2. Import ThemeProvider
351
- * 3. Wrap <App /> with <ThemeProvider>
352
- *
353
- * Safe to call multiple times skips sections that are already set up.
354
- */
355
- const patchMainTsx = (cwd: string) => {
356
- const mainPath = findMainFile(cwd);
357
- if (!mainPath) {
358
- warn('Could not find entry file (src/main.tsx). Skipping main entry setup.');
359
- return;
360
- }
361
-
362
- let content = fs.readFileSync(mainPath, 'utf-8');
363
- let changed = false;
364
-
365
- // 1. Ensure styles/index.css is imported
366
- const cssImportLine = "import './styles/index.css';";
367
- const hasCssImport = content.includes('styles/index.css') || content.includes('index.css');
368
- if (!hasCssImport) {
369
- // Insert at top before other imports
370
- const firstImport = content.match(/^import\s/m);
371
- if (firstImport?.index !== undefined) {
372
- content = content.slice(0, firstImport.index) + cssImportLine + '\n' + content.slice(firstImport.index);
373
- } else {
374
- content = cssImportLine + '\n' + content;
375
- }
376
- changed = true;
377
- } else if (!content.includes('styles/index.css')) {
378
- // Has some CSS import but not our theme CSS — add it alongside
379
- content = insertImport(content, cssImportLine);
380
- changed = true;
381
- }
382
-
383
- // 2. ThemeProvider
384
- if (!content.includes('ThemeProvider')) {
385
- content = insertImport(content, "import { ThemeProvider } from '@/lib/theme/ThemeProvider';");
386
-
387
- const wrapped = content.replace(/(<App\s*\/>)/g, '<ThemeProvider>\n $1\n </ThemeProvider>');
388
- if (wrapped === content) {
389
- warn('Could not locate <App /> in entry file — add <ThemeProvider> wrapper manually.');
390
- } else {
391
- content = wrapped;
392
- }
393
- changed = true;
394
- }
395
-
396
- if (changed) {
397
- fs.writeFileSync(mainPath, content);
398
- log(`Patched ${path.relative(cwd, mainPath)}.`);
399
- } else {
400
- log(`${path.relative(cwd, mainPath)} already configured — skipping.`);
401
- }
402
- };
403
-
404
- /**
405
- * Injects a component's bootstrap JSX (e.g. <Toaster />) into the main entry file.
406
- * Places it inside <ThemeProvider> after <App />, falls back to right after <App />.
407
- */
408
- const patchMainTsxComponent = (cwd: string, componentName: string) => {
409
- const patch = MAIN_PATCH_COMPONENTS[componentName];
410
- if (!patch) return;
411
-
412
- const mainPath = findMainFile(cwd);
413
- if (!mainPath) return;
414
-
415
- let content = fs.readFileSync(mainPath, 'utf-8');
416
- // Check by component tag name (e.g. "Toaster")
417
- const tagName = patch.jsx.match(/<(\w+)/)?.[1];
418
- if (tagName && content.includes(`<${tagName}`)) return;
419
-
420
- content = insertImport(content, patch.import);
421
-
422
- const withProvider = content.replace(
423
- /(<App\s*\/>)(\s*\n\s*<\/ThemeProvider>)/,
424
- `$1\n ${patch.jsx}$2`
425
- );
426
-
427
- if (withProvider !== content) {
428
- fs.writeFileSync(mainPath, withProvider);
429
- } else {
430
- const fallback = content.replace(/(<App\s*\/>)/, `$1\n ${patch.jsx}`);
431
- if (fallback !== content) fs.writeFileSync(mainPath, fallback);
432
- }
433
-
434
- log(`Added <${tagName}> to ${path.relative(cwd, mainPath)}.`);
435
- };
436
-
437
- // ─── Component add/remove ──────────────────────────��──────────────────────────
438
-
439
- const addComponent = (
440
- name: string,
441
- registry: { core?: unknown; components: Record<string, RegistryComponent> },
442
- cwd: string,
443
- options: { force: boolean },
444
- added: Set<string> = new Set()
445
- ) => {
446
- if (added.has(name)) return;
447
- added.add(name);
448
-
449
- const component = registry.components[name];
450
- if (!component) {
451
- error(`Component "${name}" not found. Run 'list' to see available components.`);
452
- return;
453
- }
454
-
455
- log(`Adding: ${name}...`);
456
-
457
- ensureCore(registry as Parameters<typeof ensureCore>[0], cwd);
458
- installNpmPackages(component.dependencies, cwd);
459
-
460
- if (component.internalDependencies) {
461
- for (const dep of component.internalDependencies) {
462
- if (registry.components[dep]) {
463
- addComponent(dep, registry, cwd, options, added);
464
- }
465
- }
466
- }
467
-
468
- for (const file of component.files) {
469
- const targetPath = path.join(cwd, file.path);
470
- const targetDir = path.dirname(targetPath);
471
-
472
- if (!fs.existsSync(targetDir)) fs.mkdirSync(targetDir, { recursive: true });
473
-
474
- if (fs.existsSync(targetPath) && !options.force) {
475
- warn(`Skipped (exists): ${file.path} — use --force to overwrite`);
476
- continue;
477
- }
478
-
479
- fs.writeFileSync(targetPath, file.content);
480
- log(`Created: ${file.path}`);
481
- }
482
- };
483
-
484
- const removeComponent = (
485
- name: string,
486
- registry: { components: Record<string, { files: { path: string }[] }> },
487
- cwd: string
488
- ) => {
489
- const component = registry.components[name];
490
- if (!component) {
491
- error(`Component "${name}" not found.`);
492
- return;
493
- }
494
-
495
- log(`Removing: ${name}...`);
496
-
497
- for (const file of component.files) {
498
- const targetPath = path.join(cwd, file.path);
499
- if (fs.existsSync(targetPath)) {
500
- fs.unlinkSync(targetPath);
501
- log(`Deleted: ${file.path}`);
502
- }
503
- }
504
-
505
- for (const file of component.files) {
506
- const targetDir = path.dirname(path.join(cwd, file.path));
507
- try {
508
- if (fs.existsSync(targetDir) && fs.readdirSync(targetDir).length === 0) {
509
- fs.rmdirSync(targetDir);
510
- log(`Removed empty dir: ${path.relative(cwd, targetDir)}`);
511
- }
512
- } catch (err) {
513
- warn(`Could not remove directory: ${err instanceof Error ? err.message : err}`);
514
- }
515
- }
516
- };
517
-
518
- // ─── Commands ─────────────────────────────────────────────────────────────────
519
-
520
- const main = async () => {
521
- const args = process.argv.slice(2);
522
- const isLocal = args.includes('--local');
523
- const isForce = args.includes('--force');
524
- const filteredArgs = args.filter((a) => !a.startsWith('--'));
525
- const command = filteredArgs[0];
526
- const componentNames = filteredArgs.slice(1);
527
-
528
- const cwd = getTargetProjectDir();
529
- const registry = await getRegistry(isLocal);
530
-
531
- switch (command) {
532
-
533
- case 'init': {
534
- log('Initializing project...');
535
- setupViteConfig(cwd);
536
- setupTsConfig(cwd);
537
- installNpmPackages(RUNTIME_PACKAGES, cwd);
538
- // force=true so init always refreshes core files to latest version
539
- ensureCore(registry, cwd, { force: true });
540
- patchMainTsx(cwd);
541
- log('Initialization complete.');
542
- break;
543
- }
544
-
545
- case 'add': {
546
- if (componentNames.length === 0) {
547
- error('Usage: npx basuicn add <component-name> [--force]');
548
- return;
549
- }
550
-
551
- // Auto-init if project hasn't been initialized yet
552
- const cnPath = path.join(cwd, 'src/lib/utils/cn.ts');
553
- if (!fs.existsSync(cnPath)) {
554
- log('Project not initialized — running init first...');
555
- setupViteConfig(cwd);
556
- setupTsConfig(cwd);
557
- installNpmPackages(RUNTIME_PACKAGES, cwd);
558
- ensureCore(registry, cwd, { force: true });
559
- patchMainTsx(cwd);
560
- }
561
-
562
- for (const name of componentNames) {
563
- addComponent(name, registry, cwd, { force: isForce });
564
- patchMainTsxComponent(cwd, name);
565
- }
566
- log('Done!');
567
- break;
568
- }
569
-
570
- case 'update': {
571
- if (componentNames.length === 0) {
572
- error('Usage: npx basuicn update <component-name> [...]');
573
- return;
574
- }
575
- for (const name of componentNames) {
576
- log(`Updating: ${name}...`);
577
- addComponent(name, registry, cwd, { force: true });
578
- }
579
- log('Update complete.');
580
- break;
581
- }
582
-
583
- case 'remove': {
584
- if (componentNames.length === 0) {
585
- error('Usage: npx basuicn remove <component-name>');
586
- return;
587
- }
588
- for (const name of componentNames) {
589
- removeComponent(name, registry, cwd);
590
- }
591
- log('Done!');
592
- break;
593
- }
594
-
595
- case 'list': {
596
- const components = Object.keys(registry.components).sort();
597
- log(`Available components (${components.length}):`);
598
- for (const k of components) {
599
- const comp = registry.components[k];
600
- const deps = comp.internalDependencies?.filter(Boolean);
601
- const depStr = deps?.length ? ` (requires: ${deps.join(', ')})` : '';
602
- console.log(` - ${k}${depStr}`);
603
- }
604
- break;
605
- }
606
-
607
- case 'diff': {
608
- if (componentNames.length === 0) {
609
- error('Usage: npx basuicn diff <component-name>');
610
- return;
611
- }
612
- for (const name of componentNames) {
613
- const component = registry.components[name];
614
- if (!component) {
615
- error(`Component "${name}" not found. Run 'list' to see available components.`);
616
- continue;
617
- }
618
- let hasDiff = false;
619
- console.log(`\n[diff] ${name}`);
620
- for (const file of component.files) {
621
- const targetPath = path.join(cwd, file.path);
622
- if (!fs.existsSync(targetPath)) {
623
- console.log(` + [new file] ${file.path}`);
624
- hasDiff = true;
625
- continue;
626
- }
627
- const localContent = fs.readFileSync(targetPath, 'utf-8');
628
- if (localContent === file.content) continue;
629
- hasDiff = true;
630
- console.log(`\n ~ ${file.path}`);
631
- const localLines = localContent.split('\n');
632
- const remoteLines = file.content.split('\n');
633
- const maxLen = Math.max(localLines.length, remoteLines.length);
634
- let shownLines = 0;
635
- for (let i = 0; i < maxLen; i++) {
636
- if (localLines[i] !== remoteLines[i]) {
637
- if (localLines[i] !== undefined) console.log(` - ${localLines[i]}`);
638
- if (remoteLines[i] !== undefined) console.log(` + ${remoteLines[i]}`);
639
- shownLines++;
640
- if (shownLines >= 20) {
641
- const remaining = maxLen - i - 1;
642
- if (remaining > 0) console.log(` ... and ${remaining} more lines`);
643
- break;
644
- }
645
- }
646
- }
647
- }
648
- if (!hasDiff) log(`${name}: already up to date.`);
649
- }
650
- break;
651
- }
652
-
653
- case 'doctor': {
654
- log('Running project health check...\n');
655
- let issues = 0;
656
- const check = (ok: boolean, msg: string, fix?: string) => {
657
- console.log(`${ok ? ' ✓' : ' ✗'} ${msg}`);
658
- if (!ok) { if (fix) console.log(` → ${fix}`); issues++; }
659
- };
660
-
661
- // ── Core files ──────────────────────────────────────────────────
662
- check(fs.existsSync(path.join(cwd, 'src/lib/utils/cn.ts')),
663
- 'src/lib/utils/cn.ts', 'run: npx basuicn init');
664
- check(fs.existsSync(path.join(cwd, 'src/lib/theme/themes.ts')),
665
- 'src/lib/theme/themes.ts', 'run: npx basuicn init');
666
- check(fs.existsSync(path.join(cwd, 'src/lib/theme/ThemeProvider.tsx')),
667
- 'src/lib/theme/ThemeProvider.tsx', 'run: npx basuicn init');
668
- check(fs.existsSync(path.join(cwd, 'src/styles/index.css')),
669
- 'src/styles/index.css (theme variables)', 'run: npx basuicn init');
670
-
671
- // ── main entry ────────────────���──────────────────────────��──────
672
- const mainPath = findMainFile(cwd);
673
- if (mainPath) {
674
- const mainContent = fs.readFileSync(mainPath, 'utf-8');
675
- check(mainContent.includes('ThemeProvider'),
676
- 'ThemeProvider in main entry', 'run: npx basuicn init');
677
- check(mainContent.includes('styles/index.css') || mainContent.includes('index.css'),
678
- 'CSS import in main entry', 'run: npx basuicn init');
679
- } else {
680
- check(false, 'main entry file (src/main.tsx)', 'create src/main.tsx');
681
- }
682
-
683
- // ── Runtime packages ─────────────────────────��──────────────────
684
- const pkgPath = path.join(cwd, 'package.json');
685
- if (fs.existsSync(pkgPath)) {
686
- const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf-8'));
687
- const allDeps = { ...pkg.dependencies, ...pkg.devDependencies };
688
- for (const dep of RUNTIME_PACKAGES) {
689
- check(!!allDeps[dep], `package: ${dep}`, `run: npm install ${dep}`);
690
- }
691
- // Dev packages
692
- for (const dep of VITE_DEV_PACKAGES) {
693
- check(!!allDeps[dep], `package (dev): ${dep}`, `run: npm install -D ${dep}`);
694
- }
695
- } else {
696
- check(false, 'package.json found', 'run: npm init -y');
697
- }
698
-
699
- // ── Config files ────────────────────���───────────────────────────
700
- const hasTailwindInCss = (() => {
701
- const candidates = ['src/styles/index.css', 'src/index.css', 'src/App.css'];
702
- return candidates.some(f => {
703
- const p = path.join(cwd, f);
704
- if (!fs.existsSync(p)) return false;
705
- const c = fs.readFileSync(p, 'utf-8');
706
- return c.includes('@import "tailwindcss"') || c.includes("@import 'tailwindcss'");
707
- });
708
- })();
709
- check(hasTailwindInCss, '@import "tailwindcss" in CSS', 'run: npx basuicn init');
710
-
711
- const tsCandidates = ['tsconfig.app.json', 'tsconfig.json'];
712
- const hasAlias = tsCandidates.some(f => {
713
- const p = path.join(cwd, f);
714
- if (!fs.existsSync(p)) return false;
715
- const c = fs.readFileSync(p, 'utf-8');
716
- return c.includes('"@/*"') || c.includes("'@/*'");
717
- });
718
- check(hasAlias, 'TypeScript path aliases (@/*)', 'run: npx basuicn init');
719
-
720
- const hasViteConfig =
721
- fs.existsSync(path.join(cwd, 'vite.config.ts')) ||
722
- fs.existsSync(path.join(cwd, 'vite.config.js'));
723
- check(hasViteConfig, 'vite.config.ts / vite.config.js', 'run: npx basuicn init');
724
-
725
- console.log('');
726
- if (issues === 0) {
727
- log('All checks passed! Project is healthy.');
728
- } else {
729
- warn(`${issues} issue(s) found. Run "npx basuicn init" to fix most issues.`);
730
- }
731
- break;
732
- }
733
-
734
- default: {
735
- console.log(`
736
- basuicn — UI Component CLI
737
-
738
- Commands:
739
- init Set up project: install deps, copy core files, patch main entry
740
- add <name> [--force] Add component(s) to your project
741
- update <name> Update component(s) to latest registry version
742
- diff <name> Show diff between local and registry version
743
- remove <name> Remove component(s) from your project
744
- list List all available components
745
- doctor Check project health and configuration
746
-
747
- Options:
748
- --local Use local registry.json instead of remote
749
- --force Overwrite existing files when adding
750
-
751
- Quick start:
752
- npx basuicn init
753
- npx basuicn add button
754
- npx basuicn add toast
755
- `);
756
- }
757
- }
758
- };
759
-
760
- main();
1
+ #!/usr/bin/env node
2
+ import fs from 'fs';
3
+ import path from 'path';
4
+ import { execSync } from 'child_process';
5
+ import readline from 'readline';
6
+
7
+ // ─── Constants ────────────────────────────────────────────────────────────────
8
+
9
+ const VERSION = '0.1.6';
10
+ const REGISTRY_LOCAL = './registry.json';
11
+ const REGISTRY_REMOTE = 'https://raw.githubusercontent.com/Basuicn/basuicn-core/main/registry.json';
12
+
13
+ // ─── Colors (ANSI) ───────────────────────────────────────────────────────────
14
+
15
+ const c = {
16
+ reset: '\x1b[0m',
17
+ bold: '\x1b[1m',
18
+ dim: '\x1b[2m',
19
+ green: '\x1b[32m',
20
+ yellow: '\x1b[33m',
21
+ red: '\x1b[31m',
22
+ cyan: '\x1b[36m',
23
+ magenta: '\x1b[35m',
24
+ blue: '\x1b[34m',
25
+ gray: '\x1b[90m',
26
+ };
27
+
28
+ const log = (msg: string) => console.log(`${c.cyan}▸${c.reset} ${msg}`);
29
+ const ok = (msg: string) => console.log(`${c.green}✔${c.reset} ${msg}`);
30
+ const warn = (msg: string) => console.warn(`${c.yellow}⚠${c.reset} ${msg}`);
31
+ const error = (msg: string) => console.error(`${c.red}✖${c.reset} ${msg}`);
32
+
33
+ const getTargetProjectDir = () => process.cwd();
34
+
35
+ // ─── Interactive prompt ──────────────────────────────────────────────────────
36
+
37
+ const ask = (question: string): Promise<string> => {
38
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
39
+ return new Promise((resolve) => {
40
+ rl.question(`${c.cyan}?${c.reset} ${question} `, (answer) => {
41
+ rl.close();
42
+ resolve(answer.trim());
43
+ });
44
+ });
45
+ };
46
+
47
+ const confirm = async (question: string, defaultYes = true): Promise<boolean> => {
48
+ const hint = defaultYes ? 'Y/n' : 'y/N';
49
+ const answer = await ask(`${question} ${c.dim}(${hint})${c.reset}`);
50
+ if (!answer) return defaultYes;
51
+ return answer.toLowerCase().startsWith('y');
52
+ };
53
+
54
+ // ─── Registry ─────────────────────────────────────────────────────────────────
55
+
56
+ interface RegistryFile { path: string; content: string }
57
+ interface RegistryComponent {
58
+ dependencies: string[];
59
+ internalDependencies?: string[];
60
+ files: RegistryFile[];
61
+ }
62
+ interface Registry {
63
+ core?: { dependencies: string[]; files: RegistryFile[] };
64
+ components: Record<string, RegistryComponent>;
65
+ }
66
+
67
+ const validateRegistry = (data: unknown): data is Registry => {
68
+ if (!data || typeof data !== 'object') return false;
69
+ const reg = data as Record<string, unknown>;
70
+ return 'components' in reg && typeof reg.components === 'object' && reg.components !== null;
71
+ };
72
+
73
+ const getRegistry = async (isLocal: boolean): Promise<Registry> => {
74
+ if (isLocal && fs.existsSync(REGISTRY_LOCAL)) {
75
+ log('Using local registry...');
76
+ try {
77
+ const data = JSON.parse(fs.readFileSync(REGISTRY_LOCAL, 'utf-8'));
78
+ if (!validateRegistry(data)) {
79
+ error('Invalid local registry format — missing "components" field.');
80
+ process.exit(1);
81
+ }
82
+ return data;
83
+ } catch (err) {
84
+ error(`Failed to parse local registry: ${err instanceof Error ? err.message : err}`);
85
+ process.exit(1);
86
+ }
87
+ }
88
+
89
+ log('Fetching registry from remote...');
90
+ try {
91
+ const response = await fetch(REGISTRY_REMOTE);
92
+ if (!response.ok) throw new Error(`HTTP ${response.status}`);
93
+ const data = await response.json();
94
+ if (!validateRegistry(data)) {
95
+ error('Invalid remote registry format — missing "components" field.');
96
+ process.exit(1);
97
+ }
98
+ return data;
99
+ } catch (err: unknown) {
100
+ const message = err instanceof Error ? err.message : String(err);
101
+ error(`Cannot fetch registry: ${message}`);
102
+ process.exit(1);
103
+ }
104
+ };
105
+
106
+ // ─── npm ──────────────────────────────────────────────────────────────────────
107
+
108
+ const installNpmPackages = (packages: string[], cwd: string, dev = false) => {
109
+ if (packages.length === 0) return;
110
+
111
+ const pkgJsonPath = path.join(cwd, 'package.json');
112
+ let toInstall = packages;
113
+
114
+ if (fs.existsSync(pkgJsonPath)) {
115
+ const pkg = JSON.parse(fs.readFileSync(pkgJsonPath, 'utf-8'));
116
+ const allDeps = { ...pkg.dependencies, ...pkg.devDependencies };
117
+ toInstall = packages.filter((p) => !allDeps[p]);
118
+ }
119
+
120
+ if (toInstall.length === 0) return;
121
+
122
+ log(`Installing: ${c.bold}${toInstall.join(', ')}${c.reset}...`);
123
+ const flag = dev ? '--save-dev' : '--save';
124
+ try {
125
+ execSync(`npm install ${toInstall.join(' ')} ${flag}`, { stdio: 'inherit', cwd });
126
+ } catch (err) {
127
+ error(`Failed to install packages: ${toInstall.join(', ')}. ${err instanceof Error ? err.message : ''}`);
128
+ }
129
+ };
130
+
131
+ // ─── Packages ─────────────────────────────────────────────────────────────────
132
+
133
+ const VITE_DEV_PACKAGES = [
134
+ 'tailwindcss',
135
+ '@tailwindcss/vite',
136
+ '@vitejs/plugin-react',
137
+ '@types/node',
138
+ ];
139
+
140
+ const RUNTIME_PACKAGES = [
141
+ '@base-ui/react',
142
+ 'tailwind-variants',
143
+ 'clsx',
144
+ 'tailwind-merge',
145
+ 'tailwindcss-animate',
146
+ 'lucide-react',
147
+ ];
148
+
149
+ // ─── Vite config ──────────────────────────────────────────────────────────────
150
+
151
+ const VITE_CONFIG_TEMPLATE = `import { defineConfig } from 'vite';
152
+ import tailwindcss from '@tailwindcss/vite';
153
+ import react from '@vitejs/plugin-react';
154
+ import path from 'path';
155
+
156
+ export default defineConfig({
157
+ plugins: [tailwindcss(), react()],
158
+ resolve: {
159
+ alias: {
160
+ '@': path.resolve(__dirname, './src'),
161
+ '@lib': path.resolve(__dirname, './src/lib'),
162
+ '@components': path.resolve(__dirname, './src/components'),
163
+ '@assets': path.resolve(__dirname, './src/assets'),
164
+ '@pages': path.resolve(__dirname, './src/pages'),
165
+ '@styles': path.resolve(__dirname, './src/styles'),
166
+ },
167
+ },
168
+ });
169
+ `;
170
+
171
+ const TSCONFIG_PATHS = {
172
+ '@/*': ['./src/*'],
173
+ '@lib/*': ['./src/lib/*'],
174
+ '@components/*': ['./src/components/*'],
175
+ '@assets/*': ['./src/assets/*'],
176
+ '@pages/*': ['./src/pages/*'],
177
+ '@styles/*': ['./src/styles/*'],
178
+ };
179
+
180
+ const setupViteConfig = (cwd: string) => {
181
+ installNpmPackages(VITE_DEV_PACKAGES, cwd, true);
182
+
183
+ const configTs = path.join(cwd, 'vite.config.ts');
184
+ const configJs = path.join(cwd, 'vite.config.js');
185
+
186
+ if (!fs.existsSync(configTs) && !fs.existsSync(configJs)) {
187
+ fs.writeFileSync(configTs, VITE_CONFIG_TEMPLATE);
188
+ ok('Created vite.config.ts.');
189
+ return;
190
+ }
191
+
192
+ const existingPath = fs.existsSync(configTs) ? configTs : configJs;
193
+ let content = fs.readFileSync(existingPath, 'utf-8');
194
+
195
+ const missingImports: string[] = [];
196
+ if (!content.includes('@tailwindcss/vite')) missingImports.push("import tailwindcss from '@tailwindcss/vite';");
197
+ if (!content.includes('@vitejs/plugin-react')) missingImports.push("import react from '@vitejs/plugin-react';");
198
+ if (!content.includes("from 'path'") && !content.includes('from "path"')) missingImports.push("import path from 'path';");
199
+
200
+ const missingPlugins: string[] = [];
201
+ if (!content.includes('tailwindcss()')) missingPlugins.push('tailwindcss()');
202
+ if (!content.includes('react()') && !content.includes('react({')) missingPlugins.push('react()');
203
+
204
+ const hasAlias = content.includes('alias:') || content.includes("'@'") || content.includes('"@"');
205
+
206
+ if (missingImports.length === 0 && missingPlugins.length === 0 && hasAlias) {
207
+ ok('vite.config already configured — skipping.');
208
+ return;
209
+ }
210
+
211
+ if (missingImports.length > 0) {
212
+ const importBlock = missingImports.join('\n');
213
+ const allImports = [...content.matchAll(/^import\s.+$/gm)];
214
+ if (allImports.length > 0) {
215
+ const last = allImports[allImports.length - 1];
216
+ const pos = last.index! + last[0].length;
217
+ content = content.slice(0, pos) + '\n' + importBlock + content.slice(pos);
218
+ } else {
219
+ content = importBlock + '\n' + content;
220
+ }
221
+ }
222
+
223
+ if (missingPlugins.length > 0) {
224
+ const match = content.match(/plugins:\s*\[/);
225
+ if (match && match.index !== undefined) {
226
+ const pos = match.index + match[0].length;
227
+ const after = content.slice(pos);
228
+ const pluginLines = missingPlugins.map((p) => `\n ${p},`).join('');
229
+ const needsNewline = after.length > 0 && after[0] !== '\n' && after[0] !== '\r';
230
+ content = content.slice(0, pos) + pluginLines + (needsNewline ? '\n ' : '') + after;
231
+ }
232
+ }
233
+
234
+ if (!hasAlias) {
235
+ const aliasBlock = [
236
+ ' resolve: {',
237
+ ' alias: {',
238
+ " '@': path.resolve(__dirname, './src'),",
239
+ " '@lib': path.resolve(__dirname, './src/lib'),",
240
+ " '@components': path.resolve(__dirname, './src/components'),",
241
+ " '@assets': path.resolve(__dirname, './src/assets'),",
242
+ " '@pages': path.resolve(__dirname, './src/pages'),",
243
+ " '@styles': path.resolve(__dirname, './src/styles'),",
244
+ ' },',
245
+ ' },',
246
+ ].join('\n');
247
+
248
+ const pluginsStart = content.search(/plugins:\s*\[/);
249
+ if (pluginsStart !== -1) {
250
+ let depth = 0;
251
+ let foundStart = false;
252
+ for (let i = pluginsStart; i < content.length; i++) {
253
+ if (content[i] === '[') { depth++; foundStart = true; }
254
+ if (content[i] === ']') depth--;
255
+ if (foundStart && depth === 0) {
256
+ let lineEnd = content.indexOf('\n', i);
257
+ if (lineEnd === -1) lineEnd = content.length;
258
+ content = content.slice(0, lineEnd + 1) + aliasBlock + '\n' + content.slice(lineEnd + 1);
259
+ break;
260
+ }
261
+ }
262
+ }
263
+ }
264
+
265
+ fs.writeFileSync(existingPath, content);
266
+ ok(`Updated ${path.basename(existingPath)} with Tailwind + path aliases.`);
267
+ };
268
+
269
+ // ─── tsconfig ─────────────────────────────────────────────────────────────────
270
+
271
+ const setupTsConfig = (cwd: string) => {
272
+ const candidates = ['tsconfig.app.json', 'tsconfig.json'];
273
+
274
+ for (const candidate of candidates) {
275
+ const configPath = path.join(cwd, candidate);
276
+ if (!fs.existsSync(configPath)) continue;
277
+
278
+ const raw = fs.readFileSync(configPath, 'utf-8');
279
+
280
+ if (raw.includes('"@/*"') || raw.includes("'@/*'")) {
281
+ ok(`${candidate} already has path aliases — skipping.`);
282
+ return;
283
+ }
284
+
285
+ try {
286
+ const stripped = raw
287
+ .replace(/\/\*[\s\S]*?\*\//g, '')
288
+ .replace(/(^|[\s,{[\]])\/\/[^\n]*/g, '$1');
289
+ const parsed = JSON.parse(stripped) as { compilerOptions?: Record<string, unknown> };
290
+ if (!parsed.compilerOptions) parsed.compilerOptions = {};
291
+ parsed.compilerOptions.baseUrl = '.';
292
+ parsed.compilerOptions.paths = TSCONFIG_PATHS;
293
+ fs.writeFileSync(configPath, JSON.stringify(parsed, null, 2));
294
+ ok(`Added path aliases to ${candidate}.`);
295
+ } catch (err) {
296
+ warn(`Could not auto-patch ${candidate}: ${err instanceof Error ? err.message : err}`);
297
+ warn('Add these to compilerOptions manually:');
298
+ console.log('\n "baseUrl": ".",');
299
+ console.log(' "paths": {');
300
+ for (const [alias, targets] of Object.entries(TSCONFIG_PATHS)) {
301
+ console.log(` "${alias}": ["${targets[0]}"],`);
302
+ }
303
+ console.log(' }');
304
+ console.log('');
305
+ }
306
+ return;
307
+ }
308
+
309
+ const newConfig = { compilerOptions: { baseUrl: '.', paths: TSCONFIG_PATHS } };
310
+ fs.writeFileSync(path.join(cwd, 'tsconfig.json'), JSON.stringify(newConfig, null, 2));
311
+ ok('Created tsconfig.json with path aliases.');
312
+ };
313
+
314
+ // ─── Core files ───────────────────────────────────────────────────────────────
315
+
316
+ const ensureCore = (
317
+ registry: { core?: { dependencies: string[]; files: RegistryFile[] } },
318
+ cwd: string,
319
+ options: { force?: boolean } = {}
320
+ ) => {
321
+ const core = registry.core;
322
+ if (!core) return;
323
+
324
+ installNpmPackages(core.dependencies, cwd);
325
+
326
+ for (const file of core.files) {
327
+ const targetPath = path.join(cwd, file.path);
328
+ const targetDir = path.dirname(targetPath);
329
+
330
+ if (!fs.existsSync(targetDir)) fs.mkdirSync(targetDir, { recursive: true });
331
+
332
+ if (fs.existsSync(targetPath) && !options.force) {
333
+ log(`Core file exists (skipping): ${c.dim}${file.path}${c.reset}`);
334
+ continue;
335
+ }
336
+
337
+ fs.writeFileSync(targetPath, file.content);
338
+ ok(`${fs.existsSync(targetPath) ? 'Updated' : 'Created'} core file: ${file.path}`);
339
+ }
340
+ };
341
+
342
+ // ─── main.tsx patching ────────────────────────────────────────────────────────
343
+
344
+ const MAIN_PATCH_COMPONENTS: Record<string, { import: string; jsx: string }> = {
345
+ toast: {
346
+ import: "import { Toaster } from '@/components/ui/toast/Toaster';",
347
+ jsx: '<Toaster position="top-center" expand={true} richColors />',
348
+ },
349
+ };
350
+
351
+ const MAIN_CANDIDATES = ['src/main.tsx', 'src/main.jsx', 'src/index.tsx', 'src/index.jsx'];
352
+
353
+ const findMainFile = (cwd: string): string | null => {
354
+ for (const c of MAIN_CANDIDATES) {
355
+ const p = path.join(cwd, c);
356
+ if (fs.existsSync(p)) return p;
357
+ }
358
+ return null;
359
+ };
360
+
361
+ const insertImport = (content: string, importLine: string): string => {
362
+ if (content.includes(importLine)) return content;
363
+ const allImports = [...content.matchAll(/^import\s.+$/gm)];
364
+ if (allImports.length > 0) {
365
+ const last = allImports[allImports.length - 1];
366
+ const pos = last.index! + last[0].length;
367
+ return content.slice(0, pos) + '\n' + importLine + content.slice(pos);
368
+ }
369
+ return importLine + '\n' + content;
370
+ };
371
+
372
+ const patchMainTsx = (cwd: string) => {
373
+ const mainPath = findMainFile(cwd);
374
+ if (!mainPath) {
375
+ warn('Could not find entry file (src/main.tsx). Skipping main entry setup.');
376
+ return;
377
+ }
378
+
379
+ let content = fs.readFileSync(mainPath, 'utf-8');
380
+ let changed = false;
381
+
382
+ const cssImportLine = "import './styles/index.css';";
383
+ const hasCssImport = content.includes('styles/index.css') || content.includes('index.css');
384
+ if (!hasCssImport) {
385
+ const firstImport = content.match(/^import\s/m);
386
+ if (firstImport?.index !== undefined) {
387
+ content = content.slice(0, firstImport.index) + cssImportLine + '\n' + content.slice(firstImport.index);
388
+ } else {
389
+ content = cssImportLine + '\n' + content;
390
+ }
391
+ changed = true;
392
+ } else if (!content.includes('styles/index.css')) {
393
+ content = insertImport(content, cssImportLine);
394
+ changed = true;
395
+ }
396
+
397
+ if (!content.includes('ThemeProvider')) {
398
+ content = insertImport(content, "import { ThemeProvider } from '@/lib/theme/ThemeProvider';");
399
+
400
+ const wrapped = content.replace(/(<App\s*\/>)/g, '<ThemeProvider>\n $1\n </ThemeProvider>');
401
+ if (wrapped === content) {
402
+ warn('Could not locate <App /> in entry file — add <ThemeProvider> wrapper manually.');
403
+ } else {
404
+ content = wrapped;
405
+ }
406
+ changed = true;
407
+ }
408
+
409
+ if (changed) {
410
+ fs.writeFileSync(mainPath, content);
411
+ ok(`Patched ${path.relative(cwd, mainPath)}.`);
412
+ } else {
413
+ ok(`${path.relative(cwd, mainPath)} already configured — skipping.`);
414
+ }
415
+ };
416
+
417
+ const patchMainTsxComponent = (cwd: string, componentName: string) => {
418
+ const patch = MAIN_PATCH_COMPONENTS[componentName];
419
+ if (!patch) return;
420
+
421
+ const mainPath = findMainFile(cwd);
422
+ if (!mainPath) return;
423
+
424
+ let content = fs.readFileSync(mainPath, 'utf-8');
425
+ const tagName = patch.jsx.match(/<(\w+)/)?.[1];
426
+ if (tagName && content.includes(`<${tagName}`)) return;
427
+
428
+ content = insertImport(content, patch.import);
429
+
430
+ const withProvider = content.replace(
431
+ /(<App\s*\/>)(\s*\n\s*<\/ThemeProvider>)/,
432
+ `$1\n ${patch.jsx}$2`
433
+ );
434
+
435
+ if (withProvider !== content) {
436
+ fs.writeFileSync(mainPath, withProvider);
437
+ } else {
438
+ const fallback = content.replace(/(<App\s*\/>)/, `$1\n ${patch.jsx}`);
439
+ if (fallback !== content) fs.writeFileSync(mainPath, fallback);
440
+ }
441
+
442
+ ok(`Added <${tagName}> to ${path.relative(cwd, mainPath)}.`);
443
+ };
444
+
445
+ // ─── Component add/remove ─────────────────────────────────────────────────────
446
+
447
+ const addComponent = (
448
+ name: string,
449
+ registry: { core?: unknown; components: Record<string, RegistryComponent> },
450
+ cwd: string,
451
+ options: { force: boolean },
452
+ added: Set<string> = new Set()
453
+ ) => {
454
+ if (added.has(name)) return;
455
+ added.add(name);
456
+
457
+ const component = registry.components[name];
458
+ if (!component) {
459
+ error(`Component "${name}" not found. Run '${c.cyan}basuicn list${c.reset}' to see available components.`);
460
+ return;
461
+ }
462
+
463
+ log(`Adding: ${c.bold}${name}${c.reset}...`);
464
+
465
+ ensureCore(registry as Parameters<typeof ensureCore>[0], cwd);
466
+ installNpmPackages(component.dependencies, cwd);
467
+
468
+ if (component.internalDependencies) {
469
+ for (const dep of component.internalDependencies) {
470
+ if (registry.components[dep]) {
471
+ addComponent(dep, registry, cwd, options, added);
472
+ }
473
+ }
474
+ }
475
+
476
+ for (const file of component.files) {
477
+ const targetPath = path.join(cwd, file.path);
478
+ const targetDir = path.dirname(targetPath);
479
+
480
+ if (!fs.existsSync(targetDir)) fs.mkdirSync(targetDir, { recursive: true });
481
+
482
+ if (fs.existsSync(targetPath) && !options.force) {
483
+ warn(`Skipped (exists): ${file.path} — use ${c.cyan}--force${c.reset} to overwrite`);
484
+ continue;
485
+ }
486
+
487
+ fs.writeFileSync(targetPath, file.content);
488
+ ok(`Created: ${file.path}`);
489
+ }
490
+ };
491
+
492
+ const removeComponent = (
493
+ name: string,
494
+ registry: { components: Record<string, { files: { path: string }[] }> },
495
+ cwd: string
496
+ ) => {
497
+ const component = registry.components[name];
498
+ if (!component) {
499
+ error(`Component "${name}" not found.`);
500
+ return;
501
+ }
502
+
503
+ log(`Removing: ${c.bold}${name}${c.reset}...`);
504
+
505
+ for (const file of component.files) {
506
+ const targetPath = path.join(cwd, file.path);
507
+ if (fs.existsSync(targetPath)) {
508
+ fs.unlinkSync(targetPath);
509
+ ok(`Deleted: ${file.path}`);
510
+ }
511
+ }
512
+
513
+ for (const file of component.files) {
514
+ const targetDir = path.dirname(path.join(cwd, file.path));
515
+ try {
516
+ if (fs.existsSync(targetDir) && fs.readdirSync(targetDir).length === 0) {
517
+ fs.rmdirSync(targetDir);
518
+ ok(`Removed empty dir: ${path.relative(cwd, targetDir)}`);
519
+ }
520
+ } catch (err) {
521
+ warn(`Could not remove directory: ${err instanceof Error ? err.message : err}`);
522
+ }
523
+ }
524
+ };
525
+
526
+ // ─── Help texts ───────────────────────────────────────────────────────────────
527
+
528
+ const HELP_MAIN = `
529
+ ${c.bold}${c.cyan}basuicn${c.reset} ${c.dim}v${VERSION}${c.reset} Modern React UI Component CLI
530
+
531
+ ${c.bold}USAGE${c.reset}
532
+ ${c.cyan}npx basuicn${c.reset} ${c.green}<command>${c.reset} ${c.dim}[options]${c.reset}
533
+
534
+ ${c.bold}COMMANDS${c.reset}
535
+ ${c.green}init${c.reset} Initialize project: install deps, copy core files, patch entry
536
+ ${c.green}add${c.reset} ${c.dim}<name...>${c.reset} Add component(s) to your project
537
+ ${c.green}update${c.reset} ${c.dim}<name...>${c.reset} Update component(s) to latest registry version
538
+ ${c.green}diff${c.reset} ${c.dim}<name...>${c.reset} Show diff between local and registry version
539
+ ${c.green}remove${c.reset} ${c.dim}<name...>${c.reset} Remove component(s) from your project
540
+ ${c.green}list${c.reset} List all available components
541
+ ${c.green}doctor${c.reset} Check project health and configuration
542
+
543
+ ${c.bold}OPTIONS${c.reset}
544
+ ${c.cyan}--force${c.reset} Overwrite existing files when adding/updating
545
+ ${c.cyan}--local${c.reset} Use local registry.json instead of remote
546
+ ${c.cyan}--help, -h${c.reset} Show help (use with a command for detailed help)
547
+ ${c.cyan}--version, -v${c.reset} Show version
548
+
549
+ ${c.bold}QUICK START${c.reset}
550
+ ${c.dim}$${c.reset} npx basuicn init
551
+ ${c.dim}$${c.reset} npx basuicn add button input card
552
+ ${c.dim}$${c.reset} npx basuicn add toast
553
+
554
+ ${c.bold}EXAMPLES${c.reset}
555
+ ${c.dim}$${c.reset} npx basuicn add dialog --force ${c.dim}# Overwrite existing dialog${c.reset}
556
+ ${c.dim}$${c.reset} npx basuicn diff button ${c.dim}# See what changed since last update${c.reset}
557
+ ${c.dim}$${c.reset} npx basuicn doctor ${c.dim}# Diagnose missing deps/config${c.reset}
558
+
559
+ ${c.dim}Documentation: https://github.com/Basuicn/basuicn-core${c.reset}
560
+ `;
561
+
562
+ const HELP_COMMANDS: Record<string, string> = {
563
+ init: `
564
+ ${c.bold}basuicn init${c.reset}
565
+
566
+ Initialize your project for basuicn components.
567
+
568
+ ${c.bold}What it does:${c.reset}
569
+ 1. Installs runtime dependencies (@base-ui/react, tailwind-variants, etc.)
570
+ 2. Sets up vite.config.ts with Tailwind CSS + path aliases
571
+ 3. Patches tsconfig.json with path aliases (@/*, @lib/*, etc.)
572
+ 4. Copies core files (cn.ts, themes.ts, ThemeProvider.tsx, index.css)
573
+ 5. Wraps your <App /> with <ThemeProvider> in the main entry
574
+
575
+ ${c.bold}Usage:${c.reset}
576
+ ${c.dim}$${c.reset} npx basuicn init
577
+ ${c.dim}$${c.reset} npx basuicn init --local ${c.dim}# Use local registry${c.reset}
578
+ `,
579
+ add: `
580
+ ${c.bold}basuicn add${c.reset} ${c.dim}<name...>${c.reset}
581
+
582
+ Add one or more components to your project.
583
+
584
+ ${c.bold}Options:${c.reset}
585
+ ${c.cyan}--force${c.reset} Overwrite existing component files
586
+
587
+ ${c.bold}Features:${c.reset}
588
+ Auto-runs init if project hasn't been set up
589
+ • Resolves internal dependencies (e.g., dialog depends on button)
590
+ • Installs required npm packages automatically
591
+ • Patches main entry for components that need it (e.g., toast)
592
+
593
+ ${c.bold}Usage:${c.reset}
594
+ ${c.dim}$${c.reset} npx basuicn add button
595
+ ${c.dim}$${c.reset} npx basuicn add button input card dialog
596
+ ${c.dim}$${c.reset} npx basuicn add toast --force
597
+
598
+ ${c.bold}Interactive:${c.reset}
599
+ ${c.dim}$${c.reset} npx basuicn add ${c.dim}# Prompts to select components${c.reset}
600
+ `,
601
+ update: `
602
+ ${c.bold}basuicn update${c.reset} ${c.dim}<name...>${c.reset}
603
+
604
+ Update component(s) to the latest registry version.
605
+ Equivalent to ${c.cyan}add --force${c.reset}.
606
+
607
+ ${c.bold}Usage:${c.reset}
608
+ ${c.dim}$${c.reset} npx basuicn update button
609
+ ${c.dim}$${c.reset} npx basuicn update button card dialog
610
+ `,
611
+ remove: `
612
+ ${c.bold}basuicn remove${c.reset} ${c.dim}<name...>${c.reset}
613
+
614
+ Remove component(s) from your project.
615
+ Deletes component files and cleans up empty directories.
616
+
617
+ ${c.bold}Usage:${c.reset}
618
+ ${c.dim}$${c.reset} npx basuicn remove button
619
+ ${c.dim}$${c.reset} npx basuicn remove dialog drawer sheet
620
+ `,
621
+ diff: `
622
+ ${c.bold}basuicn diff${c.reset} ${c.dim}<name...>${c.reset}
623
+
624
+ Show differences between your local component files and the registry version.
625
+ Useful to see what has changed before running update.
626
+
627
+ ${c.bold}Usage:${c.reset}
628
+ ${c.dim}$${c.reset} npx basuicn diff button
629
+ ${c.dim}$${c.reset} npx basuicn diff button card
630
+ `,
631
+ list: `
632
+ ${c.bold}basuicn list${c.reset}
633
+
634
+ Show all available components in the registry.
635
+ Displays internal dependencies for each component.
636
+
637
+ ${c.bold}Usage:${c.reset}
638
+ ${c.dim}$${c.reset} npx basuicn list
639
+ `,
640
+ doctor: `
641
+ ${c.bold}basuicn doctor${c.reset}
642
+
643
+ Run a health check on your project configuration.
644
+
645
+ ${c.bold}Checks:${c.reset}
646
+ • Core files exist (cn.ts, themes.ts, ThemeProvider.tsx, index.css)
647
+ • ThemeProvider + CSS import in main entry
648
+ Runtime packages installed
649
+ • Dev packages installed
650
+ • Tailwind CSS configured
651
+ • TypeScript path aliases
652
+ • Vite config present
653
+
654
+ ${c.bold}Usage:${c.reset}
655
+ ${c.dim}$${c.reset} npx basuicn doctor
656
+ `,
657
+ };
658
+
659
+ // ─── Commands ─────────────────────────────────────────────────────────────────
660
+
661
+ const main = async () => {
662
+ const args = process.argv.slice(2);
663
+
664
+ // Version flag
665
+ if (args.includes('--version') || args.includes('-v')) {
666
+ console.log(`basuicn v${VERSION}`);
667
+ return;
668
+ }
669
+
670
+ const isLocal = args.includes('--local');
671
+ const isForce = args.includes('--force');
672
+ const isHelp = args.includes('--help') || args.includes('-h');
673
+ const filteredArgs = args.filter((a) => !a.startsWith('--') && a !== '-h' && a !== '-v');
674
+ const command = filteredArgs[0];
675
+ const componentNames = filteredArgs.slice(1);
676
+
677
+ // Help for specific command
678
+ if (isHelp && command && HELP_COMMANDS[command]) {
679
+ console.log(HELP_COMMANDS[command]);
680
+ return;
681
+ }
682
+
683
+ // General help
684
+ if (isHelp || !command) {
685
+ console.log(HELP_MAIN);
686
+ return;
687
+ }
688
+
689
+ const cwd = getTargetProjectDir();
690
+ const registry = await getRegistry(isLocal);
691
+
692
+ switch (command) {
693
+
694
+ case 'init': {
695
+ log('Initializing project...');
696
+ setupViteConfig(cwd);
697
+ setupTsConfig(cwd);
698
+ installNpmPackages(RUNTIME_PACKAGES, cwd);
699
+ ensureCore(registry, cwd, { force: true });
700
+ patchMainTsx(cwd);
701
+ console.log('');
702
+ ok(`${c.bold}Initialization complete!${c.reset} Run ${c.cyan}npx basuicn add <component>${c.reset} to get started.`);
703
+ break;
704
+ }
705
+
706
+ case 'add': {
707
+ let names = componentNames;
708
+
709
+ // Interactive mode: no component names provided
710
+ if (names.length === 0) {
711
+ const all = Object.keys(registry.components).sort();
712
+ console.log(`\n${c.bold}Available components (${all.length}):${c.reset}`);
713
+
714
+ // Group by category
715
+ const categories: Record<string, string[]> = {};
716
+ for (const name of all) {
717
+ const prefix = name.includes('-') ? name.split('-')[0] : 'general';
718
+ if (!categories[prefix]) categories[prefix] = [];
719
+ categories[prefix].push(name);
720
+ }
721
+
722
+ // Print in columns
723
+ const cols = 4;
724
+ for (let i = 0; i < all.length; i += cols) {
725
+ const row = all.slice(i, i + cols).map(n => n.padEnd(20)).join('');
726
+ console.log(` ${c.dim}${row}${c.reset}`);
727
+ }
728
+
729
+ console.log('');
730
+ const answer = await ask(`Which components to add? ${c.dim}(space-separated, or "all")${c.reset}`);
731
+ if (!answer) {
732
+ log('No components selected.');
733
+ return;
734
+ }
735
+ names = answer === 'all' ? all : answer.split(/[\s,]+/).filter(Boolean);
736
+ }
737
+
738
+ // Auto-init if project hasn't been initialized yet
739
+ const cnPath = path.join(cwd, 'src/lib/utils/cn.ts');
740
+ if (!fs.existsSync(cnPath)) {
741
+ log('Project not initialized running init first...');
742
+ setupViteConfig(cwd);
743
+ setupTsConfig(cwd);
744
+ installNpmPackages(RUNTIME_PACKAGES, cwd);
745
+ ensureCore(registry, cwd, { force: true });
746
+ patchMainTsx(cwd);
747
+ console.log('');
748
+ }
749
+
750
+ for (const name of names) {
751
+ addComponent(name, registry, cwd, { force: isForce });
752
+ patchMainTsxComponent(cwd, name);
753
+ }
754
+ console.log('');
755
+ ok(`${c.bold}Done!${c.reset} Added ${names.length} component(s).`);
756
+ break;
757
+ }
758
+
759
+ case 'update': {
760
+ if (componentNames.length === 0) {
761
+ error(`Usage: ${c.cyan}npx basuicn update <component-name> [...]${c.reset}`);
762
+ console.log(` Run ${c.cyan}npx basuicn update --help${c.reset} for details.`);
763
+ return;
764
+ }
765
+ for (const name of componentNames) {
766
+ log(`Updating: ${c.bold}${name}${c.reset}...`);
767
+ addComponent(name, registry, cwd, { force: true });
768
+ }
769
+ console.log('');
770
+ ok(`${c.bold}Update complete.${c.reset}`);
771
+ break;
772
+ }
773
+
774
+ case 'remove': {
775
+ if (componentNames.length === 0) {
776
+ error(`Usage: ${c.cyan}npx basuicn remove <component-name>${c.reset}`);
777
+ return;
778
+ }
779
+
780
+ if (!isForce) {
781
+ const yes = await confirm(`Remove ${componentNames.join(', ')}?`);
782
+ if (!yes) {
783
+ log('Cancelled.');
784
+ return;
785
+ }
786
+ }
787
+
788
+ for (const name of componentNames) {
789
+ removeComponent(name, registry, cwd);
790
+ }
791
+ console.log('');
792
+ ok(`${c.bold}Done!${c.reset}`);
793
+ break;
794
+ }
795
+
796
+ case 'list': {
797
+ const components = Object.keys(registry.components).sort();
798
+ console.log(`\n${c.bold}Available components (${components.length}):${c.reset}\n`);
799
+
800
+ const installed: string[] = [];
801
+ const available: string[] = [];
802
+
803
+ for (const k of components) {
804
+ const comp = registry.components[k];
805
+ const firstFile = comp.files[0];
806
+ const isInstalled = firstFile && fs.existsSync(path.join(cwd, firstFile.path));
807
+ if (isInstalled) installed.push(k);
808
+ else available.push(k);
809
+ }
810
+
811
+ if (installed.length > 0) {
812
+ console.log(` ${c.green}Installed (${installed.length}):${c.reset}`);
813
+ for (const k of installed) {
814
+ const deps = registry.components[k].internalDependencies?.filter(Boolean);
815
+ const depStr = deps?.length ? ` ${c.dim}→ ${deps.join(', ')}${c.reset}` : '';
816
+ console.log(` ${c.green}●${c.reset} ${k}${depStr}`);
817
+ }
818
+ console.log('');
819
+ }
820
+
821
+ if (available.length > 0) {
822
+ console.log(` ${c.dim}Available (${available.length}):${c.reset}`);
823
+ for (const k of available) {
824
+ const deps = registry.components[k].internalDependencies?.filter(Boolean);
825
+ const depStr = deps?.length ? ` ${c.dim}→ ${deps.join(', ')}${c.reset}` : '';
826
+ console.log(` ${c.dim}○${c.reset} ${k}${depStr}`);
827
+ }
828
+ }
829
+ console.log('');
830
+ break;
831
+ }
832
+
833
+ case 'diff': {
834
+ if (componentNames.length === 0) {
835
+ error(`Usage: ${c.cyan}npx basuicn diff <component-name>${c.reset}`);
836
+ return;
837
+ }
838
+ for (const name of componentNames) {
839
+ const component = registry.components[name];
840
+ if (!component) {
841
+ error(`Component "${name}" not found.`);
842
+ continue;
843
+ }
844
+ let hasDiff = false;
845
+ console.log(`\n${c.bold}[diff] ${name}${c.reset}`);
846
+ for (const file of component.files) {
847
+ const targetPath = path.join(cwd, file.path);
848
+ if (!fs.existsSync(targetPath)) {
849
+ console.log(` ${c.green}+ [new file]${c.reset} ${file.path}`);
850
+ hasDiff = true;
851
+ continue;
852
+ }
853
+ const localContent = fs.readFileSync(targetPath, 'utf-8');
854
+ if (localContent === file.content) continue;
855
+ hasDiff = true;
856
+ console.log(`\n ${c.yellow}~${c.reset} ${file.path}`);
857
+ const localLines = localContent.split('\n');
858
+ const remoteLines = file.content.split('\n');
859
+ const maxLen = Math.max(localLines.length, remoteLines.length);
860
+ let shownLines = 0;
861
+ for (let i = 0; i < maxLen; i++) {
862
+ if (localLines[i] !== remoteLines[i]) {
863
+ if (localLines[i] !== undefined) console.log(` ${c.red}- ${localLines[i]}${c.reset}`);
864
+ if (remoteLines[i] !== undefined) console.log(` ${c.green}+ ${remoteLines[i]}${c.reset}`);
865
+ shownLines++;
866
+ if (shownLines >= 20) {
867
+ const remaining = maxLen - i - 1;
868
+ if (remaining > 0) console.log(` ${c.dim}... and ${remaining} more lines${c.reset}`);
869
+ break;
870
+ }
871
+ }
872
+ }
873
+ }
874
+ if (!hasDiff) ok(`${name}: already up to date.`);
875
+ }
876
+ break;
877
+ }
878
+
879
+ case 'doctor': {
880
+ console.log(`\n${c.bold}Project Health Check${c.reset}\n`);
881
+ let issues = 0;
882
+ const check = (passed: boolean, msg: string, fix?: string) => {
883
+ console.log(` ${passed ? `${c.green}✔${c.reset}` : `${c.red}✖${c.reset}`} ${msg}`);
884
+ if (!passed) { if (fix) console.log(` ${c.dim}→ ${fix}${c.reset}`); issues++; }
885
+ };
886
+
887
+ // Core files
888
+ check(fs.existsSync(path.join(cwd, 'src/lib/utils/cn.ts')),
889
+ 'src/lib/utils/cn.ts', 'run: npx basuicn init');
890
+ check(fs.existsSync(path.join(cwd, 'src/lib/theme/themes.ts')),
891
+ 'src/lib/theme/themes.ts', 'run: npx basuicn init');
892
+ check(fs.existsSync(path.join(cwd, 'src/lib/theme/ThemeProvider.tsx')),
893
+ 'src/lib/theme/ThemeProvider.tsx', 'run: npx basuicn init');
894
+ check(fs.existsSync(path.join(cwd, 'src/styles/index.css')),
895
+ 'src/styles/index.css (theme variables)', 'run: npx basuicn init');
896
+
897
+ // Main entry
898
+ const mainPath = findMainFile(cwd);
899
+ if (mainPath) {
900
+ const mainContent = fs.readFileSync(mainPath, 'utf-8');
901
+ check(mainContent.includes('ThemeProvider'),
902
+ 'ThemeProvider in main entry', 'run: npx basuicn init');
903
+ check(mainContent.includes('styles/index.css') || mainContent.includes('index.css'),
904
+ 'CSS import in main entry', 'run: npx basuicn init');
905
+ } else {
906
+ check(false, 'main entry file (src/main.tsx)', 'create src/main.tsx');
907
+ }
908
+
909
+ // Runtime packages
910
+ const pkgPath = path.join(cwd, 'package.json');
911
+ if (fs.existsSync(pkgPath)) {
912
+ const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf-8'));
913
+ const allDeps = { ...pkg.dependencies, ...pkg.devDependencies };
914
+ for (const dep of RUNTIME_PACKAGES) {
915
+ check(!!allDeps[dep], `package: ${dep}`, `run: npm install ${dep}`);
916
+ }
917
+ for (const dep of VITE_DEV_PACKAGES) {
918
+ check(!!allDeps[dep], `package (dev): ${dep}`, `run: npm install -D ${dep}`);
919
+ }
920
+ } else {
921
+ check(false, 'package.json found', 'run: npm init -y');
922
+ }
923
+
924
+ // Config files
925
+ const hasTailwindInCss = (() => {
926
+ const candidates = ['src/styles/index.css', 'src/index.css', 'src/App.css'];
927
+ return candidates.some(f => {
928
+ const p = path.join(cwd, f);
929
+ if (!fs.existsSync(p)) return false;
930
+ const content = fs.readFileSync(p, 'utf-8');
931
+ return content.includes('@import "tailwindcss"') || content.includes("@import 'tailwindcss'");
932
+ });
933
+ })();
934
+ check(hasTailwindInCss, '@import "tailwindcss" in CSS', 'run: npx basuicn init');
935
+
936
+ const tsCandidates = ['tsconfig.app.json', 'tsconfig.json'];
937
+ const hasAlias = tsCandidates.some(f => {
938
+ const p = path.join(cwd, f);
939
+ if (!fs.existsSync(p)) return false;
940
+ const content = fs.readFileSync(p, 'utf-8');
941
+ return content.includes('"@/*"') || content.includes("'@/*'");
942
+ });
943
+ check(hasAlias, 'TypeScript path aliases (@/*)', 'run: npx basuicn init');
944
+
945
+ const hasViteConfig =
946
+ fs.existsSync(path.join(cwd, 'vite.config.ts')) ||
947
+ fs.existsSync(path.join(cwd, 'vite.config.js'));
948
+ check(hasViteConfig, 'vite.config.ts / vite.config.js', 'run: npx basuicn init');
949
+
950
+ console.log('');
951
+ if (issues === 0) {
952
+ ok(`${c.bold}All checks passed!${c.reset} Project is healthy.`);
953
+ } else {
954
+ warn(`${c.bold}${issues} issue(s) found.${c.reset} Run ${c.cyan}npx basuicn init${c.reset} to fix most issues.`);
955
+ }
956
+ break;
957
+ }
958
+
959
+ default: {
960
+ error(`Unknown command: "${command}"`);
961
+ console.log(` Run ${c.cyan}npx basuicn --help${c.reset} to see available commands.\n`);
962
+ }
963
+ }
964
+ };
965
+
966
+ main();