weloop-kosign 1.1.0 → 1.1.2

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.
@@ -8,16 +8,19 @@
8
8
  * - If running in external projects: uses remote GitLab/GitHub URL
9
9
  *
10
10
  * Usage (via npx - recommended):
11
+ * npx weloop-kosign@latest init
11
12
  * npx weloop-kosign@latest add <component-name>
12
13
  * npx weloop-kosign@latest list
13
14
  * npx weloop-kosign@latest css [--overwrite]
14
15
  *
15
16
  * Usage (local development):
17
+ * node scripts/cli-remote.js init [--registry <url|path>]
16
18
  * node scripts/cli-remote.js add <component-name> [--registry <url|path>]
17
19
  * node scripts/cli-remote.js list [--registry <url|path>]
18
20
  * node scripts/cli-remote.js css [--registry <url|path>] [--overwrite]
19
21
  *
20
22
  * Examples:
23
+ * npx weloop-kosign@latest init
21
24
  * npx weloop-kosign@latest add button
22
25
  * npx weloop-kosign@latest add button --registry ./registry
23
26
  * npx weloop-kosign@latest css
@@ -33,21 +36,43 @@ const { execSync } = require('child_process');
33
36
  // CONFIGURATION
34
37
  // ============================================================================
35
38
 
39
+ /**
40
+ * Determines the default registry URL to use.
41
+ *
42
+ * Priority:
43
+ * 1. Local registry directory (if running in the component library project)
44
+ * 2. Environment variable WELOOP_REGISTRY_URL
45
+ * 3. Default remote GitLab URL
46
+ *
47
+ * @returns {string} The registry URL or path
48
+ */
36
49
  function getDefaultRegistryUrl() {
37
50
  const localRegistryPath = path.join(__dirname, '../registry');
51
+
52
+ // Check if we're running in the component library project itself
38
53
  if (fs.existsSync(localRegistryPath) && fs.existsSync(path.join(localRegistryPath, 'index.json'))) {
39
54
  return localRegistryPath;
40
55
  }
56
+
57
+ // Fall back to environment variable or default remote URL
41
58
  return process.env.WELOOP_REGISTRY_URL ||
42
59
  'https://gitlab.com/Sophanithchrek/weloop-shadcn-next-app/-/raw/main/registry';
43
60
  }
44
61
 
45
62
  const DEFAULT_REGISTRY_URL = getDefaultRegistryUrl();
46
63
 
64
+ // Network timeout constants (in milliseconds)
65
+ const REQUEST_TIMEOUT = 15000;
66
+ const RETRY_DELAY = 1000;
67
+
47
68
  // ============================================================================
48
- // UTILITIES
69
+ // UTILITIES - Logging and File System Helpers
49
70
  // ============================================================================
50
71
 
72
+ /**
73
+ * ANSI color codes for terminal output
74
+ * Makes the CLI output more readable with colored messages
75
+ */
51
76
  const colors = {
52
77
  reset: '\x1b[0m',
53
78
  green: '\x1b[32m',
@@ -57,52 +82,98 @@ const colors = {
57
82
  cyan: '\x1b[36m',
58
83
  };
59
84
 
85
+ /**
86
+ * Logs a message with optional color formatting
87
+ * @param {string} message - The message to log
88
+ * @param {string} color - Color name from colors object
89
+ */
60
90
  function log(message, color = 'reset') {
61
91
  console.log(`${colors[color]}${message}${colors.reset}`);
62
92
  }
63
93
 
94
+ /**
95
+ * Logs an error message in red
96
+ */
64
97
  function error(message) {
65
98
  log(`Error: ${message}`, 'red');
66
99
  }
67
100
 
101
+ /**
102
+ * Logs a success message in green
103
+ */
68
104
  function success(message) {
69
105
  log(message, 'green');
70
106
  }
71
107
 
108
+ /**
109
+ * Logs an informational message in blue
110
+ */
72
111
  function info(message) {
73
112
  log(message, 'blue');
74
113
  }
75
114
 
115
+ /**
116
+ * Logs a warning message in yellow
117
+ */
76
118
  function warn(message) {
77
119
  log(`Warning: ${message}`, 'yellow');
78
120
  }
79
121
 
122
+ /**
123
+ * Creates a directory if it doesn't exist
124
+ * Uses recursive option to create parent directories as needed
125
+ * @param {string} dirPath - Path to the directory
126
+ */
80
127
  function ensureDirectoryExists(dirPath) {
81
128
  if (!fs.existsSync(dirPath)) {
82
129
  fs.mkdirSync(dirPath, { recursive: true });
83
130
  }
84
131
  }
85
132
 
133
+ /**
134
+ * Checks if a path is a local file path or a remote URL
135
+ * @param {string} pathOrUrl - Path or URL to check
136
+ * @returns {boolean} True if it's a local path, false if it's a URL
137
+ */
86
138
  function isLocalPath(pathOrUrl) {
87
139
  return !pathOrUrl.startsWith('http://') && !pathOrUrl.startsWith('https://');
88
140
  }
89
141
 
90
142
  // ============================================================================
91
- // PACKAGE MANAGEMENT
143
+ // PACKAGE MANAGEMENT - Detecting and Installing Dependencies
92
144
  // ============================================================================
93
145
 
146
+ /**
147
+ * Automatically detects which package manager the project is using
148
+ *
149
+ * Detection order:
150
+ * 1. Check for lock files (most reliable)
151
+ * 2. Check npm user agent (fallback)
152
+ * 3. Default to npm
153
+ *
154
+ * @returns {string} Package manager name: 'npm', 'yarn', or 'pnpm'
155
+ */
94
156
  function detectPackageManager() {
157
+ // Check for lock files first (most reliable indicator)
95
158
  if (fs.existsSync(path.join(process.cwd(), 'yarn.lock'))) return 'yarn';
96
159
  if (fs.existsSync(path.join(process.cwd(), 'pnpm-lock.yaml'))) return 'pnpm';
97
160
  if (fs.existsSync(path.join(process.cwd(), 'package-lock.json'))) return 'npm';
98
161
 
162
+ // Fallback: check npm user agent (set by npm/yarn/pnpm when running)
99
163
  const userAgent = process.env.npm_config_user_agent || '';
100
164
  if (userAgent.includes('yarn')) return 'yarn';
101
165
  if (userAgent.includes('pnpm')) return 'pnpm';
102
166
 
167
+ // Default to npm if we can't detect anything
103
168
  return 'npm';
104
169
  }
105
170
 
171
+ /**
172
+ * Generates the install command for the given package manager
173
+ * @param {string} packageManager - 'npm', 'yarn', or 'pnpm'
174
+ * @param {string[]} packages - Array of package names to install
175
+ * @returns {string} The install command to run
176
+ */
106
177
  function getInstallCommand(packageManager, packages) {
107
178
  const packagesStr = packages.join(' ');
108
179
  switch (packageManager) {
@@ -113,36 +184,63 @@ function getInstallCommand(packageManager, packages) {
113
184
  }
114
185
  }
115
186
 
187
+ /**
188
+ * Checks if a package is already installed in the project
189
+ * Searches both dependencies and devDependencies
190
+ * @param {string} packageName - Name of the package to check
191
+ * @returns {boolean} True if package is installed, false otherwise
192
+ */
116
193
  function checkPackageInstalled(packageName) {
117
194
  const packageJsonPath = path.join(process.cwd(), 'package.json');
118
195
  if (!fs.existsSync(packageJsonPath)) return false;
119
196
 
120
197
  try {
121
198
  const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, 'utf-8'));
199
+ // Check both dependencies and devDependencies
122
200
  const allDeps = { ...packageJson.dependencies, ...packageJson.devDependencies };
123
201
  return !!allDeps[packageName];
124
202
  } catch (e) {
203
+ // If we can't read/parse package.json, assume package is not installed
125
204
  return false;
126
205
  }
127
206
  }
128
207
 
