@gilav21/shadcn-angular 0.0.20 → 0.0.22

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.
@@ -1,47 +1,57 @@
1
1
  import fs from 'fs-extra';
2
- import path from 'path';
3
- import { fileURLToPath } from 'url';
2
+ import path from 'node:path';
3
+ import { fileURLToPath } from 'node:url';
4
4
  import prompts from 'prompts';
5
5
  import chalk from 'chalk';
6
- import ora from 'ora';
7
- import { getConfig } from '../utils/config.js';
8
- import { registry, type ComponentName } from '../registry/index.js';
6
+ import ora, { type Ora } from 'ora';
7
+ import { getConfig, type Config } from '../utils/config.js';
8
+ import { registry, type ComponentDefinition, type ComponentName } from '../registry/index.js';
9
9
  import { installPackages } from '../utils/package-manager.js';
10
10
  import { writeShortcutRegistryIndex, type ShortcutRegistryEntry } from '../utils/shortcut-registry.js';
11
11
 
12
12
  const __filename = fileURLToPath(import.meta.url);
13
13
  const __dirname = path.dirname(__filename);
14
14
 
15
+ interface AddOptions {
16
+ yes?: boolean;
17
+ overwrite?: boolean;
18
+ all?: boolean;
19
+ path?: string;
20
+ remote?: boolean;
21
+ branch: string;
22
+ }
23
+
24
+ interface ConflictCheckResult {
25
+ toInstall: ComponentName[];
26
+ toSkip: string[];
27
+ conflicting: ComponentName[];
28
+ peerFilesToUpdate: Set<string>;
29
+ contentCache: Map<string, string>;
30
+ }
31
+
32
+ // ---------------------------------------------------------------------------
33
+ // Path & URL helpers
34
+ // ---------------------------------------------------------------------------
35
+
36
+ function validateBranch(branch: string): void {
37
+ if (!/^[\w.\-/]+$/.test(branch)) {
38
+ throw new Error(`Invalid branch name: ${branch}`);
39
+ }
40
+ }
41
+
15
42
  function getRegistryBaseUrl(branch: string) {
43
+ validateBranch(branch);
16
44
  return `https://raw.githubusercontent.com/gilav21/shadcn-angular/${branch}/packages/components/ui`;
17
45
  }
18
46
 
19
47
  function getLibRegistryBaseUrl(branch: string) {
48
+ validateBranch(branch);
20
49
  return `https://raw.githubusercontent.com/gilav21/shadcn-angular/${branch}/packages/components/lib`;
21
50
  }
22
51
 
23
- // Components source directory (relative to CLI dist folder) for local dev
24
52
  function getLocalComponentsDir(): string | null {
25
- // From dist/commands/add.js -> packages/components/ui
26
- const fromDist = path.resolve(__dirname, '../../../components/ui');
27
- if (fs.existsSync(fromDist)) {
28
- return fromDist;
29
- }
30
- // Fallback: from src/commands/add.ts -> packages/components/ui
31
- const fromSrc = path.resolve(__dirname, '../../../components/ui');
32
- if (fs.existsSync(fromSrc)) {
33
- return fromSrc;
34
- }
35
- return null;
36
- }
37
-
38
- interface AddOptions {
39
- yes?: boolean;
40
- overwrite?: boolean;
41
- all?: boolean;
42
- path?: string;
43
- remote?: boolean;
44
- branch: string;
53
+ const localPath = path.resolve(__dirname, '../../../components/ui');
54
+ return fs.existsSync(localPath) ? localPath : null;
45
55
  }
46
56
 
