@gilav21/shadcn-angular 0.0.25 → 0.0.26

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,16 +1,25 @@
1
1
  import fs from 'fs-extra';
2
2
  import path from 'node:path';
3
- import { fileURLToPath } from 'node:url';
4
3
  import prompts from 'prompts';
5
4
  import chalk from 'chalk';
6
5
  import ora, { type Ora } from 'ora';
7
6
  import { getConfig, type Config } from '../utils/config.js';
8
- import { registry, type ComponentDefinition, type ComponentName } from '../registry/index.js';
7
+ import { registry, getComponentNames, type ComponentDefinition, type ComponentName } from '../registry/index.js';
9
8
  import { installPackages } from '../utils/package-manager.js';
10
9
  import { writeShortcutRegistryIndex, type ShortcutRegistryEntry } from '../utils/shortcut-registry.js';
11
-
12
- const __filename = fileURLToPath(import.meta.url);
13
- const __dirname = path.dirname(__filename);
10
+ import {
11
+ getRegistryBaseUrl,
12
+ getLibRegistryBaseUrl,
13
+ getLocalComponentsDir,
14
+ getLocalLibDir,
15
+ resolveProjectPath,
16
+ aliasToProjectPath,
17
+ } from '../utils/paths.js';
18
+
19
+ const onCancel = () => {
20
+ console.log(chalk.dim('\nCancelled.'));
21
+ process.exit(0);
22
+ };
14
23
 
15
24
  export interface AddOptions {
16
25
  yes?: boolean;
@@ -18,7 +27,9 @@ export interface AddOptions {
18
27
  all?: boolean;
19
28
  path?: string;
20
29
  remote?: boolean;
30
+ dryRun?: boolean;
21
31
  branch: string;
32
+ registry?: string;
22
33
  }
23
34
 
24
35
  interface ConflictCheckResult {
@@ -29,54 +40,6 @@ interface ConflictCheckResult {
29
40
  contentCache: Map<string, string>;
30
41
  }
31
42
 
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
-
42
- function getRegistryBaseUrl(branch: string) {
43
- validateBranch(branch);
44
- return `https://raw.githubusercontent.com/gilav21/shadcn-angular/${branch}/packages/components/ui`;
45
- }
46
-
47
- function getLibRegistryBaseUrl(branch: string) {
48
- validateBranch(branch);
49
- return `https://raw.githubusercontent.com/gilav21/shadcn-angular/${branch}/packages/components/lib`;
50
- }
51
-
52
- function getLocalComponentsDir(): string | null {
53
- const localPath = path.resolve(__dirname, '../../../components/ui');
54
- return fs.existsSync(localPath) ? localPath : null;
55
- }
56
-
57
- function getLocalLibDir(): string | null {
58
- const fromDist = path.resolve(__dirname, '../../../components/lib');
59
- if (fs.existsSync(fromDist)) {
60
- return fromDist;
61
- }
62
- return null;
63
- }
64
-
65
- function resolveProjectPath(cwd: string, inputPath: string): string {
66
- const resolved = path.resolve(cwd, inputPath);
67
- const relative = path.relative(cwd, resolved);
68
- if (relative.startsWith('..') || path.isAbsolute(relative)) {
69
- throw new Error(`Path must stay inside the project directory: ${inputPath}`);
70
- }
71
- return resolved;
72
- }
73
-
74
- function aliasToProjectPath(aliasOrPath: string): string {
75
- return aliasOrPath.startsWith('@/')
76
- ? path.join('src', aliasOrPath.slice(2))
77
- : aliasOrPath;
78
- }
79
-
80
43
  export function normalizeContent(str: string): string {
81
44
  return str.replaceAll('\r\n', '\n').trim();
82
45
  }
@@ -95,7 +58,7 @@ async function fetchComponentContent(file: string, options: AddOptions): Promise
95
58
  }
96
59
  }
97
60
 
98
- const url = `${getRegistryBaseUrl(options.branch)}/${file}`;
61
+ const url = `${getRegistryBaseUrl(options.branch, options.registry)}/${file}`;
99
62
  try {
100
63
  const response = await fetch(url);
101
64
  if (!response.ok) {
@@ -120,7 +83,7 @@ async function fetchLibContent(file: string, options: AddOptions): Promise<strin
120
83
  }
121
84
  }