208
+ /**
209
+ * Filters out dependencies that are already installed
210
+ * Handles scoped packages (e.g., @radix-ui/react-button)
211
+ * @param {string[]} requiredDeps - List of required package names
212
+ * @returns {string[]} List of packages that need to be installed
213
+ */
129
214
  function getMissingDependencies(requiredDeps) {
130
215
  const packageJsonPath = path.join(process.cwd(), 'package.json');
131
- if (!fs.existsSync(packageJsonPath)) return requiredDeps;
216
+ if (!fs.existsSync(packageJsonPath)) {
217
+ // No package.json means we need to install everything
218
+ return requiredDeps;
219
+ }
132
220
 
133
221
  try {
134
222
  const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, 'utf-8'));
135
223
  const allDeps = { ...packageJson.dependencies, ...packageJson.devDependencies };
136
224
 
137
225
  return requiredDeps.filter(dep => {
226
+ // For scoped packages like @radix-ui/react-button, also check @radix-ui
138
227
  const depName = dep.split('/').slice(0, 2).join('/');
139
228
  return !allDeps[dep] && !allDeps[depName];
140
229
  });
141
230
  } catch (e) {
231
+ // If we can't parse package.json, assume all deps are missing
142
232
  return requiredDeps;
143
233
  }
144
234
  }
145
235
 