47
57
  function getLocalLibDir(): string | null {
@@ -67,10 +77,17 @@ function aliasToProjectPath(aliasOrPath: string): string {
67
77
  : aliasOrPath;
68
78
  }
69
79
 
80
+ function normalizeContent(str: string): string {
81
+ return str.replaceAll('\r\n', '\n').trim();
82
+ }
83
+
84
+ // ---------------------------------------------------------------------------
85
+ // Remote content fetching
86
+ // ---------------------------------------------------------------------------
87
+
70
88
  async function fetchComponentContent(file: string, options: AddOptions): Promise<string> {
71
89
  const localDir = getLocalComponentsDir();
72
90
 
73
- // 1. Prefer local if available and not forced remote
74
91
  if (localDir && !options.remote) {
75
92
  const localPath = path.join(localDir, file);
76
93
  if (await fs.pathExists(localPath)) {
@@ -78,7 +95,6 @@ async function fetchComponentContent(file: string, options: AddOptions): Promise
78
95
  }
79
96
  }
80
97
 
81
- // 2. Fetch from remote registry
82
98
  const url = `${getRegistryBaseUrl(options.branch)}/${file}`;
83
99
  try {
84
100
  const response = await fetch(url);
@@ -112,39 +128,22 @@ async function fetchLibContent(file: string, options: AddOptions): Promise<strin
112
128
  return response.text();
113
129
  }
114
130
 
115
- function collectInstalledShortcutEntries(targetDir: string): ShortcutRegistryEntry[] {
116
- const entries: ShortcutRegistryEntry[] = [];
117
- for (const definition of Object.values(registry)) {
118
- if (!definition.shortcutDefinitions?.length) {
119
- continue;
120
- }
121
- for (const shortcutDefinition of definition.shortcutDefinitions) {
122
- const sourcePath = path.join(targetDir, shortcutDefinition.sourceFile);
123
- if (fs.existsSync(sourcePath)) {
124
- entries.push(shortcutDefinition);
125
- }
126
- }
127
- }
128
- return entries;
131
+ async function fetchAndTransform(file: string, options: AddOptions, utilsAlias: string): Promise<string> {
132
+ let content = await fetchComponentContent(file, options);
133
+ content = content.replaceAll(/(\.\.\/)+lib\//, utilsAlias + '/');
134
+ return content;
129
135
  }
130
136
 
131
- export async function add(components: string[], options: AddOptions) {
132
- const cwd = process.cwd();
137
+ // ---------------------------------------------------------------------------
138
+ // Component selection & dependency resolution
139
+ // ---------------------------------------------------------------------------
133
140
 
134
- // Load config
135
- const config = await getConfig(cwd);
136
- if (!config) {
137
- console.log(chalk.red('Error: components.json not found.'));
138
- console.log(chalk.dim('Run `npx @gilav21/shadcn-angular init` first.'));
139
- process.exit(1);
141
+ async function selectComponents(components: string[], options: AddOptions): Promise<ComponentName[]> {
142
+ if (options.all) {
143
+ return Object.keys(registry);
140
144
  }
141
145
 
142
- // Get components to add
143
- let componentsToAdd: ComponentName[] = [];
144
-
145
- if (options.all) {
146
- componentsToAdd = Object.keys(registry) as ComponentName[];
147
- } else if (components.length === 0) {
146
+ if (components.length === 0) {
148
147
  const { selected } = await prompts({
149
148
  type: 'multiselect',
150
149
  name: 'selected',
@@ -155,131 +154,400 @@ export async function add(components: string[], options: AddOptions) {
155
154
  })),
156
155
  hint: '- Space to select, Enter to confirm',
157
156
  });
158
- componentsToAdd = selected;
159
- } else {
160
- componentsToAdd = components as ComponentName[];
157
+ return selected;
161
158
  }
162
159
 
163
- if (!componentsToAdd || componentsToAdd.length === 0) {
164
- console.log(chalk.dim('No components selected.'));
165
- return;
166
- }
160
+ return components;
161
+ }
167
162
 
168
- // Validate components exist
169
- const invalidComponents = componentsToAdd.filter(c => !registry[c]);
170
- if (invalidComponents.length > 0) {
171
- console.log(chalk.red(`Invalid component(s): ${invalidComponents.join(', ')}`));
163
+ function validateComponents(names: ComponentName[]): void {
164
+ const invalid = names.filter(c => !registry[c]);
165
+ if (invalid.length > 0) {
166
+ console.log(chalk.red(`Invalid component(s): ${invalid.join(', ')}`));
172
167
  console.log(chalk.dim('Available components: ' + Object.keys(registry).join(', ')));
173
168
  process.exit(1);
174
169
  }
170
+ }
171
+
172
+ export function resolveDependencies(names: ComponentName[]): Set<ComponentName> {
173
+ const all = new Set<ComponentName>();
174
+ const walk = (name: ComponentName) => {
175
+ if (all.has(name)) return;
176
+ all.add(name);
177
+ registry[name].dependencies?.forEach(dep => walk(dep));
178
+ };
179
+ names.forEach(walk);
180
+ return all;
181
+ }
182
+
183
+ // ---------------------------------------------------------------------------
184
+ // Optional dependency prompt
185
+ // ---------------------------------------------------------------------------
186
+
187
+ interface OptionalChoice {
188
+ readonly name: string;
189
+ readonly description: string;
190
+ readonly requestedBy: string;
191
+ }
175
192
 
176
- // Resolve dependencies
177
- const allComponents = new Set<ComponentName>();
178
- const resolveDeps = (name: ComponentName) => {
179
- if (allComponents.has(name)) return;
180
- allComponents.add(name);
193
+ export async function promptOptionalDependencies(
194
+ resolved: Set<ComponentName>,
195
+ options: AddOptions,
196
+ ): Promise<ComponentName[]> {
197
+ const seen = new Set<string>();
198
+ const choices: OptionalChoice[] = [];
199
+
200
+ for (const name of resolved) {
181
201
  const component = registry[name];
182
- if (component.dependencies) {
183
- component.dependencies.forEach(dep => resolveDeps(dep as ComponentName));
202
+ if (!component.optionalDependencies) continue;
203
+
204
+ for (const opt of component.optionalDependencies) {
205
+ if (resolved.has(opt.name) || seen.has(opt.name)) continue;
206
+ seen.add(opt.name);
207
+ choices.push({ name: opt.name, description: opt.description, requestedBy: name });
184
208
  }
185
- };
186
- componentsToAdd.forEach(c => resolveDeps(c));
209
+ }
187
210
 
188
- const uiBasePath = options.path ?? aliasToProjectPath(config.aliases.ui || 'src/components/ui');
189
- const targetDir = resolveProjectPath(cwd, uiBasePath);
211
+ if (choices.length === 0) return [];
212
+ if (options.yes) return [];
213
+ if (options.all) return choices.map(c => c.name);
214
+
215
+ const { selected } = await prompts({
216
+ type: 'multiselect',
217
+ name: 'selected',
218
+ message: 'Optional companion components available:',
219
+ choices: choices.map(c => ({
220
+ title: c.name + ' ' + chalk.dim('- ' + c.description + ' (for ' + c.requestedBy + ')'),
221
+ value: c.name,
222
+ })),
223
+ hint: '- Space to select, Enter to confirm (or press Enter to skip)',
224
+ });
225
+
226
+ return selected || [];
227
+ }
228
+
229
+ // ---------------------------------------------------------------------------
230
+ // Conflict detection
231
+ // ---------------------------------------------------------------------------
232
+
233
+ async function checkFileConflict(
234
+ file: string,
235
+ targetDir: string,
236
+ options: AddOptions,
237
+ utilsAlias: string,
238
+ contentCache: Map<string, string>,
239
+ ): Promise<'identical' | 'changed' | 'missing'> {
240
+ const targetPath = path.join(targetDir, file);
241
+
242
+ if (!await fs.pathExists(targetPath)) {
243
+ return 'missing';
244
+ }
245
+
246
+ const localContent = await fs.readFile(targetPath, 'utf-8');
247
+ try {
248
+ const remoteContent = await fetchAndTransform(file, options, utilsAlias);
249
+ contentCache.set(file, remoteContent);
250
+
251
+ return normalizeContent(localContent) === normalizeContent(remoteContent)
252
+ ? 'identical'
253
+ : 'changed';
254
+ } catch {
255
+ return 'changed';
256
+ }
257
+ }
258
+
259
+ async function checkPeerFiles(
260
+ component: ComponentDefinition,
261
+ targetDir: string,
262
+ options: AddOptions,
263
+ utilsAlias: string,
264
+ contentCache: Map<string, string>,
265
+ peerFilesToUpdate: Set<string>,
266
+ ): Promise<boolean> {
267
+ if (!component.peerFiles) return false;
268
+
269
+ let hasChanges = false;
270
+ for (const file of component.peerFiles) {
271
+ const status = await checkFileConflict(file, targetDir, options, utilsAlias, contentCache);
272
+ if (status === 'changed') {
273
+ hasChanges = true;
274
+ peerFilesToUpdate.add(file);
275
+ }
276
+ }
277
+ return hasChanges;
278
+ }
279
+
280
+ async function classifyComponent(
281
+ name: ComponentName,
282
+ targetDir: string,
283
+ options: AddOptions,
284
+ utilsAlias: string,
285
+ contentCache: Map<string, string>,
286
+ peerFilesToUpdate: Set<string>,
287
+ ): Promise<'install' | 'skip' | 'conflict'> {
288
+ const component = registry[name];
289
+ let hasChanges = false;
290
+ let isFullyPresent = true;
291
+
292
+ for (const file of component.files) {
293
+ const status = await checkFileConflict(file, targetDir, options, utilsAlias, contentCache);
294
+ if (status === 'missing') isFullyPresent = false;
295
+ if (status === 'changed') hasChanges = true;
296
+ }
297
+
298
+ const peerChanged = await checkPeerFiles(
299
+ component, targetDir, options, utilsAlias, contentCache, peerFilesToUpdate,
300
+ );
301
+ if (peerChanged) hasChanges = true;
190
302
 
191
- // Check for existing files and diff
192
- const componentsToInstall: ComponentName[] = [];
193
- const componentsToSkip: string[] = [];
194
- const conflictingComponents: ComponentName[] = [];
303
+ if (isFullyPresent && !hasChanges) return 'skip';
304
+ if (hasChanges) return 'conflict';
305
+ return 'install';
306
+ }
307
+
308
+ async function detectConflicts(
309
+ allComponents: Set<ComponentName>,
310
+ targetDir: string,
311
+ options: AddOptions,
312
+ utilsAlias: string,
313
+ ): Promise<ConflictCheckResult> {
314
+ const toInstall: ComponentName[] = [];
315
+ const toSkip: string[] = [];
316
+ const conflicting: ComponentName[] = [];
317
+ const peerFilesToUpdate = new Set<string>();
195
318
  const contentCache = new Map<string, string>();
196
319
 
197
- const checkSpinner = ora('Checking for conflicts...').start();
320
+ for (const name of allComponents) {
321
+ const result = await classifyComponent(
322
+ name, targetDir, options, utilsAlias, contentCache, peerFilesToUpdate,
323
+ );
324
+
325
+ if (result === 'skip') toSkip.push(name);
326
+ else if (result === 'conflict') conflicting.push(name);
327
+ else toInstall.push(name);
328
+ }
198
329
 
330
+ return { toInstall, toSkip, conflicting, peerFilesToUpdate, contentCache };
331
+ }
332
+
333
+ // ---------------------------------------------------------------------------
334
+ // Overwrite prompt
335
+ // ---------------------------------------------------------------------------
336
+
337
+ async function promptOverwrite(
338
+ conflicting: ComponentName[],
339
+ options: AddOptions,
340
+ ): Promise<ComponentName[]> {
341
+ if (conflicting.length === 0) return [];
342
+
343
+ if (options.overwrite) return conflicting;
344
+ if (options.yes) return [];
345
+
346
+ console.log(chalk.yellow(`\n${conflicting.length} component(s) have local changes or are different from remote.`));
347
+ const { selected } = await prompts({
348
+ type: 'multiselect',
349
+ name: 'selected',
350
+ message: 'Select components to OVERWRITE (Unselected will be skipped):',
351
+ choices: conflicting.map(name => ({ title: name, value: name })),
352
+ hint: '- Space to select, Enter to confirm',
353
+ });
354
+ return selected || [];
355
+ }
356
+
357
+ // ---------------------------------------------------------------------------
358
+ // File writing
359
+ // ---------------------------------------------------------------------------
360
+
361
+ async function writeComponentFiles(
362
+ component: ComponentDefinition,
363
+ targetDir: string,
364
+ options: AddOptions,
365
+ utilsAlias: string,
366
+ contentCache: Map<string, string>,
367
+ spinner: Ora,
368
+ ): Promise<boolean> {
369
+ let success = true;
370
+
371
+ for (const file of component.files) {
372
+ const targetPath = path.join(targetDir, file);
373
+ try {
374
+ const content = contentCache.get(file)
375
+ ?? await fetchAndTransform(file, options, utilsAlias);
376
+
377
+ await fs.ensureDir(path.dirname(targetPath));
378
+ await fs.writeFile(targetPath, content);
379
+ } catch (err: unknown) {
380
+ const message = err instanceof Error ? err.message : String(err);
381
+ spinner.warn(`Could not add ${file}: ${message}`);
382
+ success = false;
383
+ }
384
+ }
385
+
386
+ return success;
387
+ }
388
+
389
+ async function writePeerFiles(
390
+ component: ComponentDefinition,
391
+ targetDir: string,
392
+ options: AddOptions,
393
+ utilsAlias: string,
394
+ contentCache: Map<string, string>,
395
+ peerFilesToUpdate: Set<string>,
396
+ spinner: Ora,
397
+ ): Promise<void> {
398
+ if (!component.peerFiles) return;
399
+
400
+ for (const file of component.peerFiles) {
401
+ if (!peerFilesToUpdate.has(file)) continue;
402
+
403
+ const targetPath = path.join(targetDir, file);
404
+ try {
405
+ const content = contentCache.get(file)
406
+ ?? await fetchAndTransform(file, options, utilsAlias);
407
+
408
+ await fs.writeFile(targetPath, content);
409
+ spinner.text = `Updated peer file ${file}`;
410
+ } catch (err: unknown) {
411
+ const message = err instanceof Error ? err.message : String(err);
412
+ spinner.warn(`Could not update peer file ${file}: ${message}`);
413
+ }
414
+ }
415
+ }
416
+
417
+ async function installLibFiles(
418
+ allComponents: Set<ComponentName>,
419
+ cwd: string,
420
+ libDir: string,
421
+ options: AddOptions,
422
+ ): Promise<void> {
423
+ const required = new Set<string>();
199
424
  for (const name of allComponents) {
200
- const component = registry[name];
201
- let hasChanges = false;
202
- let isFullyPresent = true;
203
-
204
- for (const file of component.files) {
205
- const targetPath = path.join(targetDir, file);
206
- if (await fs.pathExists(targetPath)) {
207
- const localContent = await fs.readFile(targetPath, 'utf-8');
208
-
209
- try {
210
- let remoteContent = await fetchComponentContent(file, options);
211
- // Transform all lib/ imports for comparison
212
- remoteContent = remoteContent.replace(/(\.\.\/)+lib\//g, config.aliases.utils + '/');
213
-
214
- const normalize = (str: string) => str.replace(/\r\n/g, '\n').trim();
215
- if (normalize(localContent) !== normalize(remoteContent)) {
216
- hasChanges = true;
217
- }
218
- contentCache.set(file, remoteContent); // Cache for installation
219
- } catch (error) {
220
- // unexpected error fetching remote
221
- console.warn(`Could not fetch remote content for comparison: ${file}`);
222
- hasChanges = true; // Assume changed/unknown
223
- }
224
- } else {
225
- isFullyPresent = false;
425
+ registry[name].libFiles?.forEach(f => required.add(f));
426
+ }
427
+
428
+ if (required.size === 0) return;
429
+
430
+ await fs.ensureDir(libDir);
431
+ for (const libFile of required) {
432
+ const targetPath = path.join(libDir, libFile);
433
+ if (!await fs.pathExists(targetPath) || options.overwrite) {
434
+ try {
435
+ const content = await fetchLibContent(libFile, options);
436
+ await fs.writeFile(targetPath, content);
437
+ } catch (err: unknown) {
438
+ const message = err instanceof Error ? err.message : String(err);
439
+ console.warn(chalk.yellow(`Could not install lib file ${libFile}: ${message}`));
226
440
  }
227
441
  }
442
+ }
443
+ }
228
444
 
229
- if (isFullyPresent && !hasChanges) {
230
- componentsToSkip.push(name);
231
- } else if (hasChanges) {
232
- conflictingComponents.push(name);
233
- } else {
234
- componentsToInstall.push(name);
235
- }
445
+ async function installNpmDependencies(
446
+ finalComponents: ComponentName[],
447
+ cwd: string,
448
+ ): Promise<void> {
449
+ const deps = new Set<string>();
450
+ for (const name of finalComponents) {
451
+ registry[name].npmDependencies?.forEach(dep => deps.add(dep));
236
452
  }
237
453
 
238
- checkSpinner.stop();
454
+ if (deps.size === 0) return;
239
455
 
240
- let componentsToOverwrite: ComponentName[] = [];
456
+ const spinner = ora('Installing dependencies...').start();
457
+ try {
458
+ await installPackages(Array.from(deps), { cwd });
459
+ spinner.succeed('Dependencies installed.');
460
+ } catch (e) {
461
+ spinner.fail('Failed to install dependencies.');
462
+ console.error(e);
463
+ }
464
+ }
241
465
 
242
- if (conflictingComponents.length > 0) {
243
- if (options.overwrite) {
244
- componentsToOverwrite = conflictingComponents;
245
- } else if (options.yes) {
246
- componentsToOverwrite = []; // Skip conflicts in non-interactive mode unless --overwrite
247
- } else {
248
- console.log(chalk.yellow(`\n${conflictingComponents.length} component(s) have local changes or are different from remote.`));
249
- const { selected } = await prompts({
250
- type: 'multiselect',
251
- name: 'selected',
252
- message: 'Select components to OVERWRITE (Unselected will be skipped):',
253
- choices: conflictingComponents.map(name => ({
254
- title: name,
255
- value: name,
256
- })),
257
- hint: '- Space to select, Enter to confirm',
258
- });
259
- componentsToOverwrite = selected || [];
466
+ async function ensureShortcutService(
467
+ targetDir: string,
468
+ cwd: string,
469
+ config: Config,
470
+ options: AddOptions,
471
+ ): Promise<void> {
472
+ const entries = collectInstalledShortcutEntries(targetDir);
473
+
474
+ if (entries.length > 0) {
475
+ const libDir = resolveProjectPath(cwd, aliasToProjectPath(config.aliases.utils));
476
+ const servicePath = path.join(libDir, 'shortcut-binding.service.ts');
477
+ if (!await fs.pathExists(servicePath)) {
478
+ const content = await fetchLibContent('shortcut-binding.service.ts', options);
479
+ await fs.ensureDir(libDir);
480
+ await fs.writeFile(servicePath, content);
260
481
  }
261
482
  }
262
483
 
263
- // Final list of components to process
264
- // We process:
265
- // 1. componentsToInstall (Brand new or partial)
266
- // 2. componentsToOverwrite (User selected)
267
- // We SKIP:
268
- // 1. componentsToSkip (Identical)
269
- // 2. conflictingComponents NOT in componentsToOverwrite
484
+ await writeShortcutRegistryIndex(cwd, config, entries);
485
+ }
486
+
487
+ function collectInstalledShortcutEntries(targetDir: string): ShortcutRegistryEntry[] {
488
+ const entries: ShortcutRegistryEntry[] = [];
489
+ for (const definition of Object.values(registry)) {
490
+ if (!definition.shortcutDefinitions?.length) continue;
491
+ for (const sd of definition.shortcutDefinitions) {
492
+ if (fs.existsSync(path.join(targetDir, sd.sourceFile))) {
493
+ entries.push(sd);
494
+ }
495
+ }
496
+ }
497
+ return entries;
498
+ }
499
+
500
+ // ---------------------------------------------------------------------------
501
+ // Main entry point
502
+ // ---------------------------------------------------------------------------
270
503
 
271
- const finalComponents = [...componentsToInstall, ...componentsToOverwrite];
504
+ export async function add(components: string[], options: AddOptions) {
505
+ const cwd = process.cwd();
272
506
 
273
- if (finalComponents.length === 0 && componentsToSkip.length > 0) {
274
- console.log(chalk.green(`\nAll components are up to date! (${componentsToSkip.length} skipped)`));
507
+ const config = await getConfig(cwd);
508
+ if (!config) {
509
+ console.log(chalk.red('Error: components.json not found.'));
510
+ console.log(chalk.dim('Run `npx @gilav21/shadcn-angular init` first.'));
511
+ process.exit(1);
512
+ }
513
+
514
+ const componentsToAdd = await selectComponents(components, options);
515
+ if (!componentsToAdd || componentsToAdd.length === 0) {
516
+ console.log(chalk.dim('No components selected.'));
275
517
  return;
276
518
  }
277
519
 
520
+ validateComponents(componentsToAdd);
521
+
522
+ const resolvedComponents = resolveDependencies(componentsToAdd);
523
+ const optionalChoices = await promptOptionalDependencies(resolvedComponents, options);
524
+ const allComponents = optionalChoices.length > 0
525
+ ? resolveDependencies([...resolvedComponents, ...optionalChoices])
526
+ : resolvedComponents;
527
+ const uiBasePath = options.path ?? aliasToProjectPath(config.aliases.ui || 'src/components/ui');
528
+ const targetDir = resolveProjectPath(cwd, uiBasePath);
529
+ const utilsAlias = config.aliases.utils;
530
+
531
+ // Detect conflicts
532
+ const checkSpinner = ora('Checking for conflicts...').start();
533
+ const { toInstall, toSkip, conflicting, peerFilesToUpdate, contentCache } =
534
+ await detectConflicts(allComponents, targetDir, options, utilsAlias);
535
+ checkSpinner.stop();
536
+
537
+ // Prompt user for overwrite decisions
538
+ const toOverwrite = await promptOverwrite(conflicting, options);
539
+ const finalComponents = [...toInstall, ...toOverwrite];
540
+
278
541
  if (finalComponents.length === 0) {
279
- console.log(chalk.dim('\nNo components to install.'));
542
+ if (toSkip.length > 0) {
543
+ console.log(chalk.green(`\nAll components are up to date! (${toSkip.length} skipped)`));
544
+ } else {
545
+ console.log(chalk.dim('\nNo components to install.'));
546
+ }
280
547
  return;
281
548
  }
282
549
 
550
+ // Install component files
283
551
  const spinner = ora('Installing components...').start();
284
552
  let successCount = 0;
285
553
 
@@ -288,28 +556,15 @@ export async function add(components: string[], options: AddOptions) {
288
556
 
289
557
  for (const name of finalComponents) {
290
558
  const component = registry[name];
291
- let componentSuccess = true;
292
-
293
- for (const file of component.files) {
294
- const targetPath = path.join(targetDir, file);
295
-
296
- try {
297
- let content = contentCache.get(file);
298
- if (!content) {
299
- content = await fetchComponentContent(file, options);
300
- // Transform all lib/ imports if not already transformed (cached is transformed)
301
- content = content.replace(/(\.\.\/)+lib\//g, config.aliases.utils + '/');
302
- }
303
-
304
- await fs.ensureDir(path.dirname(targetPath));
305
- await fs.writeFile(targetPath, content);
306
- // spinner.text = `Added ${file}`; // Too verbose?
307
- } catch (err: any) {
308
- spinner.warn(`Could not add ${file}: ${err.message}`);
309
- componentSuccess = false;
310
- }
311
- }
312
- if (componentSuccess) {
559
+
560
+ const ok = await writeComponentFiles(
561
+ component, targetDir, options, utilsAlias, contentCache, spinner,
562
+ );
563
+ await writePeerFiles(
564
+ component, targetDir, options, utilsAlias, contentCache, peerFilesToUpdate, spinner,
565
+ );
566
+
567
+ if (ok) {
313
568
  successCount++;
314
569
  spinner.text = `Added ${name}`;
315
570
  }
@@ -317,87 +572,24 @@ export async function add(components: string[], options: AddOptions) {
317
572
 
318
573
  if (successCount > 0) {
319
574
  spinner.succeed(chalk.green(`Success! Added ${successCount} component(s)`));
320
-
321
575
  console.log('\n' + chalk.dim('Components added:'));
322
- finalComponents.forEach(name => {
323
- console.log(chalk.dim(' - ') + chalk.cyan(name));
324
- });
576
+ finalComponents.forEach(name => console.log(chalk.dim(' - ') + chalk.cyan(name)));
325
577
  } else {
326
578
  spinner.info('No new components installed.');
327
579
  }
328
580
 
329
- // Install required lib utility files
330
- if (finalComponents.length > 0) {
331
- const requiredLibFiles = new Set<string>();
332
- for (const name of allComponents) {
333
- const component = registry[name];
334
- if (component.libFiles) {
335
- component.libFiles.forEach(f => requiredLibFiles.add(f));
336
- }
337
- }
338
-
339
- if (requiredLibFiles.size > 0) {
340
- const libDir = resolveProjectPath(cwd, aliasToProjectPath(config.aliases.utils));
341
- await fs.ensureDir(libDir);
342
-
343
- for (const libFile of requiredLibFiles) {
344
- const libTargetPath = path.join(libDir, libFile);
345
- if (!await fs.pathExists(libTargetPath) || options.overwrite) {
346
- try {
347
- const libContent = await fetchLibContent(libFile, options);
348
- await fs.writeFile(libTargetPath, libContent);
349
- } catch (err: any) {
350
- console.warn(chalk.yellow(`Could not install lib file ${libFile}: ${err.message}`));
351
- }
352
- }
353
- }
354
- }
355
- }
356
-
357
- if (finalComponents.length > 0) {
358
- const npmDependencies = new Set<string>();
359
- for (const name of finalComponents) {
360
- const component = registry[name];
361
- if (component.npmDependencies) {
362
- component.npmDependencies.forEach(dep => npmDependencies.add(dep));
363
- }
364
- }
365
-
366
- if (npmDependencies.size > 0) {
367
- const depSpinner = ora('Installing dependencies...').start();
368
- try {
369
- await installPackages(Array.from(npmDependencies), { cwd });
370
- depSpinner.succeed('Dependencies installed.');
371
- } catch (e) {
372
- depSpinner.fail('Failed to install dependencies.');
373
- console.error(e);
374
- }
375
- }
376
- }
377
-
378
- const shortcutEntries = collectInstalledShortcutEntries(targetDir);
379
- if (shortcutEntries.length > 0) {
380
- const libDir2 = resolveProjectPath(cwd, aliasToProjectPath(config.aliases.utils));
381
- const shortcutServicePath = path.join(libDir2, 'shortcut-binding.service.ts');
581
+ // Post-install: lib files, npm deps, shortcuts
582
+ const libDir = resolveProjectPath(cwd, aliasToProjectPath(utilsAlias));
583
+ await installLibFiles(allComponents, cwd, libDir, options);
584
+ await installNpmDependencies(finalComponents, cwd);
585
+ await ensureShortcutService(targetDir, cwd, config, options);
382
586
 
383
- if (!await fs.pathExists(shortcutServicePath)) {
384
- const shortcutServiceContent = await fetchLibContent('shortcut-binding.service.ts', options);
385
- await fs.ensureDir(libDir2);
386
- await fs.writeFile(shortcutServicePath, shortcutServiceContent);
387
- }
388
- }
389
-
390
- await writeShortcutRegistryIndex(cwd, config, shortcutEntries);
391
-
392
- if (componentsToSkip.length > 0) {
587
+ if (toSkip.length > 0) {
393
588
  console.log('\n' + chalk.dim('Components skipped (up to date):'));
394
- componentsToSkip.forEach(name => {
395
- console.log(chalk.dim(' - ') + chalk.gray(name));
396
- });
589
+ toSkip.forEach(name => console.log(chalk.dim(' - ') + chalk.gray(name)));
397
590
  }
398
591
 
399
592
  console.log('');
400
-
401
593
  } catch (error) {
402
594
  spinner.fail('Failed to add components');
403
595
  console.error(error);