122
85
 
123
- const url = `${getLibRegistryBaseUrl(options.branch)}/${file}`;
86
+ const url = `${getLibRegistryBaseUrl(options.branch, options.registry)}/${file}`;
124
87
  const response = await fetch(url);
125
88
  if (!response.ok) {
126
89
  throw new Error(`Failed to fetch library file from ${url}: ${response.statusText}`);
@@ -140,7 +103,7 @@ export async function fetchAndTransform(file: string, options: AddOptions, utils
140
103
 
141
104
  async function selectComponents(components: string[], options: AddOptions): Promise<ComponentName[]> {
142
105
  if (options.all) {
143
- return Object.keys(registry);
106
+ return getComponentNames();
144
107
  }
145
108
 
146
109
  if (components.length === 0) {
@@ -148,23 +111,23 @@ async function selectComponents(components: string[], options: AddOptions): Prom
148
111
  type: 'multiselect',
149
112
  name: 'selected',
150
113
  message: 'Which components would you like to add?',
151
- choices: Object.keys(registry).map(name => ({
114
+ choices: getComponentNames().map(name => ({
152
115
  title: name,
153
116
  value: name,
154
117
  })),
155
118
  hint: '- Space to select, Enter to confirm',
156
- });
119
+ }, { onCancel });
157
120
  return selected;
158
121
  }
159
122
 
160
- return components;
123
+ return components as ComponentName[];
161
124
  }
162
125
 
163
126
  function validateComponents(names: ComponentName[]): void {
164
- const invalid = names.filter(c => !registry[c]);
127
+ const invalid = names.filter(c => !(c in registry));
165
128
  if (invalid.length > 0) {
166
129
  console.log(chalk.red(`Invalid component(s): ${invalid.join(', ')}`));
167
- console.log(chalk.dim('Available components: ' + Object.keys(registry).join(', ')));
130
+ console.log(chalk.dim('Available components: ' + getComponentNames().join(', ')));
168
131
  process.exit(1);
169
132
  }
170
133
  }
@@ -174,9 +137,11 @@ export function resolveDependencies(names: ComponentName[]): Set<ComponentName>
174
137
  const walk = (name: ComponentName) => {
175
138
  if (all.has(name)) return;
176
139
  all.add(name);
177
- registry[name].dependencies?.forEach(dep => walk(dep));
140
+ for (const dep of registry[name].dependencies ?? []) {
141
+ walk(dep as ComponentName);
142
+ }
178
143
  };
179
- names.forEach(walk);
144
+ for (const name of names) walk(name);
180
145
  return all;
181
146
  }
182
147
 
@@ -202,7 +167,7 @@ export async function promptOptionalDependencies(
202
167
  if (!component.optionalDependencies) continue;
203
168
 
204
169
  for (const opt of component.optionalDependencies) {
205
- if (resolved.has(opt.name) || seen.has(opt.name)) continue;
170
+ if (resolved.has(opt.name as ComponentName) || seen.has(opt.name)) continue;
206
171
  seen.add(opt.name);
207
172
  choices.push({ name: opt.name, description: opt.description, requestedBy: name });
208
173
  }
@@ -210,7 +175,7 @@ export async function promptOptionalDependencies(
210
175
 
211
176
  if (choices.length === 0) return [];
212
177
  if (options.yes) return [];
213
- if (options.all) return choices.map(c => c.name);
178
+ if (options.all) return choices.map(c => c.name as ComponentName);
214
179
 
215
180
  const { selected } = await prompts({
216
181
  type: 'multiselect',
@@ -221,7 +186,7 @@ export async function promptOptionalDependencies(
221
186
  value: c.name,
222
187
  })),
223
188
  hint: '- Space to select, Enter to confirm (or press Enter to skip)',
224
- });
189
+ }, { onCancel });
225
190
 
226
191
  return selected || [];
227
192
  }
@@ -296,11 +261,32 @@ export async function classifyComponent(
296
261
  component, targetDir, options, utilsAlias, contentCache, peerFilesToUpdate,
297
262
  );
