@gilav21/shadcn-angular 0.0.22 → 0.0.24

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,598 +1,621 @@
1
- import fs from 'fs-extra';
2
- import path from 'node:path';
3
- import { fileURLToPath } from 'node:url';
4
- import prompts from 'prompts';
5
- import chalk from 'chalk';
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
- import { installPackages } from '../utils/package-manager.js';
10
- import { writeShortcutRegistryIndex, type ShortcutRegistryEntry } from '../utils/shortcut-registry.js';
11
-
12
- const __filename = fileURLToPath(import.meta.url);
13
- const __dirname = path.dirname(__filename);
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
-
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
- function normalizeContent(str: string): string {
81
- return str.replaceAll('\r\n', '\n').trim();
82
- }
83
-
84
- // ---------------------------------------------------------------------------
85
- // Remote content fetching
86
- // ---------------------------------------------------------------------------
87
-
88
- async function fetchComponentContent(file: string, options: AddOptions): Promise<string> {
89
- const localDir = getLocalComponentsDir();
90
-
91
- if (localDir && !options.remote) {
92
- const localPath = path.join(localDir, file);
93
- if (await fs.pathExists(localPath)) {
94
- return fs.readFile(localPath, 'utf-8');
95
- }
96
- }
97
-
98
- const url = `${getRegistryBaseUrl(options.branch)}/${file}`;
99
- try {
100
- const response = await fetch(url);
101
- if (!response.ok) {
102
- throw new Error(`Failed to fetch component from ${url}: ${response.statusText}`);
103
- }
104
- return await response.text();
105
- } catch (error) {
106
- if (localDir) {
107
- throw new Error(`Component file not found locally or remotely: ${file}`);
108
- }
109
- throw error;
110
- }
111
- }
112
-
113
- async function fetchLibContent(file: string, options: AddOptions): Promise<string> {
114
- const localDir = getLocalLibDir();
115
-
116
- if (localDir && !options.remote) {
117
- const localPath = path.join(localDir, file);
118
- if (await fs.pathExists(localPath)) {
119
- return fs.readFile(localPath, 'utf-8');
120
- }
121
- }
122
-
123
- const url = `${getLibRegistryBaseUrl(options.branch)}/${file}`;
124
- const response = await fetch(url);
125
- if (!response.ok) {
126
- throw new Error(`Failed to fetch library file from ${url}: ${response.statusText}`);
127
- }
128
- return response.text();
129
- }
130
-
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;
135
- }
136
-
137
- // ---------------------------------------------------------------------------
138
- // Component selection & dependency resolution
139
- // ---------------------------------------------------------------------------
140
-
141
- async function selectComponents(components: string[], options: AddOptions): Promise<ComponentName[]> {
142
- if (options.all) {
143
- return Object.keys(registry);
144
- }
145
-
146
- if (components.length === 0) {
147
- const { selected } = await prompts({
148
- type: 'multiselect',
149
- name: 'selected',
150
- message: 'Which components would you like to add?',
151
- choices: Object.keys(registry).map(name => ({
152
- title: name,
153
- value: name,
154
- })),
155
- hint: '- Space to select, Enter to confirm',
156
- });
157
- return selected;
158
- }
159
-
160
- return components;
161
- }
162
-
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(', ')}`));
167
- console.log(chalk.dim('Available components: ' + Object.keys(registry).join(', ')));
168
- process.exit(1);
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
- }
192
-
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) {
201
- const component = registry[name];
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 });
208
- }
209
- }
210
-
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;
302
-
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>();
318
- const contentCache = new Map<string, string>();
319
-
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
- }
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>();
424
- for (const name of allComponents) {
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}`));
440
- }
441
- }
442
- }
443
- }
444
-
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));
452
- }
453
-
454
- if (deps.size === 0) return;
455
-
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
- }
465
-
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);
481
- }
482
- }
483
-
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
- // ---------------------------------------------------------------------------
503
-
504
- export async function add(components: string[], options: AddOptions) {
505
- const cwd = process.cwd();
506
-
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.'));
517
- return;
518
- }
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
-
541
- if (finalComponents.length === 0) {
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
- }
547
- return;
548
- }
549
-
550
- // Install component files
551
- const spinner = ora('Installing components...').start();
552
- let successCount = 0;
553
-
554
- try {
555
- await fs.ensureDir(targetDir);
556
-
557
- for (const name of finalComponents) {
558
- const component = registry[name];
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) {
568
- successCount++;
569
- spinner.text = `Added ${name}`;
570
- }
571
- }
572
-
573
- if (successCount > 0) {
574
- spinner.succeed(chalk.green(`Success! Added ${successCount} component(s)`));
575
- console.log('\n' + chalk.dim('Components added:'));
576
- finalComponents.forEach(name => console.log(chalk.dim(' - ') + chalk.cyan(name)));
577
- } else {
578
- spinner.info('No new components installed.');
579
- }
580
-
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);
586
-
587
- if (toSkip.length > 0) {
588
- console.log('\n' + chalk.dim('Components skipped (up to date):'));
589
- toSkip.forEach(name => console.log(chalk.dim(' - ') + chalk.gray(name)));
590
- }
591
-
592
- console.log('');
593
- } catch (error) {
594
- spinner.fail('Failed to add components');
595
- console.error(error);
596
- process.exit(1);
597
- }
598
- }
1
+ import fs from 'fs-extra';
2
+ import path from 'node:path';
3
+ import { fileURLToPath } from 'node:url';
4
+ import prompts from 'prompts';
5
+ import chalk from 'chalk';
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
+ import { installPackages } from '../utils/package-manager.js';
10
+ import { writeShortcutRegistryIndex, type ShortcutRegistryEntry } from '../utils/shortcut-registry.js';
11
+
12
+ const __filename = fileURLToPath(import.meta.url);
13
+ const __dirname = path.dirname(__filename);
14
+
15
+ export 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
+
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
+ export function normalizeContent(str: string): string {
81
+ return str.replaceAll('\r\n', '\n').trim();
82
+ }
83
+
84
+ // ---------------------------------------------------------------------------
85
+ // Remote content fetching
86
+ // ---------------------------------------------------------------------------
87
+
88
+ async function fetchComponentContent(file: string, options: AddOptions): Promise<string> {
89
+ const localDir = getLocalComponentsDir();
90
+
91
+ if (localDir && !options.remote) {
92
+ const localPath = path.join(localDir, file);
93
+ if (await fs.pathExists(localPath)) {
94
+ return fs.readFile(localPath, 'utf-8');
95
+ }
96
+ }
97
+
98
+ const url = `${getRegistryBaseUrl(options.branch)}/${file}`;
99
+ try {
100
+ const response = await fetch(url);
101
+ if (!response.ok) {
102
+ throw new Error(`Failed to fetch component from ${url}: ${response.statusText}`);
103
+ }
104
+ return await response.text();
105
+ } catch (error) {
106
+ if (localDir) {
107
+ throw new Error(`Component file not found locally or remotely: ${file}`);
108
+ }
109
+ throw error;
110
+ }
111
+ }
112
+
113
+ async function fetchLibContent(file: string, options: AddOptions): Promise<string> {
114
+ const localDir = getLocalLibDir();
115
+
116
+ if (localDir && !options.remote) {
117
+ const localPath = path.join(localDir, file);
118
+ if (await fs.pathExists(localPath)) {
119
+ return fs.readFile(localPath, 'utf-8');
120
+ }
121
+ }
122
+
123
+ const url = `${getLibRegistryBaseUrl(options.branch)}/${file}`;
124
+ const response = await fetch(url);
125
+ if (!response.ok) {
126
+ throw new Error(`Failed to fetch library file from ${url}: ${response.statusText}`);
127
+ }
128
+ return response.text();
129
+ }
130
+
131
+ export async function fetchAndTransform(file: string, options: AddOptions, utilsAlias: string): Promise<string> {
132
+ let content = await fetchComponentContent(file, options);
133
+ content = content.replaceAll(/(\.\.\/)+lib\//g, utilsAlias + '/');
134
+ return content;
135
+ }
136
+
137
+ // ---------------------------------------------------------------------------
138
+ // Component selection & dependency resolution
139
+ // ---------------------------------------------------------------------------
140
+
141
+ async function selectComponents(components: string[], options: AddOptions): Promise<ComponentName[]> {
142
+ if (options.all) {
143
+ return Object.keys(registry);
144
+ }
145
+
146
+ if (components.length === 0) {
147
+ const { selected } = await prompts({
148
+ type: 'multiselect',
149
+ name: 'selected',
150
+ message: 'Which components would you like to add?',
151
+ choices: Object.keys(registry).map(name => ({
152
+ title: name,
153
+ value: name,
154
+ })),
155
+ hint: '- Space to select, Enter to confirm',
156
+ });
157
+ return selected;
158
+ }
159
+
160
+ return components;
161
+ }
162
+
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(', ')}`));
167
+ console.log(chalk.dim('Available components: ' + Object.keys(registry).join(', ')));
168
+ process.exit(1);
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
+ }
192
+
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) {
201
+ const component = registry[name];
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 });
208
+ }
209
+ }
210
+
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
+ export 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<void> {
267
+ if (!component.peerFiles) return;
268
+
269
+ for (const file of component.peerFiles) {
270
+ const status = await checkFileConflict(file, targetDir, options, utilsAlias, contentCache);
271
+ if (status === 'changed') {
272
+ peerFilesToUpdate.add(file);
273
+ }
274
+ }
275
+ }
276
+
277
+ export async function classifyComponent(
278
+ name: ComponentName,
279
+ targetDir: string,
280
+ options: AddOptions,
281
+ utilsAlias: string,
282
+ contentCache: Map<string, string>,
283
+ peerFilesToUpdate: Set<string>,
284
+ ): Promise<'install' | 'skip' | 'conflict'> {
285
+ const component = registry[name];
286
+ let ownFilesChanged = false;
287
+ let isFullyPresent = true;
288
+
289
+ for (const file of component.files) {
290
+ const status = await checkFileConflict(file, targetDir, options, utilsAlias, contentCache);
291
+ if (status === 'missing') isFullyPresent = false;
292
+ if (status === 'changed') ownFilesChanged = true;
293
+ }
294
+
295
+ await checkPeerFiles(
296
+ component, targetDir, options, utilsAlias, contentCache, peerFilesToUpdate,
297
+ );
298
+
299
+ if (isFullyPresent && !ownFilesChanged) return 'skip';
300
+ if (ownFilesChanged) return 'conflict';
301
+ return 'install';
302
+ }
303
+
304
+ export async function detectConflicts(
305
+ allComponents: Set<ComponentName>,
306
+ targetDir: string,
307
+ options: AddOptions,
308
+ utilsAlias: string,
309
+ ): Promise<ConflictCheckResult> {
310
+ const toInstall: ComponentName[] = [];
311
+ const toSkip: string[] = [];
312
+ const conflicting: ComponentName[] = [];
313
+ const peerFilesToUpdate = new Set<string>();
314
+ const contentCache = new Map<string, string>();
315
+
316
+ for (const name of allComponents) {
317
+ const result = await classifyComponent(
318
+ name, targetDir, options, utilsAlias, contentCache, peerFilesToUpdate,
319
+ );
320
+
321
+ if (result === 'skip') toSkip.push(name);
322
+ else if (result === 'conflict') conflicting.push(name);
323
+ else toInstall.push(name);
324
+ }
325
+
326
+ return { toInstall, toSkip, conflicting, peerFilesToUpdate, contentCache };
327
+ }
328
+
329
+ // ---------------------------------------------------------------------------
330
+ // Overwrite prompt
331
+ // ---------------------------------------------------------------------------
332
+
333
+ async function promptOverwrite(
334
+ conflicting: ComponentName[],
335
+ options: AddOptions,
336
+ ): Promise<ComponentName[]> {
337
+ if (conflicting.length === 0) return [];
338
+
339
+ if (options.overwrite) return conflicting;
340
+ if (options.yes) return [];
341
+
342
+ console.log(chalk.yellow(`\n${conflicting.length} component(s) have local changes or are different from remote.`));
343
+ const { selected } = await prompts({
344
+ type: 'multiselect',
345
+ name: 'selected',
346
+ message: 'Select components to OVERWRITE (Unselected will be skipped):',
347
+ choices: conflicting.map(name => ({ title: name, value: name })),
348
+ hint: '- Space to select, Enter to confirm',
349
+ });
350
+ return selected || [];
351
+ }
352
+
353
+ // ---------------------------------------------------------------------------
354
+ // File writing
355
+ // ---------------------------------------------------------------------------
356
+
357
+ async function writeComponentFiles(
358
+ component: ComponentDefinition,
359
+ targetDir: string,
360
+ options: AddOptions,
361
+ utilsAlias: string,
362
+ contentCache: Map<string, string>,
363
+ spinner: Ora,
364
+ ): Promise<boolean> {
365
+ let success = true;
366
+
367
+ for (const file of component.files) {
368
+ const targetPath = path.join(targetDir, file);
369
+ try {
370
+ const content = contentCache.get(file)
371
+ ?? await fetchAndTransform(file, options, utilsAlias);
372
+
373
+ await fs.ensureDir(path.dirname(targetPath));
374
+ await fs.writeFile(targetPath, content);
375
+ } catch (err: unknown) {
376
+ const message = err instanceof Error ? err.message : String(err);
377
+ spinner.warn(`Could not add ${file}: ${message}`);
378
+ success = false;
379
+ }
380
+ }
381
+
382
+ return success;
383
+ }
384
+
385
+ async function writePeerFiles(
386
+ component: ComponentDefinition,
387
+ targetDir: string,
388
+ options: AddOptions,
389
+ utilsAlias: string,
390
+ contentCache: Map<string, string>,
391
+ peerFilesToUpdate: Set<string>,
392
+ spinner: Ora,
393
+ ): Promise<void> {
394
+ if (!component.peerFiles) return;
395
+
396
+ for (const file of component.peerFiles) {
397
+ if (!peerFilesToUpdate.has(file)) continue;
398
+
399
+ const targetPath = path.join(targetDir, file);
400
+ try {
401
+ const content = contentCache.get(file)
402
+ ?? await fetchAndTransform(file, options, utilsAlias);
403
+
404
+ await fs.writeFile(targetPath, content);
405
+ spinner.text = `Updated peer file ${file}`;
406
+ } catch (err: unknown) {
407
+ const message = err instanceof Error ? err.message : String(err);
408
+ spinner.warn(`Could not update peer file ${file}: ${message}`);
409
+ }
410
+ }
411
+ }
412
+
413
+ async function installLibFiles(
414
+ allComponents: Set<ComponentName>,
415
+ cwd: string,
416
+ libDir: string,
417
+ options: AddOptions,
418
+ ): Promise<void> {
419
+ const required = new Set<string>();
420
+ for (const name of allComponents) {
421
+ registry[name].libFiles?.forEach(f => required.add(f));
422
+ }
423
+
424
+ if (required.size === 0) return;
425
+
426
+ await fs.ensureDir(libDir);
427
+ 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
+ }
437
+ }
438
+ }
439
+ }
440
+
441
+ async function installNpmDependencies(
442
+ finalComponents: ComponentName[],
443
+ cwd: string,
444
+ ): Promise<void> {
445
+ const deps = new Set<string>();
446
+ for (const name of finalComponents) {
447
+ registry[name].npmDependencies?.forEach(dep => deps.add(dep));
448
+ }
449
+
450
+ if (deps.size === 0) return;
451
+
452
+ const spinner = ora('Installing dependencies...').start();
453
+ try {
454
+ await installPackages(Array.from(deps), { cwd });
455
+ spinner.succeed('Dependencies installed.');
456
+ } catch (e) {
457
+ spinner.fail('Failed to install dependencies.');
458
+ console.error(e);
459
+ }
460
+ }
461
+
462
+ async function ensureShortcutService(
463
+ targetDir: string,
464
+ cwd: string,
465
+ config: Config,
466
+ options: AddOptions,
467
+ ): Promise<void> {
468
+ const entries = collectInstalledShortcutEntries(targetDir);
469
+
470
+ if (entries.length > 0) {
471
+ const libDir = resolveProjectPath(cwd, aliasToProjectPath(config.aliases.utils));
472
+ const servicePath = path.join(libDir, 'shortcut-binding.service.ts');
473
+ if (!await fs.pathExists(servicePath)) {
474
+ const content = await fetchLibContent('shortcut-binding.service.ts', options);
475
+ await fs.ensureDir(libDir);
476
+ await fs.writeFile(servicePath, content);
477
+ }
478
+ }
479
+
480
+ await writeShortcutRegistryIndex(cwd, config, entries);
481
+ }
482
+
483
+ function collectInstalledShortcutEntries(targetDir: string): ShortcutRegistryEntry[] {
484
+ const entries: ShortcutRegistryEntry[] = [];
485
+ for (const definition of Object.values(registry)) {
486
+ if (!definition.shortcutDefinitions?.length) continue;
487
+ for (const sd of definition.shortcutDefinitions) {
488
+ if (fs.existsSync(path.join(targetDir, sd.sourceFile))) {
489
+ entries.push(sd);
490
+ }
491
+ }
492
+ }
493
+ return entries;
494
+ }
495
+
496
+ // ---------------------------------------------------------------------------
497
+ // Main entry point
498
+ // ---------------------------------------------------------------------------
499
+
500
+ export async function add(components: string[], options: AddOptions) {
501
+ const cwd = process.cwd();
502
+
503
+ const config = await getConfig(cwd);
504
+ if (!config) {
505
+ console.log(chalk.red('Error: components.json not found.'));
506
+ console.log(chalk.dim('Run `npx @gilav21/shadcn-angular init` first.'));
507
+ process.exit(1);
508
+ }
509
+
510
+ const componentsToAdd = await selectComponents(components, options);
511
+ if (!componentsToAdd || componentsToAdd.length === 0) {
512
+ console.log(chalk.dim('No components selected.'));
513
+ return;
514
+ }
515
+
516
+ validateComponents(componentsToAdd);
517
+
518
+ const resolvedComponents = resolveDependencies(componentsToAdd);
519
+ const optionalChoices = await promptOptionalDependencies(resolvedComponents, options);
520
+ const allComponents = optionalChoices.length > 0
521
+ ? resolveDependencies([...resolvedComponents, ...optionalChoices])
522
+ : resolvedComponents;
523
+ const uiBasePath = options.path ?? aliasToProjectPath(config.aliases.ui || 'src/components/ui');
524
+ const targetDir = resolveProjectPath(cwd, uiBasePath);
525
+ const utilsAlias = config.aliases.utils;
526
+
527
+ // Detect conflicts
528
+ const checkSpinner = ora('Checking for conflicts...').start();
529
+ const { toInstall, toSkip, conflicting, peerFilesToUpdate, contentCache } =
530
+ await detectConflicts(allComponents, targetDir, options, utilsAlias);
531
+ checkSpinner.stop();
532
+
533
+ // Prompt user for overwrite decisions
534
+ const toOverwrite = await promptOverwrite(conflicting, options);
535
+ const finalComponents = [...toInstall, ...toOverwrite];
536
+
537
+ // Remove peer files that belong only to declined components
538
+ 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
+ }
550
+ }
551
+
552
+ 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
+ }
564
+ return;
565
+ }
566
+
567
+ // Install component files
568
+ const spinner = ora('Installing components...').start();
569
+ let successCount = 0;
570
+ const finalComponentSet = new Set(finalComponents);
571
+
572
+ try {
573
+ await fs.ensureDir(targetDir);
574
+
575
+ for (const name of finalComponents) {
576
+ 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
+
585
+ if (ok) {
586
+ successCount++;
587
+ spinner.text = `Added ${name}`;
588
+ }
589
+ }
590
+
591
+ if (successCount > 0) {
592
+ spinner.succeed(chalk.green(`Success! Added ${successCount} component(s)`));
593
+ console.log('\n' + chalk.dim('Components added:'));
594
+ finalComponents.forEach(name => console.log(chalk.dim(' - ') + chalk.cyan(name)));
595
+ } else {
596
+ spinner.info('No new components installed.');
597
+ }
598
+
599
+ // Post-install: lib files, npm deps, shortcuts
600
+ const libDir = resolveProjectPath(cwd, aliasToProjectPath(utilsAlias));
601
+ await installLibFiles(finalComponentSet, cwd, libDir, options);
602
+ await installNpmDependencies(finalComponents, cwd);
603
+ await ensureShortcutService(targetDir, cwd, config, options);
604
+
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
+
615
+ console.log('');
616
+ } catch (error) {
617
+ spinner.fail('Failed to add components');
618
+ console.error(error);
619
+ process.exit(1);
620
+ }
621
+ }