236
+ /**
237
+ * Installs npm packages using the detected package manager
238
+ *
239
+ * For npm: Filters out ERESOLVE peer dependency warnings (common false positives)
240
+ * For yarn/pnpm: Uses standard execSync
241
+ *
242
+ * @param {string[]} packages - Array of package names to install
243
+ */
146
244
  async function installPackages(packages) {
147
245
  if (packages.length === 0) return;
148
246
 
@@ -152,7 +250,8 @@ async function installPackages(packages) {
152
250
  try {
153
251
  const installCmd = getInstallCommand(packageManager, packages);
154
252
 
155
- // Filter out ERESOLVE peer dependency warnings while keeping errors
253
+ // npm has noisy ERESOLVE warnings that aren't real errors
254
+ // We filter these out while keeping actual error messages
156
255
  if (packageManager === 'npm') {
157
256
  const { spawn } = require('child_process');
158
257
  const [cmd, ...args] = installCmd.split(' ');
@@ -167,7 +266,8 @@ async function installPackages(packages) {
167
266
  let stderr = '';
168
267
  proc.stderr.on('data', (data) => {
169
268
  const output = data.toString();
170
- // Filter out ERESOLVE warnings but keep actual errors
269
+ // Filter out ERESOLVE peer dependency warnings (these are usually safe to ignore)
270
+ // but keep actual error messages visible to the user
171
271
  if (!output.includes('ERESOLVE overriding peer dependency') &&
172
272
  !output.includes('npm warn ERESOLVE')) {
173
273
  process.stderr.write(data);
@@ -177,13 +277,14 @@ async function installPackages(packages) {
177
277
 
178
278
  proc.on('close', (code) => {
179
279
  if (code !== 0) {
180
- // Check if it's a real error (not just warnings)
280
+ // npm sometimes exits with non-zero code due to warnings, not real errors
281
+ // Check if there's an actual error message (not just ERESOLVE warnings)
181
282
  const hasRealError = stderr.includes('npm error') &&
182
283
  !stderr.match(/npm error.*ERESOLVE/);
183
284
  if (hasRealError) {
184
285
  reject(new Error(`Installation failed with code ${code}`));
185
286
  } else {
186
- // Installation succeeded despite warnings
287
+ // Installation succeeded despite warnings - this is common with peer deps
187
288
  resolve();
188
289
  }
189
290
  } else {
@@ -206,13 +307,22 @@ async function installPackages(packages) {
206
307
  }
207
308
 
208
309
  // ============================================================================
209
- // FILE OPERATIONS
310
+ // FILE OPERATIONS - Configuration and File Handling
210
311
  // ============================================================================
211
312
 
313
+ /**
314
+ * Creates a default components.json configuration file
315
+ *
316
+ * Auto-detects Next.js app directory structure and sets appropriate paths.
317
+ * This file stores project-specific settings like component paths and aliases.
318
+ *
319
+ * @returns {object} The default configuration object
320
+ */
212
321
  function createDefaultComponentsConfig() {
213
322
  const configPath = path.join(process.cwd(), 'components.json');
214
323
 
215
- // Detect if it's a Next.js app (app directory) or pages directory
324
+ // Detect Next.js project structure
325
+ // App Router uses 'app/globals.css', Pages Router uses 'styles/globals.css'
216
326
  const hasAppDir = fs.existsSync(path.join(process.cwd(), 'app'));
217
327
  const cssPath = hasAppDir ? 'app/globals.css' : 'styles/globals.css';
218
328
 
@@ -245,6 +355,12 @@ function createDefaultComponentsConfig() {
245
355
  return defaultConfig;
246
356
  }
247
357
 
358
+ /**
359
+ * Loads the components.json configuration file
360
+ * Creates a default one if it doesn't exist
361
+ *
362
+ * @returns {object} The configuration object
363
+ */
248
364
  function loadComponentsConfig() {
249
365
  const configPath = path.join(process.cwd(), 'components.json');
250
366
 
@@ -257,10 +373,25 @@ function loadComponentsConfig() {
257
373
  return JSON.parse(content);
258
374
  }
259
375
 
376
+ /**
377
+ * Fetches and parses a JSON file from a local path or remote URL
378
+ *
379
+ * Features:
380
+ * - Handles local files and remote URLs
381
+ * - Automatic retry on network errors
382
+ * - Follows HTTP redirects
383
+ * - Provides helpful error messages
384
+ *
385
+ * @param {string} urlOrPath - Local file path or remote URL
386
+ * @param {number} retries - Number of retry attempts (default: 3)
387
+ * @returns {Promise<object>} Parsed JSON object
388
+ */
260
389
  async function fetchJSON(urlOrPath, retries = 3) {
390
+ // Handle local file paths
261
391
  if (isLocalPath(urlOrPath)) {
262
392
  return new Promise((resolve, reject) => {
263
393
  try {
394
+ // Resolve relative paths to absolute
264
395
  const fullPath = path.isAbsolute(urlOrPath)
265
396
  ? urlOrPath
266
397
  : path.join(process.cwd(), urlOrPath);
@@ -278,6 +409,7 @@ async function fetchJSON(urlOrPath, retries = 3) {
278
409
  });
279
410
  }
280
411
 
412
+ // Handle remote URLs
281
413
  return new Promise((resolve, reject) => {
282
414
  const client = urlOrPath.startsWith('https') ? https : http;
283
415
  let timeout;
@@ -287,10 +419,12 @@ async function fetchJSON(urlOrPath, retries = 3) {
287
419
  request = client.get(urlOrPath, (res) => {
288
420
  clearTimeout(timeout);
289
421
 
422
+ // Handle HTTP redirects (301, 302)
290
423
  if (res.statusCode === 302 || res.statusCode === 301) {
291
424
  return fetchJSON(res.headers.location, retries).then(resolve).catch(reject);
292
425
  }
293
426
 
427
+ // Provide helpful error messages for common HTTP status codes
294
428
  if (res.statusCode === 403) {
295
429
  reject(new Error(`Access forbidden (403). Repository may be private. Make it public in GitLab/GitHub settings, or use --registry with a public URL.`));
296
430
  return;
@@ -306,10 +440,12 @@ async function fetchJSON(urlOrPath, retries = 3) {
306
440
  return;
307
441
  }
308
442
 
443
+ // Collect response data
309
444
  let data = '';
310
445
  res.on('data', (chunk) => { data += chunk; });
311
446
  res.on('end', () => {
312
447
  try {
448
+ // Check if we got HTML instead of JSON (common when repo is private)
313
449
  if (data.trim().startsWith('<')) {
314
450
  reject(new Error('Received HTML instead of JSON. Repository may be private or URL incorrect.'));
315
451
  return;
@@ -321,36 +457,46 @@ async function fetchJSON(urlOrPath, retries = 3) {
321
457
  });
322
458
  });
323
459
 
460
+ // Handle network errors with automatic retry
324
461
  request.on('error', (err) => {
325
462
  clearTimeout(timeout);
326
463
  if (retries > 0 && (err.code === 'ECONNRESET' || err.code === 'ETIMEDOUT' || err.code === 'ENOTFOUND')) {
327
464
  info(`Connection error, retrying... (${retries} attempts left)`);
328
465
  setTimeout(() => {
329
466
  fetchJSON(urlOrPath, retries - 1).then(resolve).catch(reject);
330
- }, 1000);
467
+ }, RETRY_DELAY);
331
468
  } else {
332
469
  reject(new Error(`Network error: ${err.message} (${err.code || 'UNKNOWN'})`));
333
470
  }
334
471
  });
335
472
 
473
+ // Set request timeout with retry logic
336
474
  timeout = setTimeout(() => {
337
475
  request.destroy();
338
476
  if (retries > 0) {
339
477
  info(`Request timeout, retrying... (${retries} attempts left)`);
340
478
  setTimeout(() => {
341
479
  fetchJSON(urlOrPath, retries - 1).then(resolve).catch(reject);
342
- }, 1000);
480
+ }, RETRY_DELAY);
343
481
  } else {
344
482
  reject(new Error('Request timeout after multiple attempts'));
345
483
  }
346
- }, 15000);
484
+ }, REQUEST_TIMEOUT);
347
485
  };
348
486
 
349
487
  makeRequest();
350
488
  });
351
489
  }
352
490
 
491
+ /**
492
+ * Fetches text content (like CSS) from a local path or remote URL
493
+ * Similar to fetchJSON but returns raw text instead of parsing JSON
494
+ *
495
+ * @param {string} urlOrPath - Local file path or remote URL
496
+ * @returns {Promise<string>} The file content as text
497
+ */
353
498
  async function fetchText(urlOrPath) {
499
+ // Handle local file paths
354
500
  if (isLocalPath(urlOrPath)) {
355
501
  if (!fs.existsSync(urlOrPath)) {
356
502
  throw new Error(`File not found: ${urlOrPath}`);
@@ -358,15 +504,18 @@ async function fetchText(urlOrPath) {
358
504
  return fs.readFileSync(urlOrPath, 'utf-8');
359
505
  }
360
506
 
507
+ // Handle remote URLs
361
508
  return new Promise((resolve, reject) => {
362
509
  const client = urlOrPath.startsWith('https') ? https : http;
363
510
  let data = '';
364
511
 
365
512
  const req = client.get(urlOrPath, (res) => {
513
+ // Follow redirects
366
514
  if (res.statusCode === 302 || res.statusCode === 301) {
367
515
  return fetchText(res.headers.location).then(resolve).catch(reject);
368
516
  }
369
517
 
518
+ // Handle common HTTP errors
370
519
  if (res.statusCode === 403) {
371
520
  reject(new Error('Access forbidden - repository may be private'));
372
521
  return;
@@ -382,8 +531,10 @@ async function fetchText(urlOrPath) {
382
531
  return;
383
532
  }
384
533
 
534
+ // Collect response data
385
535
  res.on('data', (chunk) => { data += chunk; });
386
536
  res.on('end', () => {
537
+ // Check if we got HTML instead of CSS (common when repo is private)
387
538
  if (data.trim().startsWith('<')) {
388
539
  reject(new Error('Received HTML instead of CSS'));
389
540
  return;
@@ -392,42 +543,70 @@ async function fetchText(urlOrPath) {
392
543
  });
393
544
  });
394
545
 
546
+ // Handle network errors with automatic retry
395
547
  req.on('error', (err) => {
396
548
  if (err.code === 'ECONNRESET' || err.code === 'ETIMEDOUT') {
397
549
  setTimeout(() => {
398
550
  fetchText(urlOrPath).then(resolve).catch(reject);
399
- }, 1000);
551
+ }, RETRY_DELAY);
400
552
  } else {
401
553
  reject(err);
402
554
  }
403
555
  });
404
556
 
405
- req.setTimeout(15000, () => {
557
+ // Set request timeout
558
+ req.setTimeout(REQUEST_TIMEOUT, () => {
406
559
  req.destroy();
407
560
  reject(new Error('Request timeout'));
408
561
  });
409
562
  });
410
563
  }
411
564
 
565
+ /**
566
+ * Resolves the full path/URL for a registry file
567
+ * Handles both local paths and remote URLs
568
+ *
569
+ * @param {string} baseUrl - Base registry URL or path
570
+ * @param {string} fileName - Name of the file to resolve
571
+ * @returns {string} Full path or URL to the file
572
+ */
412
573
  function resolveRegistryPath(baseUrl, fileName) {
413
574
  if (isLocalPath(baseUrl)) {
575
+ // Resolve local paths (absolute or relative)
414
576
  const basePath = path.isAbsolute(baseUrl)
415
577
  ? baseUrl
416
578
  : path.join(process.cwd(), baseUrl);
417
579
  return path.join(basePath, fileName);
418
580
  }
581
+ // For remote URLs, just append the filename
419
582
  return `${baseUrl}/${fileName}`;
420
583
  }
421
584
 
585
+ /**
586
+ * Loads a component registry file from the registry
587
+ * Returns null if component not found (doesn't throw)
588
+ *
589
+ * @param {string} componentName - Name of the component
590
+ * @param {string} registryBaseUrl - Base URL/path of the registry
591
+ * @returns {Promise<object|null>} Component registry object or null if not found
592
+ */
422
593
  async function loadRegistry(componentName, registryBaseUrl) {
423
594
  try {
424
595
  const registryPath = resolveRegistryPath(registryBaseUrl, `${componentName}.json`);
425
596
  return await fetchJSON(registryPath);
426
597
  } catch (err) {
598
+ // Component not found - return null instead of throwing
427
599
  return null;
428
600
  }
429
601
  }
430
602
 
603
+ /**
604
+ * Loads the registry index file (lists all available components)
605
+ * Throws an error if index cannot be loaded
606
+ *
607
+ * @param {string} registryBaseUrl - Base URL/path of the registry
608
+ * @returns {Promise<object>} Index object containing list of components
609
+ */
431
610
  async function loadIndex(registryBaseUrl) {
432
611
  const indexPath = resolveRegistryPath(registryBaseUrl, 'index.json');
433
612
  try {
@@ -439,31 +618,47 @@ async function loadIndex(registryBaseUrl) {
439
618
  }
440
619
 
441
620
  // ============================================================================
442
- // CSS PROCESSING
621
+ // CSS PROCESSING - Style Installation and Formatting
443
622
  // ============================================================================
444
623
 
624
+ /**
625
+ * Checks if CSS content contains Weloop design system variables
626
+ * Used to detect if styles have already been installed
627
+ *
628
+ * @param {string} content - CSS content to check
629
+ * @returns {boolean} True if Weloop styles are present
630
+ */
445
631
  function hasWeloopStyles(content) {
446
- return content.includes('--WLDS-PRM-') ||
447
- content.includes('--WLDS-RED-') ||
448
- content.includes('--WLDS-NTL-') ||
449
- content.includes('--system-100') ||
632
+ // Check for Weloop-specific CSS variable prefixes
633
+ return content.includes('--WLDS-PRM-') || // Primary colors
634
+ content.includes('--WLDS-RED-') || // Red colors
635
+ content.includes('--WLDS-NTL-') || // Neutral colors
636
+ content.includes('--system-100') || // System colors
450
637
  content.includes('--system-200');
451
638
  }
452
639
 
640
+ /**
641
+ * Removes duplicate tw-animate-css imports and keeps only one
642
+ * Ensures the import appears right after tailwindcss import
643
+ *
644
+ * @param {string} cssContent - CSS content to clean
645
+ * @returns {string} CSS content with duplicates removed
646
+ */
453
647
  function removeDuplicateTwAnimateImports(cssContent) {
454
648
  const twAnimatePattern = /@import\s+["']tw-animate-css["'];?\s*\n?/g;
455
649
  const matches = cssContent.match(twAnimatePattern);
456
650
 
457
651
  if (matches && matches.length > 1) {
458
- // Remove all occurrences
652
+ // Remove all occurrences first
459
653
  let cleaned = cssContent.replace(twAnimatePattern, '');
460
- // Add it back once after tailwindcss import
654
+ // Add it back once, right after tailwindcss import if it exists
461
655
  if (cleaned.includes('@import "tailwindcss"') || cleaned.includes("@import 'tailwindcss'")) {
462
656
  cleaned = cleaned.replace(
463
657
  /(@import\s+["']tailwindcss["'];?\s*\n?)/,
464
658
  '$1@import "tw-animate-css";\n'
465
659
  );
466
660
  } else {
661
+ // If no tailwindcss import, add it at the beginning
467
662
  cleaned = '@import "tw-animate-css";\n' + cleaned;
468
663
  }
469
664
  return cleaned;
@@ -472,11 +667,19 @@ function removeDuplicateTwAnimateImports(cssContent) {
472
667
  return cssContent;
473
668
  }
474
669
 
670
+ /**
671
+ * Removes duplicate @custom-variant declarations
672
+ * Keeps only one instance at the top
673
+ *
674
+ * @param {string} cssContent - CSS content to clean
675
+ * @returns {string} CSS content with duplicates removed
676
+ */
475
677
  function removeDuplicateCustomVariant(cssContent) {
476
678
  const variantPattern = /@custom-variant\s+dark\s+\(&:is\(\.dark \*\)\);\s*\n?/g;
477
679
  const matches = cssContent.match(variantPattern);
478
680
 
479
681
  if (matches && matches.length > 1) {
682
+ // Remove all occurrences and add back one at the top
480
683
  const withoutVariants = cssContent.replace(variantPattern, '');
481
684
  return `@custom-variant dark (&:is(.dark *));\n\n${withoutVariants.trimStart()}`;
482
685
  }
@@ -484,11 +687,93 @@ function removeDuplicateCustomVariant(cssContent) {
484
687
  return cssContent;
485
688
  }
486
689
 
690
+ /**
691
+ * Normalizes CSS format to ensure consistent structure
692
+ *
693
+ * Format order:
694
+ * 1. @import statements (tailwindcss, then tw-animate-css, then others)
695
+ * 2. @custom-variant declaration
696
+ * 3. Rest of the CSS content
697
+ *
698
+ * This ensures the CSS file always has the same structure regardless of
699
+ * how it was generated or merged.
700
+ *
701
+ * @param {string} cssContent - CSS content to normalize
702
+ * @returns {string} Normalized CSS content
703
+ */
704
+ function normalizeCSSFormat(cssContent) {
705
+ // Extract @custom-variant declaration
706
+ const variantPattern = /@custom-variant\s+dark\s+\(&:is\(\.dark \*\)\);\s*\n?/g;
707
+ const variantMatch = cssContent.match(variantPattern);
708
+ const hasVariant = variantMatch && variantMatch.length > 0;
709
+
710
+ // Extract all @import statements
711
+ const importPattern = /@import\s+["'][^"']+["'];?\s*\n?/g;
712
+ const imports = cssContent.match(importPattern) || [];
713
+
714
+ // Separate imports by type for proper ordering
715
+ const tailwindImport = imports.find(imp => imp.includes('tailwindcss'));
716
+ const twAnimateImport = imports.find(imp => imp.includes('tw-animate-css'));
717
+ const otherImports = imports.filter(imp =>
718
+ !imp.includes('tailwindcss') && !imp.includes('tw-animate-css')
719
+ );
720
+
721
+ // Remove all imports and variant from content to get the rest
722
+ let content = cssContent
723
+ .replace(variantPattern, '')
724
+ .replace(importPattern, '')
725
+ .trim();
726
+
727
+ // Build normalized format in the correct order
728
+ let normalized = '';
729
+
730
+ // Step 1: Imports first (tailwindcss, then tw-animate-css, then others)
731
+ if (tailwindImport) {
732
+ normalized += tailwindImport.trim() + '\n';
733
+ }
734
+ if (twAnimateImport) {
735
+ normalized += twAnimateImport.trim() + '\n';
736
+ }
737
+ if (otherImports.length > 0) {
738
+ normalized += otherImports.join('') + '\n';
739
+ }
740
+
741
+ // Step 2: @custom-variant second (if it exists)
742
+ if (hasVariant) {
743
+ if (normalized) {
744
+ normalized += '\n@custom-variant dark (&:is(.dark *));\n';
745
+ } else {
746
+ normalized += '@custom-variant dark (&:is(.dark *));\n';
747
+ }
748
+ }
749
+
750
+ // Step 3: Rest of content (theme variables, etc.)
751
+ if (normalized && content) {
752
+ normalized += '\n' + content;
753
+ } else if (content) {
754
+ normalized = content;
755
+ }
756
+
757
+ return normalized.trim() + (normalized ? '\n' : '');
758
+ }
759
+
760
+ /**
761
+ * Processes tw-animate-css import based on whether the package is installed
762
+ *
763
+ * - If package is NOT installed: Removes the import to prevent build errors
764
+ * - If package IS installed: Ensures import exists and removes duplicates
765
+ *
766
+ * @param {string} cssContent - CSS content to process
767
+ * @param {boolean} hasTwAnimate - Whether tw-animate-css package is installed
768
+ * @param {boolean} forceUpdate - Whether this is a forced update
769
+ * @returns {string} Processed CSS content
770
+ */
487
771
  function processTwAnimateImport(cssContent, hasTwAnimate, forceUpdate = false) {
488
772
  const twAnimatePattern = /@import\s+["']tw-animate-css["'];?\s*\n?/g;
489
773
  let processed = cssContent;
490
774
 
491
775
  if (!hasTwAnimate) {
776
+ // Package not installed - remove import to prevent build errors
492
777
  processed = cssContent.replace(twAnimatePattern, '');
493
778
  if (cssContent.includes('tw-animate-css') && !processed.includes('tw-animate-css')) {
494
779
  if (forceUpdate) {
@@ -502,10 +787,10 @@ function processTwAnimateImport(cssContent, hasTwAnimate, forceUpdate = false) {
502
787
  }
503
788
  }
504
789
  } else {
505
- // Remove any duplicates first
790
+ // Package is installed - ensure import exists and remove duplicates
506
791
  processed = removeDuplicateTwAnimateImports(processed);
507
792
 
508
- // Ensure import exists if package is installed (only if not already present)
793
+ // Add import if it doesn't exist (right after tailwindcss if present)
509
794
  if (!processed.match(/@import\s+["']tw-animate-css["'];?\s*\n?/)) {
510
795
  if (processed.includes('@import "tailwindcss"') || processed.includes("@import 'tailwindcss'")) {
511
796
  processed = processed.replace(
@@ -521,10 +806,25 @@ function processTwAnimateImport(cssContent, hasTwAnimate, forceUpdate = false) {
521
806
  return processed;
522
807
  }
523
808
 
809
+ /**
810
+ * Removes tailwindcss import statements from CSS
811
+ * Used when merging styles to avoid duplicates
812
+ *
813
+ * @param {string} cssContent - CSS content
814
+ * @returns {string} CSS content without tailwindcss imports
815
+ */
524
816
  function removeTailwindImport(cssContent) {
525
817
  return cssContent.replace(/@import\s+["']tailwindcss["'];?\s*\n?/g, '');
526
818
  }
527
819
 
820
+ /**
821
+ * Ensures tw-animate-css import exists if the package is installed
822
+ * Adds it right after tailwindcss import if present
823
+ *
824
+ * @param {string} cssContent - CSS content
825
+ * @param {boolean} hasTwAnimate - Whether tw-animate-css package is installed
826
+ * @returns {string} CSS content with tw-animate-css import ensured
827
+ */
528
828
  function ensureTwAnimateImport(cssContent, hasTwAnimate) {
529
829
  if (!hasTwAnimate) {
530
830
  return cssContent;
@@ -538,7 +838,7 @@ function ensureTwAnimateImport(cssContent, hasTwAnimate) {
538
838
  return cleaned;
539
839
  }
540
840
 
541
- // Add import after tailwindcss if it exists
841
+ // Add import after tailwindcss if it exists, otherwise at the beginning
542
842
  if (cleaned.includes('@import "tailwindcss"') || cleaned.includes("@import 'tailwindcss'")) {
543
843
  return cleaned.replace(
544
844
  /(@import\s+["']tailwindcss["'];?\s*\n?)/,
@@ -549,21 +849,36 @@ function ensureTwAnimateImport(cssContent, hasTwAnimate) {
549
849
  return '@import "tw-animate-css";\n' + cleaned;
550
850
  }
551
851
 
852
+ /**
853
+ * Merges Weloop styles with existing CSS that has Tailwind imports
854
+ *
855
+ * Strategy:
856
+ * - Finds the tailwindcss import in existing CSS
857
+ * - Inserts Weloop styles right after it
858
+ * - Handles tw-animate-css import to avoid duplicates
859
+ *
860
+ * @param {string} existing - Existing CSS content
861
+ * @param {string} weloopStyles - Weloop styles to merge in
862
+ * @param {boolean} hasTwAnimate - Whether tw-animate-css package is installed
863
+ * @returns {string} Merged CSS content
864
+ */
552
865
  function mergeCSSWithTailwind(existing, weloopStyles, hasTwAnimate) {
553
866
  const tailwindMatch = existing.match(/(@import\s+["']tailwindcss["'];?\s*\n?)/);
554
867
  if (!tailwindMatch) {
868
+ // No tailwindcss import found - just prepend Weloop styles
555
869
  return weloopStyles + '\n\n' + existing;
556
870
  }
557
871
 
872
+ // Split existing CSS around the tailwindcss import
558
873
  const beforeTailwind = existing.substring(0, existing.indexOf(tailwindMatch[0]));
559
874
  const afterTailwind = existing.substring(existing.indexOf(tailwindMatch[0]) + tailwindMatch[0].length);
560
875
 
561
- // Check if tw-animate-css import already exists in existing or weloopStyles
876
+ // Check if tw-animate-css import already exists
562
877
  const hasTwAnimateInExisting = existing.match(/@import\s+["']tw-animate-css["'];?\s*\n?/);
563
878
  const hasTwAnimateInWeloop = weloopStyles.match(/@import\s+["']tw-animate-css["'];?\s*\n?/);
564
879
 
565
- // If the existing file already has the tw-animate import, strip it from the
566
- // Weloop styles to avoid duplicating it in the merge output.
880
+ // If existing file already has tw-animate import, remove it from Weloop styles
881
+ // to avoid duplicating it in the merge output
567
882
  let weloopStylesCleaned = weloopStyles;
568
883
  if (hasTwAnimateInExisting && hasTwAnimateInWeloop) {
569
884
  weloopStylesCleaned = weloopStyles.replace(
@@ -572,44 +887,75 @@ function mergeCSSWithTailwind(existing, weloopStyles, hasTwAnimate) {
572
887
  );
573
888
  }
574
889
 
890
+ // Add tw-animate import if needed (package installed but import missing)
575
891
  let importsToAdd = '';
576
892
  if (hasTwAnimate && !hasTwAnimateInExisting && !hasTwAnimateInWeloop) {
577
893
  importsToAdd = '@import "tw-animate-css";\n';
578
894
  }
579
895
 
896
+ // Merge: before tailwind + tailwind import + tw-animate (if needed) + Weloop styles + after tailwind
580
897
  return beforeTailwind + tailwindMatch[0] + importsToAdd + weloopStylesCleaned + '\n' + afterTailwind;
581
898
  }
582
899
 
900
+ /**
901
+ * Replaces existing Weloop styles with new ones
902
+ *
903
+ * Finds where Weloop styles start (usually @theme inline or :root)
904
+ * and replaces everything from there with the new styles.
905
+ * Preserves imports that come before the Weloop styles.
906
+ *
907
+ * @param {string} existing - Existing CSS content
908
+ * @param {string} newStyles - New Weloop styles to replace with
909
+ * @param {boolean} hasTwAnimate - Whether tw-animate-css package is installed
910
+ * @returns {string} CSS content with Weloop styles replaced
911
+ */
583
912
  function replaceWeloopStyles(existing, newStyles, hasTwAnimate) {
584
913
  const importPattern = /(@import\s+["'][^"']+["'];?\s*\n?)/g;
585
914
  const imports = existing.match(importPattern) || [];
586
915
  const importsText = imports.join('');
587
916
 
917
+ // Find where Weloop styles start (look for @theme inline or :root)
588
918
  const weloopStartPattern = /(@theme\s+inline|:root\s*\{)/;
589
919
  const weloopStartMatch = existing.search(weloopStartPattern);
590
920
 
591
921
  if (weloopStartMatch === -1) {
922
+ // No existing Weloop styles found - just append
592
923
  return existing + '\n\n' + newStyles;
593
924
  }
594
925
 
926
+ // Extract content before Weloop styles
595
927
  let contentBeforeWeloop = existing.substring(0, weloopStartMatch);
928
+ // Keep non-Weloop imports (filter out tw-animate if package not installed)
596
929
  const nonWeloopImports = importsText.split('\n').filter(imp =>
597
930
  !imp.includes('tw-animate-css') || hasTwAnimate
598
931
  ).join('\n');
932
+ // Remove all imports from contentBeforeWeloop (we'll add them back)
599
933
  contentBeforeWeloop = nonWeloopImports + '\n' + contentBeforeWeloop.replace(importPattern, '');
600
934
 
935
+ // Return: preserved imports + content before Weloop + new Weloop styles
601
936
  return contentBeforeWeloop.trim() + '\n\n' + newStyles.trim();
602
937
  }
603
938
 
939
+ /**
940
+ * Fetches the CSS file from the registry
941
+ *
942
+ * For local paths: Looks for app/globals.css relative to registry directory
943
+ * For remote URLs: Constructs URL by replacing /registry with /app/globals.css
944
+ *
945
+ * @param {string} registryUrl - Registry base URL or path
946
+ * @returns {Promise<string>} CSS file content
947
+ */
604
948
  async function fetchCSSFromRegistry(registryUrl) {
605
949
  let sourceCssPath;
606
950
 
607
951
  if (isLocalPath(registryUrl)) {
952
+ // Local path: find app/globals.css relative to registry directory
608
953
  const basePath = path.isAbsolute(registryUrl)
609
954
  ? path.dirname(registryUrl)
610
955
  : path.join(process.cwd(), path.dirname(registryUrl));
611
956
  sourceCssPath = path.join(basePath, 'app', 'globals.css');
612
957
  } else {
958
+ // Remote URL: replace /registry with /app/globals.css
613
959
  const baseUrl = registryUrl.replace('/registry', '');
614
960
  sourceCssPath = `${baseUrl}/app/globals.css`;
615
961
  }
@@ -617,17 +963,31 @@ async function fetchCSSFromRegistry(registryUrl) {
617
963
  return await fetchText(sourceCssPath);
618
964
  }
619
965
 
966
+ /**
967
+ * Installs or updates CSS styles from the registry
968
+ *
969
+ * Handles three scenarios:
970
+ * 1. --overwrite: Replaces entire file
971
+ * 2. Normal update: Intelligently merges with existing styles
972
+ * 3. New file: Creates file with Weloop styles
973
+ *
974
+ * @param {object} config - Components configuration object
975
+ * @param {string} registryUrl - Registry base URL or path
976
+ * @param {boolean} forceUpdate - Whether to overwrite existing file
977
+ * @param {boolean} silent - Whether to suppress output messages
978
+ */
620
979
  async function installCSSStyles(config, registryUrl, forceUpdate = false, silent = false) {
621
980
  const cssPath = config.tailwind?.css || 'app/globals.css';
622
981
  const fullCssPath = path.join(process.cwd(), cssPath);
623
982
 
624
- // Check if Weloop styles already exist
983
+ // Check if Weloop styles already exist in the file
625
984
  let hasWeloopStylesInFile = false;
626
985
  if (fs.existsSync(fullCssPath)) {
627
986
  const existingContent = fs.readFileSync(fullCssPath, 'utf-8');
628
987
  hasWeloopStylesInFile = hasWeloopStyles(existingContent);
629
988
 
630
- // If styles already exist and not forcing update, skip silently
989
+ // If styles already exist and we're not forcing update, skip silently
990
+ // This prevents unnecessary updates when installing components
631
991
  if (hasWeloopStylesInFile && !forceUpdate && silent) {
632
992
  return;
633
993
  }
@@ -644,10 +1004,13 @@ async function installCSSStyles(config, registryUrl, forceUpdate = false, silent
644
1004
  const hasTwAnimate = checkPackageInstalled('tw-animate-css');
645
1005
  let processedCssContent = processTwAnimateImport(cssContent, hasTwAnimate, forceUpdate);
646
1006
 
647
- // Handle file installation
1007
+ // Handle file installation based on mode and existing file state
648
1008
  if (forceUpdate && fs.existsSync(fullCssPath)) {
649
- // --overwrite: Replace entire file
650
- fs.writeFileSync(fullCssPath, processedCssContent);
1009
+ // Mode 1: --overwrite flag - Replace entire file with fresh styles
1010
+ let normalized = normalizeCSSFormat(processedCssContent);
1011
+ normalized = removeDuplicateTwAnimateImports(normalized);
1012
+ normalized = removeDuplicateCustomVariant(normalized);
1013
+ fs.writeFileSync(fullCssPath, normalized);
651
1014
  if (!silent) {
652
1015
  success(`Overwritten ${cssPath} with Weloop styles`);
653
1016
  if (hasTwAnimate) {
@@ -655,34 +1018,36 @@ async function installCSSStyles(config, registryUrl, forceUpdate = false, silent
655
1018
  }
656
1019
  }
657
1020
  } else if (fs.existsSync(fullCssPath)) {
658
- // Normal mode: Merge intelligently
1021
+ // Mode 2: Normal update - Intelligently merge with existing styles
659
1022
  const existing = fs.readFileSync(fullCssPath, 'utf-8');
660
1023
  const hasTailwindImport = existing.includes('@import "tailwindcss"') ||
661
1024
  existing.includes('@tailwind base');
662
1025
 
663
1026
  if (hasWeloopStylesInFile) {
664
- // Replace existing Weloop styles (only if different)
1027
+ // Case 2a: Weloop styles already exist - replace them with updated versions
665
1028
  let weloopStyles = removeTailwindImport(processedCssContent);
666
1029
  weloopStyles = ensureTwAnimateImport(weloopStyles, hasTwAnimate);
667
1030
  let finalContent = replaceWeloopStyles(existing, weloopStyles, hasTwAnimate);
668
1031
  finalContent = removeDuplicateTwAnimateImports(finalContent);
669
1032
  finalContent = removeDuplicateCustomVariant(finalContent);
1033
+ finalContent = normalizeCSSFormat(finalContent);
670
1034
 
671
- // Only update if content actually changed
1035
+ // Only write if content actually changed (prevents unnecessary file updates)
672
1036
  if (finalContent !== existing) {
673
1037
  fs.writeFileSync(fullCssPath, finalContent);
674
1038
  if (!silent) {
675
1039
  success(`Updated ${cssPath} with Weloop styles`);
676
1040
  }
677
1041
  }
678
- // If no changes, silently skip
1042
+ // If no changes, silently skip (file is already up to date)
679
1043
  } else if (hasTailwindImport) {
680
- // Merge after Tailwind imports
1044
+ // Case 2b: No Weloop styles but Tailwind exists - merge after Tailwind imports
681
1045
  let weloopStyles = removeTailwindImport(processedCssContent);
682
1046
  weloopStyles = ensureTwAnimateImport(weloopStyles, hasTwAnimate);
683
1047
  let merged = mergeCSSWithTailwind(existing, weloopStyles, hasTwAnimate);
684
1048
  merged = removeDuplicateTwAnimateImports(merged);
685
1049
  merged = removeDuplicateCustomVariant(merged);
1050
+ merged = normalizeCSSFormat(merged);
686
1051
  fs.writeFileSync(fullCssPath, merged);
687
1052
  if (!silent) {
688
1053
  success(`Updated ${cssPath} with Weloop styles`);
@@ -692,9 +1057,10 @@ async function installCSSStyles(config, registryUrl, forceUpdate = false, silent
692
1057
  info(` Your existing styles are preserved`);
693
1058
  }
694
1059
  } else {
695
- // No Tailwind imports, prepend everything
1060
+ // Case 2c: No Tailwind imports - prepend Weloop styles to existing content
696
1061
  let finalCssContent = removeDuplicateTwAnimateImports(processedCssContent);
697
1062
 
1063
+ // Ensure tw-animate import exists if package is installed
698
1064
  if (hasTwAnimate && !finalCssContent.match(/@import\s+["']tw-animate-css["'];?\s*\n?/)) {
699
1065
  if (finalCssContent.includes('@import "tailwindcss"')) {
700
1066
  finalCssContent = finalCssContent.replace(
@@ -705,7 +1071,9 @@ async function installCSSStyles(config, registryUrl, forceUpdate = false, silent
705
1071
  finalCssContent = '@import "tailwindcss";\n@import "tw-animate-css";\n' + finalCssContent;
706
1072
  }
707
1073
  }
708
- fs.writeFileSync(fullCssPath, finalCssContent + '\n\n' + existing);
1074
+ let combined = finalCssContent + '\n\n' + existing;
1075
+ combined = normalizeCSSFormat(combined);
1076
+ fs.writeFileSync(fullCssPath, combined);
709
1077
  if (!silent) {
710
1078
  success(`Updated ${cssPath} with Weloop styles`);
711
1079
  if (hasTwAnimate) {
@@ -714,8 +1082,11 @@ async function installCSSStyles(config, registryUrl, forceUpdate = false, silent
714
1082
  }
715
1083
  }
716
1084
  } else {
717
- // Create new file
718
- fs.writeFileSync(fullCssPath, processedCssContent);
1085
+ // Mode 3: File doesn't exist - create new file with Weloop styles
1086
+ let normalized = normalizeCSSFormat(processedCssContent);
1087
+ normalized = removeDuplicateTwAnimateImports(normalized);
1088
+ normalized = removeDuplicateCustomVariant(normalized);
1089
+ fs.writeFileSync(fullCssPath, normalized);
719
1090
  if (!silent) {
720
1091
  success(`Created ${cssPath} with Weloop styles`);
721
1092
  if (hasTwAnimate) {
@@ -735,13 +1106,22 @@ async function installCSSStyles(config, registryUrl, forceUpdate = false, silent
735
1106
  }
736
1107
 
737
1108
  // ============================================================================
738
- // COMPONENT INSTALLATION
1109
+ // COMPONENT INSTALLATION - Installing Components from Registry
739
1110
  // ============================================================================
740
1111
 
1112
+ /**
1113
+ * Creates the utils.ts file if it doesn't exist
1114
+ *
1115
+ * This file contains the 'cn' utility function used by all components
1116
+ * for merging Tailwind classes. It's required for components to work.
1117
+ *
1118
+ * @param {string} utilsPath - Path where utils.ts should be created
1119
+ */
741
1120
  function createUtilsFile(utilsPath) {
742
1121
  if (fs.existsSync(utilsPath)) return;
743
1122
 
744
1123
  // Silently create utils file (matching shadcn/ui behavior)
1124
+ // Don't overwrite if user has customized it
745
1125
  ensureDirectoryExists(path.dirname(utilsPath));
746
1126
 
747
1127
  const utilsContent = `import { clsx, type ClassValue } from "clsx"
@@ -754,10 +1134,27 @@ export function cn(...inputs: ClassValue[]) {
754
1134
  fs.writeFileSync(utilsPath, utilsContent);
755
1135
  }
756
1136
 
1137
+ /**
1138
+ * Installs a component from the registry
1139
+ *
1140
+ * Installation process:
1141
+ * 1. Install base dependencies (clsx, tailwind-merge)
1142
+ * 2. Install tw-animate-css for animations
1143
+ * 3. Install/update CSS styles (silently if already installed)
1144
+ * 4. Install component dependencies (recursive)
1145
+ * 5. Install component files
1146
+ * 6. Create utils.ts if needed
1147
+ * 7. Install npm dependencies
1148
+ *
1149
+ * @param {string} componentName - Name of the component to install
1150
+ * @param {object} options - Installation options
1151
+ * @param {boolean} options.overwrite - Whether to overwrite existing files
1152
+ * @param {string} options.registryUrl - Registry URL or path
1153
+ */
757
1154
  async function installComponent(componentName, options = {}) {
758
1155
  const { overwrite = false, registryUrl = DEFAULT_REGISTRY_URL } = options;
759
1156
 
760
- // Install base dependencies first (required for utils.ts)
1157
+ // Step 1: Install base dependencies (required for utils.ts to work)
761
1158
  const baseDeps = [];
762
1159
  if (!checkPackageInstalled('clsx')) {
763
1160
  baseDeps.push('clsx');
@@ -770,19 +1167,20 @@ async function installComponent(componentName, options = {}) {
770
1167
  await installPackages(baseDeps);
771
1168
  }
772
1169
 
773
- // Load or create components.json (after base deps are installed)
1170
+ // Step 2: Load or create components.json configuration
774
1171
  const config = loadComponentsConfig();
775
1172
 
776
- // Install tw-animate-css automatically (required for animations)
1173
+ // Step 3: Install tw-animate-css automatically (required for component animations)
777
1174
  if (!checkPackageInstalled('tw-animate-css')) {
778
1175
  info('Installing tw-animate-css for animations...');
779
1176
  await installPackages(['tw-animate-css']);
780
1177
  }
781
1178
 
782
- // Install CSS styles early (before component installation) - silent if already installed
1179
+ // Step 4: Install CSS styles early (before component installation)
1180
+ // Silent mode prevents output when styles are already installed
783
1181
  await installCSSStyles(config, registryUrl, false, true);
784
1182
 
785
- // Get paths from components.json
1183
+ // Step 5: Resolve paths from components.json configuration
786
1184
  const uiAlias = config.aliases?.ui || '@/components/ui';
787
1185
  const utilsAlias = config.aliases?.utils || '@/lib/utils';
788
1186
 
@@ -793,30 +1191,34 @@ async function installComponent(componentName, options = {}) {
793
1191
  }
794
1192
  utilsPath = path.join(process.cwd(), utilsPath);
795
1193
 
796
- // Load component registry
1194
+ // Step 6: Load component registry from remote or local source
797
1195
  const registry = await loadRegistry(componentName, registryUrl);
798
1196
 
799
1197
  if (!registry) {
800
1198
  error(`Component "${componentName}" not found in registry.`);
801
1199
  info('Available components:');
802
1200
  try {
1201
+ // Show available components to help user
803
1202
  const index = await loadIndex(registryUrl);
804
1203
  index.registry.forEach(comp => {
805
1204
  console.log(` - ${comp.name}`);
806
1205
  });
807
1206
  } catch (e) {
808
- // Ignore if index fails
1207
+ // Ignore if index fails - we already showed the error
809
1208
  }
810
1209
  process.exit(1);
811
1210
  }
812
1211
 
813
- // Check if component already exists (silently skip if exists, matching shadcn/ui behavior)
1212
+ // Step 7: Check if component already exists
1213
+ // Silently skip if exists (matching shadcn/ui behavior)
1214
+ // Only install if overwrite flag is set
814
1215
  const componentPath = path.join(componentsDir, `${componentName}.tsx`);
815
1216
  if (fs.existsSync(componentPath) && !overwrite) {
816
1217
  return;
817
1218
  }
818
1219
 
819
- // Install registry dependencies first
1220
+ // Step 8: Install component dependencies first (recursive)
1221
+ // Some components depend on other components (e.g., button-group depends on button)
820
1222
  if (registry.registryDependencies && registry.registryDependencies.length > 0) {
821
1223
  info(`Installing dependencies: ${registry.registryDependencies.join(', ')}`);
822
1224
  for (const dep of registry.registryDependencies) {
@@ -824,7 +1226,8 @@ async function installComponent(componentName, options = {}) {
824
1226
  }
825
1227
  }
826
1228
 
827
- // Install component files
1229
+ // Step 9: Install component files
1230
+ // Components can have multiple files (e.g., component + types + styles)
828
1231
  for (const file of registry.files) {
829
1232
  const filePath = path.join(process.cwd(), file.path);
830
1233
  ensureDirectoryExists(path.dirname(filePath));
@@ -832,10 +1235,11 @@ async function installComponent(componentName, options = {}) {
832
1235
  success(`Installed: ${file.path}`);
833
1236
  }
834
1237
 
835
- // Create utils.ts if needed
1238
+ // Step 10: Create utils.ts if it doesn't exist
1239
+ // This is required for all components to work
836
1240
  createUtilsFile(utilsPath);
837
1241
 
838
- // Install npm dependencies
1242
+ // Step 11: Install npm dependencies required by the component
839
1243
  if (registry.dependencies && registry.dependencies.length > 0) {
840
1244
  const missingDeps = getMissingDependencies(registry.dependencies);
841
1245
  if (missingDeps.length > 0) {
@@ -849,15 +1253,184 @@ async function installComponent(componentName, options = {}) {
849
1253
  }
850
1254
 
851
1255
  // ============================================================================
852
- // COMMANDS
1256
+ // INIT COMMAND - Project Initialization
853
1257
  // ============================================================================
854
1258
 
1259
+ /**
1260
+ * Detects the project type (Next.js or Vite)
1261
+ * @returns {string} 'nextjs' or 'vite' or null
1262
+ */
1263
+ function detectProjectType() {
1264
+ // Check for Next.js
1265
+ if (fs.existsSync(path.join(process.cwd(), 'next.config.js')) ||
1266
+ fs.existsSync(path.join(process.cwd(), 'next.config.mjs')) ||
1267
+ fs.existsSync(path.join(process.cwd(), 'next.config.ts')) ||
1268
+ fs.existsSync(path.join(process.cwd(), 'app')) ||
1269
+ fs.existsSync(path.join(process.cwd(), 'pages'))) {
1270
+ return 'nextjs';
1271
+ }
1272
+
1273
+ // Check for Vite
1274
+ if (fs.existsSync(path.join(process.cwd(), 'vite.config.js')) ||
1275
+ fs.existsSync(path.join(process.cwd(), 'vite.config.ts')) ||
1276
+ fs.existsSync(path.join(process.cwd(), 'vite.config.mjs'))) {
1277
+ return 'vite';
1278
+ }
1279
+
1280
+ return null;
1281
+ }
1282
+
1283
+ /**
1284
+ * Prompts user for configuration using readline
1285
+ * @param {string} question - Question to ask
1286
+ * @param {string} defaultValue - Default value
1287
+ * @returns {Promise<string>} User input
1288
+ */
1289
+ function prompt(question, defaultValue = '') {
1290
+ return new Promise((resolve) => {
1291
+ const readline = require('readline');
1292
+ const rl = readline.createInterface({
1293
+ input: process.stdin,
1294
+ output: process.stdout
1295
+ });
1296
+
1297
+ const promptText = defaultValue
1298
+ ? `${question} › ${defaultValue} `
1299
+ : `${question} › `;
1300
+
1301
+ rl.question(promptText, (answer) => {
1302
+ rl.close();
1303
+ resolve(answer.trim() || defaultValue);
1304
+ });
1305
+ });
1306
+ }
1307
+
1308
+ /**
1309
+ * Initializes the project with components.json configuration
1310
+ * Similar to shadcn/ui init command
1311
+ */
1312
+ async function initProject() {
1313
+ info('Initializing project...\n');
1314
+
1315
+ // Check if components.json already exists
1316
+ const configPath = path.join(process.cwd(), 'components.json');
1317
+ if (fs.existsSync(configPath)) {
1318
+ warn('components.json already exists.');
1319
+ const overwrite = await prompt('Do you want to overwrite it? (y/N)', 'N');
1320
+ if (overwrite.toLowerCase() !== 'y' && overwrite.toLowerCase() !== 'yes') {
1321
+ info('Skipping initialization.');
1322
+ return;
1323
+ }
1324
+ }
1325
+
1326
+ // Detect project type
1327
+ const projectType = detectProjectType();
1328
+ let detectedType = projectType || 'nextjs';
1329
+
1330
+ if (!projectType) {
1331
+ info('Could not detect project type. Defaulting to Next.js.');
1332
+ } else {
1333
+ info(`Detected ${projectType === 'nextjs' ? 'Next.js' : 'Vite'} project.`);
1334
+ }
1335
+
1336
+ // Ask for project type if not detected or user wants to override
1337
+ const useDetected = projectType
1338
+ ? await prompt(`Which project type? (Next.js/Vite)`, detectedType === 'nextjs' ? 'Next.js' : 'Vite')
1339
+ : await prompt(`Which project type? (Next.js/Vite)`, 'Next.js');
1340
+
1341
+ const isNextjs = useDetected.toLowerCase().includes('next');
1342
+ const isVite = useDetected.toLowerCase().includes('vite');
1343
+
1344
+ // Detect CSS path
1345
+ let cssPath = 'app/globals.css';
1346
+ if (isVite) {
1347
+ cssPath = 'src/index.css';
1348
+ } else if (fs.existsSync(path.join(process.cwd(), 'app', 'globals.css'))) {
1349
+ cssPath = 'app/globals.css';
1350
+ } else if (fs.existsSync(path.join(process.cwd(), 'styles', 'globals.css'))) {
1351
+ cssPath = 'styles/globals.css';
1352
+ } else {
1353
+ cssPath = await prompt('Where is your global CSS file?', cssPath);
1354
+ }
1355
+
1356
+ // Ask for base color
1357
+ const baseColor = await prompt('Which color would you like to use as base color?', 'neutral');
1358
+
1359
+ // Ask for style
1360
+ const style = await prompt('Which style would you like to use?', 'blue');
1361
+
1362
+ // Create components.json
1363
+ const config = {
1364
+ $schema: 'https://weloop.kosign.dev/schema.json',
1365
+ style: style,
1366
+ rsc: isNextjs,
1367
+ tsx: true,
1368
+ tailwind: {
1369
+ config: 'tailwind.config.ts',
1370
+ css: cssPath,
1371
+ baseColor: baseColor,
1372
+ cssVariables: true,
1373
+ prefix: ''
1374
+ },
1375
+ iconLibrary: 'lucide',
1376
+ aliases: {
1377
+ components: '@/components',
1378
+ utils: '@/lib/utils',
1379
+ ui: '@/components/ui',
1380
+ lib: '@/lib',
1381
+ hooks: '@/hooks'
1382
+ },
1383
+ registry: 'https://gitlab.com/Sophanithchrek/weloop-shadcn-next-app/-/raw/main/registry/index.json'
1384
+ };
1385
+
1386
+ fs.writeFileSync(configPath, JSON.stringify(config, null, 2));
1387
+ success('Created components.json\n');
1388
+
1389
+ // Install base dependencies
1390
+ info('Installing base dependencies...');
1391
+ const baseDeps = ['clsx', 'tailwind-merge'];
1392
+ const missingBaseDeps = baseDeps.filter(dep => !checkPackageInstalled(dep));
1393
+
1394
+ if (missingBaseDeps.length > 0) {
1395
+ await installPackages(missingBaseDeps);
1396
+ } else {
1397
+ info('Base dependencies already installed.');
1398
+ }
1399
+
1400
+ // Install tw-animate-css
1401
+ if (!checkPackageInstalled('tw-animate-css')) {
1402
+ info('Installing tw-animate-css for animations...');
1403
+ await installPackages(['tw-animate-css']);
1404
+ }
1405
+
1406
+ // Install CSS styles
1407
+ info('\nInstalling CSS styles...');
1408
+ await installCSSStyles(config, DEFAULT_REGISTRY_URL, false, false);
1409
+
1410
+ success('\n✅ Project initialized successfully!');
1411
+ info('\nYou can now add components using:');
1412
+ info(' npx weloop-kosign@latest add <component-name>');
1413
+ info(' pnpm dlx weloop-kosign@latest add <component-name>');
1414
+ info(' yarn weloop-kosign@latest add <component-name>\n');
1415
+ }
1416
+
1417
+ // ============================================================================
1418
+ // COMMANDS - CLI Command Handlers
1419
+ // ============================================================================
1420
+
1421
+ /**
1422
+ * Lists all available components from the registry
1423
+ * Shows component names and their dependencies
1424
+ *
1425
+ * @param {string} registryUrl - Registry URL or path
1426
+ */
855
1427
  async function listComponents(registryUrl = DEFAULT_REGISTRY_URL) {
856
1428
  try {
857
1429
  const index = await loadIndex(registryUrl);
858
1430
 
859
1431
  console.log('\nAvailable components:\n');
860
1432
  index.registry.forEach(comp => {
1433
+ // Show dependencies if component has any
861
1434
  const deps = comp.registryDependencies && comp.registryDependencies.length > 0
862
1435
  ? ` (depends on: ${comp.registryDependencies.join(', ')})`
863
1436
  : '';
@@ -871,27 +1444,43 @@ async function listComponents(registryUrl = DEFAULT_REGISTRY_URL) {
871
1444
  }
872
1445
 
873
1446
  // ============================================================================
874
- // MAIN
1447
+ // MAIN - CLI Entry Point
875
1448
  // ============================================================================
876
1449
 
1450
+ /**
1451
+ * Main CLI entry point
1452
+ *
1453
+ * Parses command line arguments and routes to appropriate command handler:
1454
+ * - add: Install a component
1455
+ * - list: List available components
1456
+ * - css: Install/update CSS styles
1457
+ *
1458
+ * Supports options:
1459
+ * - --registry: Custom registry URL or path
1460
+ * - --overwrite / -f: Overwrite existing files
1461
+ */
877
1462
  async function main() {
878
1463
  const args = process.argv.slice(2);
879
1464
  const command = args[0];
880
1465
  const componentName = args[1];
881
1466
 
1467
+ // Parse --registry option if provided
882
1468
  const registryIndex = args.indexOf('--registry');
883
1469
  const registryUrl = registryIndex !== -1 && args[registryIndex + 1]
884
1470
  ? args[registryIndex + 1]
885
1471
  : DEFAULT_REGISTRY_URL;
886
1472
 
1473
+ // Build options object
887
1474
  const options = {
888
1475
  overwrite: args.includes('--overwrite') || args.includes('-f'),
889
1476
  registryUrl: registryUrl,
890
1477
  };
891
1478
 
1479
+ // Show usage if no command provided
892
1480
  if (!command) {
893
1481
  console.log(`
894
1482
  Usage:
1483
+ node scripts/cli-remote.js init [--registry <url>] Initialize project
895
1484
  node scripts/cli-remote.js add <component-name> [--registry <url>] Install a component
896
1485
  node scripts/cli-remote.js list [--registry <url>] List all available components
897
1486
  node scripts/cli-remote.js css [--registry <url>] [--overwrite] Install/update CSS styles
@@ -901,6 +1490,7 @@ Options:
901
1490
  --overwrite, -f Overwrite existing files
902
1491
 
903
1492
  Examples:
1493
+ node scripts/cli-remote.js init
904
1494
  node scripts/cli-remote.js add button
905
1495
  node scripts/cli-remote.js add button --registry https://raw.githubusercontent.com/user/repo/main/registry
906
1496
  node scripts/cli-remote.js list
@@ -910,7 +1500,12 @@ Examples:
910
1500
  process.exit(0);
911
1501
  }
912
1502
 
1503
+ // Route to appropriate command handler
913
1504
  switch (command) {
1505
+ case 'init':
1506
+ await initProject();
1507
+ break;
1508
+
914
1509
  case 'add':
915
1510
  if (!componentName) {
916
1511
  error('Please provide a component name');
@@ -926,17 +1521,19 @@ Examples:
926
1521
 
927
1522
  case 'css':
928
1523
  case 'styles':
1524
+ // Both 'css' and 'styles' commands do the same thing
929
1525
  const config = loadComponentsConfig();
930
1526
  await installCSSStyles(config, registryUrl, options.overwrite, false);
931
1527
  break;
932
1528
 
933
1529
  default:
934
1530
  error(`Unknown command: ${command}`);
935
- console.log('Available commands: add, list, css');
1531
+ console.log('Available commands: init, add, list, css');
936
1532
  process.exit(1);
937
1533
  }
938
1534
  }
939
1535
 
1536
+ // Run main function and handle errors
940
1537
  main().catch(err => {
941
1538
  error(`Error: ${err.message}`);
942
1539
  process.exit(1);