298
263
 
264
+ if (options.overwrite) return isFullyPresent ? 'conflict' : 'install';
299
265
  if (isFullyPresent && !ownFilesChanged) return 'skip';
300
266
  if (ownFilesChanged) return 'conflict';
301
267
  return 'install';
302
268
  }
303
269
 
270
+ class ConcurrencyLimiter {
271
+ private active = 0;
272
+ private readonly queue: Array<() => void> = [];
273
+
274
+ constructor(private readonly concurrency: number) {}
275
+
276
+ async run<T>(fn: () => Promise<T>): Promise<T> {
277
+ if (this.active >= this.concurrency) {
278
+ await new Promise<void>(resolve => this.queue.push(resolve));
279
+ }
280
+ this.active++;
281
+ try {
282
+ return await fn();
283
+ } finally {
284
+ this.active--;
285
+ if (this.queue.length > 0) this.queue.shift()!();
286
+ }
287
+ }
288
+ }
289
+
304
290
  export async function detectConflicts(
305
291
  allComponents: Set<ComponentName>,
306
292
  targetDir: string,
@@ -313,11 +299,20 @@ export async function detectConflicts(
313
299
  const peerFilesToUpdate = new Set<string>();
314
300
  const contentCache = new Map<string, string>();
315
301
 
316
- for (const name of allComponents) {
317
- const result = await classifyComponent(
318
- name, targetDir, options, utilsAlias, contentCache, peerFilesToUpdate,
319
- );
302
+ const limiter = new ConcurrencyLimiter(8);
320
303
 
304
+ const results = await Promise.all(
305
+ [...allComponents].map(name =>
306
+ limiter.run(async () => ({
307
+ name,
308
+ result: await classifyComponent(
309
+ name, targetDir, options, utilsAlias, contentCache, peerFilesToUpdate,
310
+ ),
311
+ })),
312
+ ),
313
+ );
314
+
315
+ for (const { name, result } of results) {
321
316
  if (result === 'skip') toSkip.push(name);
322
317
  else if (result === 'conflict') conflicting.push(name);
323
318
  else toInstall.push(name);
@@ -330,23 +325,54 @@ export async function detectConflicts(
330
325
  // Overwrite prompt
331
326
  // ---------------------------------------------------------------------------
332
327
 
328
+ function showConflictDiffs(
329
+ conflicting: ComponentName[],
330
+ targetDir: string,
331
+ contentCache: Map<string, string>,
332
+ ): void {
333
+ for (const name of conflicting) {
334
+ const component = registry[name];
335
+ const changedFiles: string[] = [];
336
+
337
+ for (const file of component.files) {
338
+ const remote = contentCache.get(file);
339
+ if (!remote) continue;
340
+ const localPath = path.join(targetDir, file);
341
+ if (!fs.existsSync(localPath)) continue;
342
+
343
+ const local = normalizeContent(fs.readFileSync(localPath, 'utf-8'));
344
+ if (local !== normalizeContent(remote)) {
345
+ changedFiles.push(file);
346
+ }
347
+ }
348
+
349
+ if (changedFiles.length > 0) {
350
+ console.log(chalk.dim(` ${name}: `) + chalk.yellow(changedFiles.join(', ')));
351
+ }
352
+ }
353
+ }
354
+
333
355
  async function promptOverwrite(
334
356
  conflicting: ComponentName[],
335
357
  options: AddOptions,
358
+ targetDir: string,
359
+ contentCache: Map<string, string>,
336
360
  ): Promise<ComponentName[]> {
337
361
  if (conflicting.length === 0) return [];
338
362
 
339
- if (options.overwrite) return conflicting;
340
- if (options.yes) return [];
363
+ if (options.overwrite || options.yes) return conflicting;
364
+
365
+ console.log(chalk.yellow(`\n${conflicting.length} component(s) have local changes or are different from remote:`));
366
+ showConflictDiffs(conflicting, targetDir, contentCache);
367
+ console.log(chalk.dim('\n Use `npx shadcn-angular diff <component>` for full diffs.\n'));
341
368
 
342
- console.log(chalk.yellow(`\n${conflicting.length} component(s) have local changes or are different from remote.`));
343
369
  const { selected } = await prompts({
344
370
  type: 'multiselect',
345
371
  name: 'selected',
346
372
  message: 'Select components to OVERWRITE (Unselected will be skipped):',
347
373
  choices: conflicting.map(name => ({ title: name, value: name })),
348
374
  hint: '- Space to select, Enter to confirm',
349
- });
375
+ }, { onCancel });
350
376
  return selected || [];
351
377
  }
352
378
 
@@ -410,6 +436,25 @@ async function writePeerFiles(
410
436
  }
411
437
  }
412
438
 
439
+ async function installSingleLibFile(
440
+ libFile: string,
441
+ libDir: string,
442
+ options: AddOptions,
443
+ ): Promise<void> {
444
+ const targetPath = path.join(libDir, libFile);
445
+ const content = await fetchLibContent(libFile, options);
446
+
447
+ if (!await fs.pathExists(targetPath) || options.overwrite) {
448
+ await fs.writeFile(targetPath, content);
449
+ return;
450
+ }
451
+
452
+ const local = normalizeContent(await fs.readFile(targetPath, 'utf-8'));
453
+ if (local !== normalizeContent(content)) {
454
+ console.log(chalk.yellow(` Lib file ${libFile} differs from remote (use --overwrite to update)`));
455
+ }
456
+ }
457
+
413
458
  async function installLibFiles(
414
459
  allComponents: Set<ComponentName>,
415
460
  cwd: string,
@@ -418,22 +463,18 @@ async function installLibFiles(
418
463
  ): Promise<void> {
419
464
  const required = new Set<string>();
420
465
  for (const name of allComponents) {
421
- registry[name].libFiles?.forEach(f => required.add(f));
466
+ for (const f of registry[name].libFiles ?? []) required.add(f);
422
467
  }
423
468
 
424
469
  if (required.size === 0) return;
425
470
 
426
471
  await fs.ensureDir(libDir);
427
472
  for (const libFile of required) {
428
- const targetPath = path.join(libDir, libFile);
429
- if (!await fs.pathExists(targetPath) || options.overwrite) {
430
- try {
431
- const content = await fetchLibContent(libFile, options);
432
- await fs.writeFile(targetPath, content);
433
- } catch (err: unknown) {
434
- const message = err instanceof Error ? err.message : String(err);
435
- console.warn(chalk.yellow(`Could not install lib file ${libFile}: ${message}`));
436
- }
473
+ try {
474
+ await installSingleLibFile(libFile, libDir, options);
475
+ } catch (err: unknown) {
476
+ const message = err instanceof Error ? err.message : String(err);
477
+ console.warn(chalk.yellow(`Could not install lib file ${libFile}: ${message}`));
437
478
  }
438
479
  }
439
480
  }
@@ -444,7 +485,7 @@ async function installNpmDependencies(
444
485
  ): Promise<void> {
445
486
  const deps = new Set<string>();
446
487
  for (const name of finalComponents) {
447
- registry[name].npmDependencies?.forEach(dep => deps.add(dep));
488
+ for (const dep of registry[name].npmDependencies ?? []) deps.add(dep);
448
489
  }
449
490
 
450
491
  if (deps.size === 0) return;
@@ -453,9 +494,13 @@ async function installNpmDependencies(
453
494
  try {
454
495
  await installPackages(Array.from(deps), { cwd });
455
496
  spinner.succeed('Dependencies installed.');
456
- } catch (e) {
497
+ } catch (e: unknown) {
457
498
  spinner.fail('Failed to install dependencies.');
458
- console.error(e);
499
+ if (e && typeof e === 'object' && 'stderr' in e && typeof e.stderr === 'string') {
500
+ console.error(chalk.red(e.stderr));
501
+ } else {
502
+ console.error(e);
503
+ }
459
504
  }
460
505
  }
461
506
 
@@ -493,6 +538,67 @@ function collectInstalledShortcutEntries(targetDir: string): ShortcutRegistryEnt
493
538
  return entries;
494
539
  }
495
540
 
541
+ // ---------------------------------------------------------------------------
542
+ // Peer file & summary helpers
543
+ // ---------------------------------------------------------------------------
544
+
545
+ function pruneDeclinedPeerFiles(
546
+ declined: ComponentName[],
547
+ finalComponents: ComponentName[],
548
+ peerFilesToUpdate: Set<string>,
549
+ ): void {
550
+ for (const name of declined) {
551
+ const component = registry[name];
552
+ if (!component.peerFiles) continue;
553
+ for (const file of component.peerFiles) {
554
+ const stillNeeded = finalComponents.some(fc =>
555
+ registry[fc].peerFiles?.includes(file),
556
+ );
557
+ if (!stillNeeded) {
558
+ peerFilesToUpdate.delete(file);
559
+ }
560
+ }
561
+ }
562
+ }
563
+
564
+ function printNothingToInstall(toSkip: string[], declined: ComponentName[]): void {
565
+ if (toSkip.length > 0 || declined.length > 0) {
566
+ printSkipSummary(toSkip, declined);
567
+ } else {
568
+ console.log(chalk.dim('\nNo components to install.'));
569
+ }
570
+ }
571
+
572
+ function printDryRunSummary(
573
+ toInstall: ComponentName[],
574
+ toOverwrite: ComponentName[],
575
+ toSkip: string[],
576
+ declined: ComponentName[],
577
+ ): void {
578
+ console.log(chalk.bold('\n[Dry Run] No changes will be made.\n'));
579
+ if (toInstall.length > 0) {
580
+ console.log(chalk.green(` Would install ${toInstall.length} component(s):`));
581
+ for (const name of toInstall) console.log(chalk.dim(' + ') + chalk.cyan(name));
582
+ }
583
+ if (toOverwrite.length > 0) {
584
+ console.log(chalk.yellow(` Would overwrite ${toOverwrite.length} component(s):`));
585
+ for (const name of toOverwrite) console.log(chalk.dim(' ~ ') + chalk.yellow(name));
586
+ }
587
+ printSkipSummary(toSkip, declined);
588
+ console.log('');
589
+ }
590
+
591
+ function printSkipSummary(toSkip: string[], declined: ComponentName[]): void {
592
+ if (toSkip.length > 0) {
593
+ console.log('\n' + chalk.dim('Components skipped (up to date):'));
594
+ for (const name of toSkip) console.log(chalk.dim(' - ') + chalk.gray(name));
595
+ }
596
+ if (declined.length > 0) {
597
+ console.log('\n' + chalk.dim('Components skipped (kept local changes):'));
598
+ for (const name of declined) console.log(chalk.dim(' - ') + chalk.yellow(name));
599
+ }
600
+ }
601
+
496
602
  // ---------------------------------------------------------------------------
497
603
  // Main entry point
498
604
  // ---------------------------------------------------------------------------
@@ -507,6 +613,11 @@ export async function add(components: string[], options: AddOptions) {
507
613
  process.exit(1);
508
614
  }
509
615
 
616
+ // CLI flag takes priority over components.json
617
+ if (!options.registry && config.registry) {
618
+ options.registry = config.registry;
619
+ }
620
+
510
621
  const componentsToAdd = await selectComponents(components, options);
511
622
  if (!componentsToAdd || componentsToAdd.length === 0) {
512
623
  console.log(chalk.dim('No components selected.'));
@@ -524,64 +635,37 @@ export async function add(components: string[], options: AddOptions) {
524
635
  const targetDir = resolveProjectPath(cwd, uiBasePath);
525
636
  const utilsAlias = config.aliases.utils;
526
637
 
527
- // Detect conflicts
528
638
  const checkSpinner = ora('Checking for conflicts...').start();
529
639
  const { toInstall, toSkip, conflicting, peerFilesToUpdate, contentCache } =
530
640
  await detectConflicts(allComponents, targetDir, options, utilsAlias);
531
641
  checkSpinner.stop();
532
642
 
533
- // Prompt user for overwrite decisions
534
- const toOverwrite = await promptOverwrite(conflicting, options);
643
+ const toOverwrite = await promptOverwrite(conflicting, options, targetDir, contentCache);
535
644
  const finalComponents = [...toInstall, ...toOverwrite];
536
-
537
- // Remove peer files that belong only to declined components
538
645
  const declined = conflicting.filter(c => !toOverwrite.includes(c));
539
- for (const name of declined) {
540
- const component = registry[name];
541
- if (!component.peerFiles) continue;
542
- for (const file of component.peerFiles) {
543
- const stillNeeded = finalComponents.some(fc =>
544
- registry[fc].peerFiles?.includes(file),
545
- );
546
- if (!stillNeeded) {
547
- peerFilesToUpdate.delete(file);
548
- }
549
- }
646
+
647
+ pruneDeclinedPeerFiles(declined, finalComponents, peerFilesToUpdate);
648
+
649
+ if (options.dryRun) {
650
+ printDryRunSummary(toInstall, toOverwrite, toSkip, declined);
651
+ return;
550
652
  }
551
653
 
552
654
  if (finalComponents.length === 0) {
553
- if (toSkip.length > 0 || declined.length > 0) {
554
- if (toSkip.length > 0) {
555
- console.log(chalk.green(`\nAll components are up to date! (${toSkip.length} skipped)`));
556
- }
557
- if (declined.length > 0) {
558
- console.log('\n' + chalk.dim('Components skipped (kept local changes):'));
559
- declined.forEach(name => console.log(chalk.dim(' - ') + chalk.yellow(name)));
560
- }
561
- } else {
562
- console.log(chalk.dim('\nNo components to install.'));
563
- }
655
+ printNothingToInstall(toSkip, declined);
564
656
  return;
565
657
  }
566
658
 
567
- // Install component files
568
659
  const spinner = ora('Installing components...').start();
569
660
  let successCount = 0;
570
- const finalComponentSet = new Set(finalComponents);
571
661
 
572
662
  try {
573
663
  await fs.ensureDir(targetDir);
574
664
 
575
665
  for (const name of finalComponents) {
576
666
  const component = registry[name];
577
-
578
- const ok = await writeComponentFiles(
579
- component, targetDir, options, utilsAlias, contentCache, spinner,
580
- );
581
- await writePeerFiles(
582
- component, targetDir, options, utilsAlias, contentCache, peerFilesToUpdate, spinner,
583
- );
584
-
667
+ const ok = await writeComponentFiles(component, targetDir, options, utilsAlias, contentCache, spinner);
668
+ await writePeerFiles(component, targetDir, options, utilsAlias, contentCache, peerFilesToUpdate, spinner);
585
669
  if (ok) {
586
670
  successCount++;
587
671
  spinner.text = `Added ${name}`;
@@ -591,27 +675,17 @@ export async function add(components: string[], options: AddOptions) {
591
675
  if (successCount > 0) {
592
676
  spinner.succeed(chalk.green(`Success! Added ${successCount} component(s)`));
593
677
  console.log('\n' + chalk.dim('Components added:'));
594
- finalComponents.forEach(name => console.log(chalk.dim(' - ') + chalk.cyan(name)));
678
+ for (const name of finalComponents) console.log(chalk.dim(' - ') + chalk.cyan(name));
595
679
  } else {
596
680
  spinner.info('No new components installed.');
597
681
  }
598
682
 
599
- // Post-install: lib files, npm deps, shortcuts
600
683
  const libDir = resolveProjectPath(cwd, aliasToProjectPath(utilsAlias));
601
- await installLibFiles(finalComponentSet, cwd, libDir, options);
684
+ await installLibFiles(new Set(finalComponents), cwd, libDir, options);
602
685
  await installNpmDependencies(finalComponents, cwd);
603
686
  await ensureShortcutService(targetDir, cwd, config, options);
604
687
 
605
- if (toSkip.length > 0) {
606
- console.log('\n' + chalk.dim('Components skipped (up to date):'));
607
- toSkip.forEach(name => console.log(chalk.dim(' - ') + chalk.gray(name)));
608
- }
609
-
610
- if (declined.length > 0) {
611
- console.log('\n' + chalk.dim('Components skipped (kept local changes):'));
612
- declined.forEach(name => console.log(chalk.dim(' - ') + chalk.yellow(name)));
613
- }
614
-
688
+ printSkipSummary(toSkip, declined);
615
689
  console.log('');
616
690
  } catch (error) {
617
691
  spinner.fail('Failed to add components');