epicshop 6.50.13 → 6.50.14

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.
@@ -234,6 +234,134 @@ async function checkWorkshopAccess(workshops) {
234
234
  hasAccess: accessResults[index],
235
235
  }));
236
236
  }
237
+ /**
238
+ * Helper function to add a single workshop by repo name
239
+ * This handles the actual cloning and setup logic
240
+ */
241
+ async function addSingleWorkshop(repoName, options) {
242
+ const { silent = false } = options;
243
+ const hasExplicitCloneDestination = Boolean(options.destination?.trim() || options.directory?.trim());
244
+ // Ensure config is set up first (only when using the managed repos directory)
245
+ if (!hasExplicitCloneDestination) {
246
+ if (!(await ensureConfigured())) {
247
+ return { success: false, message: 'Setup cancelled' };
248
+ }
249
+ }
250
+ const { getReposDirectory, workshopExists } = await import('@epic-web/workshop-utils/workshops.server');
251
+ // Check if workshop already exists (only meaningful for managed repos directory)
252
+ if (!hasExplicitCloneDestination) {
253
+ if (await workshopExists(repoName)) {
254
+ const message = `Workshop "${repoName}" already exists`;
255
+ if (!silent) {
256
+ const { getWorkshop } = await import('@epic-web/workshop-utils/workshops.server');
257
+ const reposDir = await getReposDirectory();
258
+ const workshop = await getWorkshop(repoName);
259
+ const workshopPath = workshop?.path ?? path.join(reposDir, repoName);
260
+ const workshopRepoName = workshop?.repoName ?? repoName;
261
+ const openCommand = `npx epicshop open ${workshopRepoName}`;
262
+ const startCommand = `npx epicshop start ${workshopRepoName}`;
263
+ console.log(chalk.yellow(`⚠️ ${message}`));
264
+ console.log(chalk.gray(` Location on disk: ${workshopPath}`));
265
+ console.log(chalk.gray(` You can run:`));
266
+ console.log(chalk.white.bold(` ${openCommand}`));
267
+ console.log(chalk.white.bold(` ${startCommand}`));
268
+ }
269
+ return { success: false, message };
270
+ }
271
+ }
272
+ let reposDir;
273
+ let workshopPath;
274
+ if (options.destination?.trim()) {
275
+ // destination is always treated as a parent directory
276
+ // - if it exists and is a directory: clone into <destination>/<repoName>
277
+ // - if it doesn't exist: create it and clone into <destination>/<repoName>
278
+ // This ensures consistent behavior for both single and multiple workshop setups
279
+ const resolvedDestination = path.resolve(resolvePathWithTilde(options.destination));
280
+ try {
281
+ const stat = await fs.promises.stat(resolvedDestination);
282
+ if (stat.isDirectory()) {
283
+ reposDir = resolvedDestination;
284
+ workshopPath = path.join(reposDir, repoName);
285
+ }
286
+ else {
287
+ return {
288
+ success: false,
289
+ message: `Destination is not a directory: ${resolvedDestination}`,
290
+ };
291
+ }
292
+ }
293
+ catch {
294
+ // Destination doesn't exist. Create it as a parent directory and clone inside.
295
+ reposDir = resolvedDestination;
296
+ workshopPath = path.join(reposDir, repoName);
297
+ }
298
+ }
299
+ else {
300
+ reposDir = options.directory?.trim()
301
+ ? path.resolve(resolvePathWithTilde(options.directory))
302
+ : await getReposDirectory();
303
+ workshopPath = path.join(reposDir, repoName);
304
+ }
305
+ // Ensure the repos directory exists
306
+ await fs.promises.mkdir(reposDir, { recursive: true });
307
+ // Check if directory already exists
308
+ try {
309
+ await fs.promises.access(workshopPath);
310
+ const message = `Directory already exists: ${workshopPath}`;
311
+ if (!silent)
312
+ console.log(chalk.yellow(`⚠️ ${message}`));
313
+ return { success: false, message };
314
+ }
315
+ catch {
316
+ // Directory doesn't exist, which is what we want
317
+ }
318
+ const repoUrl = `https://github.com/${GITHUB_ORG}/${repoName}.git`;
319
+ if (!silent) {
320
+ console.log(chalk.cyan(`📦 Cloning ${repoUrl}...`));
321
+ }
322
+ // Clone the repository
323
+ const cloneResult = await runCommand('git', ['clone', repoUrl, workshopPath], {
324
+ cwd: reposDir,
325
+ silent,
326
+ });
327
+ if (!cloneResult.success) {
328
+ return {
329
+ success: false,
330
+ message: `Failed to clone repository: ${cloneResult.message}`,
331
+ error: cloneResult.error,
332
+ };
333
+ }
334
+ if (!silent) {
335
+ console.log(chalk.cyan(`🔧 Running npm run setup...`));
336
+ }
337
+ // Run npm run setup
338
+ const setupResult = await runCommand('npm', ['run', 'setup'], {
339
+ cwd: workshopPath,
340
+ silent,
341
+ });
342
+ if (!setupResult.success) {
343
+ // Clean up the cloned directory on setup failure
344
+ if (!silent) {
345
+ console.log(chalk.yellow(`🧹 Cleaning up cloned directory...`));
346
+ }
347
+ try {
348
+ await fs.promises.rm(workshopPath, { recursive: true, force: true });
349
+ }
350
+ catch {
351
+ // Ignore cleanup errors
352
+ }
353
+ return {
354
+ success: false,
355
+ message: `Failed to run setup: ${setupResult.message}`,
356
+ error: setupResult.error,
357
+ };
358
+ }
359
+ const message = `Workshop "${repoName}" cloned successfully to ${workshopPath}`;
360
+ if (!silent) {
361
+ console.log(chalk.green(`✅ ${message}`));
362
+ }
363
+ return { success: true, message };
364
+ }
237
365
  /**
238
366
  * Add a workshop by cloning from epicweb-dev GitHub org and running setup
239
367
  */
@@ -325,7 +453,7 @@ export async function add(options) {
325
453
  error: error instanceof Error ? error : new Error(message),
326
454
  };
327
455
  }
328
- const { search } = await import('@inquirer/prompts');
456
+ const { search, select, checkbox } = await import('@inquirer/prompts');
329
457
  console.log();
330
458
  console.log(chalk.bold.cyan('📚 Available Workshops\n'));
331
459
  console.log(chalk.gray('Icon Key:'));
@@ -335,176 +463,246 @@ export async function add(options) {
335
463
  console.log(chalk.gray(` 🔑 You have access to this workshop`));
336
464
  console.log(chalk.gray(` ✔︎ Already downloaded on your machine`));
337
465
  console.log();
338
- const allChoices = enrichedWorkshops.map((w) => {
339
- const productIcon = w.productHost
340
- ? PRODUCT_ICONS[w.productHost] || ''
341
- : '';
342
- const accessIcon = w.hasAccess === true ? chalk.yellow('🔑') : '';
343
- const downloadedIcon = w.isDownloaded === true ? chalk.green('✔︎') : '';
344
- const nameParts = [
345
- productIcon,
346
- w.title || w.name,
347
- accessIcon,
348
- downloadedIcon,
349
- ].filter(Boolean);
350
- const name = nameParts.join(' ');
351
- const descriptionParts = [
352
- w.instructorName ? `by ${w.instructorName}` : null,
353
- w.productDisplayName || w.productHost,
354
- w.description,
355
- ].filter(Boolean);
356
- const description = descriptionParts.join(' ') || undefined;
357
- return {
358
- name,
359
- value: w.name,
360
- description,
361
- workshop: w,
466
+ // Filter workshops for quick-select options (has access, not downloaded, has product)
467
+ const quickSelectCandidates = enrichedWorkshops.filter((w) => w.hasAccess === true &&
468
+ w.isDownloaded !== true &&
469
+ w.productHost &&
470
+ w.name !== TUTORIAL_REPO);
471
+ // Check if we have enough candidates for quick-select options
472
+ const hasQuickSelectOptions = quickSelectCandidates.length > 1;
473
+ let selectedRepoNames = [];
474
+ if (hasQuickSelectOptions) {
475
+ // Group workshops by product for quick-select options
476
+ const workshopsByProduct = new Map();
477
+ for (const w of quickSelectCandidates) {
478
+ const host = w.productHost;
479
+ const existing = workshopsByProduct.get(host) || [];
480
+ existing.push(w.name);
481
+ workshopsByProduct.set(host, existing);
482
+ }
483
+ const selectionMethodChoices = [];
484
+ // Add "All My Workshops" option
485
+ selectionMethodChoices.push({
486
+ name: `⭐ All My Workshops`,
487
+ value: '__ALL_MY__',
488
+ description: `Set up all ${quickSelectCandidates.length} workshops you have access to`,
489
+ });
490
+ // Add per-product options for products with multiple workshops
491
+ const productDisplayNames = {
492
+ 'www.epicreact.dev': '🚀 All Epic React workshops',
493
+ 'www.epicweb.dev': '🌌 All Epic Web workshops',
494
+ 'www.epicai.pro': '⚡ All Epic AI workshops',
362
495
  };
363
- });
364
- repoName = await search({
365
- message: 'Select a workshop to add:',
366
- source: async (input) => {
367
- if (!input) {
368
- return allChoices;
496
+ for (const [host, workshops] of workshopsByProduct) {
497
+ if (workshops.length > 1 && productDisplayNames[host]) {
498
+ selectionMethodChoices.push({
499
+ name: productDisplayNames[host],
500
+ value: `__PRODUCT__${host}`,
501
+ description: `Set up all ${workshops.length} workshops from this product`,
502
+ });
369
503
  }
370
- return matchSorter(allChoices, input, {
371
- keys: [
372
- { key: 'name', threshold: rankings.CONTAINS },
373
- { key: 'value', threshold: rankings.CONTAINS },
374
- {
375
- key: 'workshop.productDisplayName',
376
- threshold: rankings.CONTAINS,
377
- },
378
- {
379
- key: 'workshop.instructorName',
380
- threshold: rankings.CONTAINS,
381
- },
382
- { key: 'description', threshold: rankings.WORD_STARTS_WITH },
383
- ],
504
+ }
505
+ // Add "Choose individually" option
506
+ selectionMethodChoices.push({
507
+ name: '📋 Choose individually',
508
+ value: '__INDIVIDUAL__',
509
+ description: 'Select specific workshops from a list',
510
+ });
511
+ // Add "Browse all" option to see all workshops including ones without access
512
+ selectionMethodChoices.push({
513
+ name: '🔍 Browse all workshops',
514
+ value: '__BROWSE_ALL__',
515
+ description: 'Search through all available workshops',
516
+ });
517
+ const selectionMethod = await select({
518
+ message: 'How would you like to select workshops?',
519
+ choices: selectionMethodChoices,
520
+ });
521
+ if (selectionMethod === '__ALL_MY__') {
522
+ selectedRepoNames = quickSelectCandidates.map((w) => w.name);
523
+ console.log(chalk.cyan(`\n✓ Selected all ${selectedRepoNames.length} workshops you have access to\n`));
524
+ }
525
+ else if (selectionMethod.startsWith('__PRODUCT__')) {
526
+ const host = selectionMethod.replace('__PRODUCT__', '');
527
+ selectedRepoNames = workshopsByProduct.get(host) || [];
528
+ const productName = productDisplayNames[host]?.replace(/^[^\s]+\s/, '') || host;
529
+ console.log(chalk.cyan(`\n✓ Selected ${selectedRepoNames.length} ${productName.replace('All ', '')}\n`));
530
+ }
531
+ else if (selectionMethod === '__INDIVIDUAL__') {
532
+ // Show checkbox for individual selection from accessible workshops
533
+ const individualChoices = quickSelectCandidates.map((w) => {
534
+ const productIcon = w.productHost
535
+ ? PRODUCT_ICONS[w.productHost] || ''
536
+ : '';
537
+ const accessIcon = chalk.yellow('🔑');
538
+ const name = [productIcon, w.title || w.name, accessIcon]
539
+ .filter(Boolean)
540
+ .join(' ');
541
+ const descriptionParts = [
542
+ w.instructorName ? `by ${w.instructorName}` : null,
543
+ w.productDisplayName || w.productHost,
544
+ w.description,
545
+ ].filter(Boolean);
546
+ const description = descriptionParts.join(' • ') || undefined;
547
+ return {
548
+ name,
549
+ value: w.name,
550
+ description,
551
+ };
552
+ });
553
+ console.log(chalk.gray('\n Use space to select, enter to confirm your selection.\n'));
554
+ selectedRepoNames = await checkbox({
555
+ message: 'Select workshops to set up:',
556
+ choices: individualChoices,
384
557
  });
385
- },
386
- });
387
- }
388
- const hasExplicitCloneDestination = Boolean(options.destination?.trim() || options.directory?.trim());
389
- // Ensure config is set up first (only when using the managed repos directory)
390
- if (!hasExplicitCloneDestination) {
391
- if (!(await ensureConfigured())) {
392
- return { success: false, message: 'Setup cancelled' };
393
- }
394
- }
395
- const { getReposDirectory, workshopExists } = await import('@epic-web/workshop-utils/workshops.server');
396
- // Check if workshop already exists (only meaningful for managed repos directory)
397
- if (!hasExplicitCloneDestination) {
398
- if (await workshopExists(repoName)) {
399
- const message = `Workshop "${repoName}" already exists`;
400
- if (!silent) {
401
- const { getWorkshop } = await import('@epic-web/workshop-utils/workshops.server');
402
- const reposDir = await getReposDirectory();
403
- const workshop = await getWorkshop(repoName);
404
- const workshopPath = workshop?.path ?? path.join(reposDir, repoName);
405
- const workshopRepoName = workshop?.repoName ?? repoName;
406
- const openCommand = `npx epicshop open ${workshopRepoName}`;
407
- const startCommand = `npx epicshop start ${workshopRepoName}`;
408
- console.log(chalk.yellow(`⚠️ ${message}`));
409
- console.log(chalk.gray(` Location on disk: ${workshopPath}`));
410
- console.log(chalk.gray(` You can run:`));
411
- console.log(chalk.white.bold(` ${openCommand}`));
412
- console.log(chalk.white.bold(` ${startCommand}`));
413
558
  }
414
- return { success: false, message };
559
+ // For __BROWSE_ALL__, selectedRepoNames stays empty and falls through to search
415
560
  }
416
- }
417
- let reposDir;
418
- let workshopPath;
419
- if (options.destination?.trim()) {
420
- // destination can be either:
421
- // - a parent directory (existing) => clone into <destination>/<repoName>
422
- // - a full target path (non-existing) => clone into <destination>
423
- const resolvedDestination = path.resolve(resolvePathWithTilde(options.destination));
424
- try {
425
- const stat = await fs.promises.stat(resolvedDestination);
426
- if (stat.isDirectory()) {
427
- reposDir = resolvedDestination;
428
- workshopPath = path.join(reposDir, repoName);
561
+ // If no quick-select was made, show the full search interface
562
+ if (selectedRepoNames.length === 0) {
563
+ const allChoices = enrichedWorkshops.map((w) => {
564
+ const productIcon = w.productHost
565
+ ? PRODUCT_ICONS[w.productHost] || ''
566
+ : '';
567
+ const accessIcon = w.hasAccess === true ? chalk.yellow('🔑') : '';
568
+ const downloadedIcon = w.isDownloaded === true ? chalk.green('✔︎') : '';
569
+ const nameParts = [
570
+ productIcon,
571
+ w.title || w.name,
572
+ accessIcon,
573
+ downloadedIcon,
574
+ ].filter(Boolean);
575
+ const name = nameParts.join(' ');
576
+ const descriptionParts = [
577
+ w.instructorName ? `by ${w.instructorName}` : null,
578
+ w.productDisplayName || w.productHost,
579
+ w.description,
580
+ ].filter(Boolean);
581
+ const description = descriptionParts.join(' • ') || undefined;
582
+ return {
583
+ name,
584
+ value: w.name,
585
+ description,
586
+ workshop: w,
587
+ };
588
+ });
589
+ repoName = await search({
590
+ message: 'Select a workshop to add:',
591
+ source: async (input) => {
592
+ if (!input) {
593
+ return allChoices;
594
+ }
595
+ return matchSorter(allChoices, input, {
596
+ keys: [
597
+ { key: 'name', threshold: rankings.CONTAINS },
598
+ { key: 'value', threshold: rankings.CONTAINS },
599
+ {
600
+ key: 'workshop.productDisplayName',
601
+ threshold: rankings.CONTAINS,
602
+ },
603
+ {
604
+ key: 'workshop.instructorName',
605
+ threshold: rankings.CONTAINS,
606
+ },
607
+ { key: 'description', threshold: rankings.WORD_STARTS_WITH },
608
+ ],
609
+ });
610
+ },
611
+ });
612
+ selectedRepoNames = [repoName];
613
+ }
614
+ // Create a map from repo name to workshop title for nice display
615
+ const repoToTitle = new Map();
616
+ for (const w of enrichedWorkshops) {
617
+ repoToTitle.set(w.name, w.title || w.name);
618
+ }
619
+ // Helper to get display name for a repo
620
+ const getDisplayName = (repo) => repoToTitle.get(repo) || repo;
621
+ // Set up selected workshops
622
+ if (selectedRepoNames.length > 1) {
623
+ // Multiple workshops selected - confirm before proceeding
624
+ const { confirm } = await import('@inquirer/prompts');
625
+ console.log();
626
+ const shouldProceed = await confirm({
627
+ message: `You've selected to set up ${selectedRepoNames.length} workshops. This may take some time. Continue?`,
628
+ default: true,
629
+ });
630
+ if (!shouldProceed) {
631
+ console.log(chalk.gray('\nSetup cancelled.\n'));
632
+ return { success: false, message: 'Setup cancelled by user' };
633
+ }
634
+ console.log();
635
+ let successCount = 0;
636
+ let failCount = 0;
637
+ for (const selectedRepo of selectedRepoNames) {
638
+ const displayName = getDisplayName(selectedRepo);
639
+ console.log(chalk.cyan(`🏎️ Setting up ${chalk.bold(displayName)}...\n`));
640
+ const result = await addSingleWorkshop(selectedRepo, options);
641
+ if (result.success) {
642
+ successCount++;
643
+ console.log(chalk.green(`🏁 Finished setting up ${chalk.bold(displayName)}\n`));
644
+ }
645
+ else {
646
+ failCount++;
647
+ console.log(chalk.yellow(`⚠️ Failed to set up ${displayName}. You can retry later with \`npx epicshop add ${selectedRepo}\`.`));
648
+ if (result.message)
649
+ console.log(chalk.gray(` ${result.message}`));
650
+ console.log();
651
+ }
652
+ }
653
+ // Final summary
654
+ if (successCount > 0) {
655
+ console.log(chalk.green.bold(`🏁 🏁 Finished setting up all ${successCount} workshop${successCount > 1 ? 's' : ''}${failCount > 0 ? ` (${failCount} failed)` : ''}.\n`));
656
+ console.log(chalk.white('Run:'));
657
+ console.log(chalk.white(` ${chalk.cyan('npx epicshop open')} - open a workshop in your editor`));
658
+ console.log(chalk.white(` ${chalk.cyan('npx epicshop start')} - start a workshop`));
659
+ console.log();
660
+ return {
661
+ success: true,
662
+ message: `Successfully set up ${successCount} workshop(s)${failCount > 0 ? `, ${failCount} failed` : ''}`,
663
+ };
429
664
  }
430
665
  else {
431
666
  return {
432
667
  success: false,
433
- message: `Destination is not a directory: ${resolvedDestination}`,
668
+ message: `Failed to set up any workshops`,
434
669
  };
435
670
  }
436
671
  }
437
- catch {
438
- // Destination doesn't exist. Treat it as the exact clone target path.
439
- workshopPath = resolvedDestination;
440
- reposDir = path.dirname(workshopPath);
672
+ // Single workshop selected
673
+ repoName = selectedRepoNames[0];
674
+ if (!repoName) {
675
+ return { success: false, message: 'No workshop selected' };
441
676
  }
442
- }
443
- else {
444
- reposDir = options.directory?.trim()
445
- ? path.resolve(resolvePathWithTilde(options.directory))
446
- : await getReposDirectory();
447
- workshopPath = path.join(reposDir, repoName);
448
- }
449
- // Ensure the repos directory exists
450
- await fs.promises.mkdir(reposDir, { recursive: true });
451
- // Check if directory already exists
452
- try {
453
- await fs.promises.access(workshopPath);
454
- const message = `Directory already exists: ${workshopPath}`;
455
- if (!silent)
456
- console.log(chalk.yellow(`⚠️ ${message}`));
457
- return { success: false, message };
458
- }
459
- catch {
460
- // Directory doesn't exist, which is what we want
461
- }
462
- const repoUrl = `https://github.com/${GITHUB_ORG}/${repoName}.git`;
463
- if (!silent) {
464
- console.log(chalk.cyan(`📦 Cloning ${repoUrl}...`));
465
- }
466
- // Clone the repository
467
- const cloneResult = await runCommand('git', ['clone', repoUrl, workshopPath], {
468
- cwd: reposDir,
469
- silent,
470
- });
471
- if (!cloneResult.success) {
472
- return {
473
- success: false,
474
- message: `Failed to clone repository: ${cloneResult.message}`,
475
- error: cloneResult.error,
476
- };
477
- }
478
- if (!silent) {
479
- console.log(chalk.cyan(`🔧 Running npm run setup...`));
480
- }
481
- // Run npm run setup
482
- const setupResult = await runCommand('npm', ['run', 'setup'], {
483
- cwd: workshopPath,
484
- silent,
485
- });
486
- if (!setupResult.success) {
487
- // Clean up the cloned directory on setup failure
488
- if (!silent) {
489
- console.log(chalk.yellow(`🧹 Cleaning up cloned directory...`));
490
- }
491
- try {
492
- await fs.promises.rm(workshopPath, { recursive: true, force: true });
493
- }
494
- catch {
495
- // Ignore cleanup errors
677
+ const displayName = getDisplayName(repoName);
678
+ console.log(chalk.cyan(`🏎️ Setting up ${chalk.bold(displayName)}...\n`));
679
+ const result = await addSingleWorkshop(repoName, options);
680
+ if (result.success) {
681
+ console.log(chalk.green(`🏁 Finished setting up ${chalk.bold(displayName)}\n`));
682
+ console.log(chalk.white('Run:'));
683
+ console.log(chalk.white(` ${chalk.cyan('npx epicshop open')} - open a workshop in your editor`));
684
+ console.log(chalk.white(` ${chalk.cyan('npx epicshop start')} - start a workshop`));
685
+ console.log();
496
686
  }
497
- return {
498
- success: false,
499
- message: `Failed to run setup: ${setupResult.message}`,
500
- error: setupResult.error,
501
- };
687
+ return result;
688
+ }
689
+ // Ensure we have a repo name at this point
690
+ if (!repoName) {
691
+ return { success: false, message: 'No workshop selected' };
502
692
  }
503
- const message = `Workshop "${repoName}" cloned successfully to ${workshopPath}`;
693
+ // Use the helper to set up the single workshop (when repo was provided via CLI args)
504
694
  if (!silent) {
505
- console.log(chalk.green(`✅ ${message}`));
695
+ console.log(chalk.cyan(`🏎️ Setting up ${chalk.bold(repoName)}...\n`));
696
+ }
697
+ const result = await addSingleWorkshop(repoName, options);
698
+ if (result.success && !silent) {
699
+ console.log(chalk.green(`🏁 Finished setting up ${chalk.bold(repoName)}\n`));
700
+ console.log(chalk.white('Run:'));
701
+ console.log(chalk.white(` ${chalk.cyan('npx epicshop open')} - open a workshop in your editor`));
702
+ console.log(chalk.white(` ${chalk.cyan('npx epicshop start')} - start a workshop`));
703
+ console.log();
506
704
  }
507
- return { success: true, message };
705
+ return result;
508
706
  }
509
707
  catch (error) {
510
708
  if (error.message === 'USER_QUIT') {
@@ -1511,7 +1709,7 @@ async function promptAndSetupAccessibleWorkshops() {
1511
1709
  'Skip this step by not running onboarding, and add workshops directly: npx epicshop add <repo-name>',
1512
1710
  ],
1513
1711
  });
1514
- const { search } = await import('@inquirer/prompts');
1712
+ const { checkbox } = await import('@inquirer/prompts');
1515
1713
  const spinner = ora('Fetching available workshops...').start();
1516
1714
  let enrichedWorkshops;
1517
1715
  try {
@@ -1566,11 +1764,77 @@ async function promptAndSetupAccessibleWorkshops() {
1566
1764
  console.log(chalk.gray(` ⚡ EpicAI.pro`));
1567
1765
  console.log(chalk.gray(` 🔑 You have access to this workshop`));
1568
1766
  console.log();
1569
- const buildChoices = async () => {
1570
- // Recompute downloaded status each loop (in case user just added one)
1571
- const updated = await checkWorkshopDownloadStatus(candidates);
1572
- const remaining = updated.filter((w) => !w.isDownloaded);
1573
- const workshopChoices = remaining.map((w) => {
1767
+ // Filter workshops that have a product configured (for "All My Workshops" option)
1768
+ const workshopsWithProduct = candidates.filter((w) => w.productHost);
1769
+ // Group workshops by product for quick-select options
1770
+ const workshopsByProduct = new Map();
1771
+ for (const w of workshopsWithProduct) {
1772
+ const host = w.productHost;
1773
+ const existing = workshopsByProduct.get(host) || [];
1774
+ existing.push(w.name);
1775
+ workshopsByProduct.set(host, existing);
1776
+ }
1777
+ const selectionMethodChoices = [];
1778
+ // Add "All My Workshops" option if there are multiple workshops with products
1779
+ if (workshopsWithProduct.length > 1) {
1780
+ selectionMethodChoices.push({
1781
+ name: `⭐ All My Workshops`,
1782
+ value: '__ALL_MY__',
1783
+ description: `Set up all ${workshopsWithProduct.length} workshops you have access to`,
1784
+ });
1785
+ }
1786
+ // Add per-product options for products with multiple workshops
1787
+ const productDisplayNames = {
1788
+ 'www.epicreact.dev': '🚀 All Epic React workshops',
1789
+ 'www.epicweb.dev': '🌌 All Epic Web workshops',
1790
+ 'www.epicai.pro': '⚡ All Epic AI workshops',
1791
+ };
1792
+ for (const [host, workshops] of workshopsByProduct) {
1793
+ if (workshops.length > 1 && productDisplayNames[host]) {
1794
+ selectionMethodChoices.push({
1795
+ name: productDisplayNames[host],
1796
+ value: `__PRODUCT__${host}`,
1797
+ description: `Set up all ${workshops.length} workshops from this product`,
1798
+ });
1799
+ }
1800
+ }
1801
+ // Always add the "Choose individually" option
1802
+ selectionMethodChoices.push({
1803
+ name: '📋 Choose individually',
1804
+ value: '__INDIVIDUAL__',
1805
+ description: 'Select specific workshops from a list',
1806
+ });
1807
+ // Add skip option
1808
+ selectionMethodChoices.push({
1809
+ name: '⏭️ Skip for now',
1810
+ value: '__SKIP__',
1811
+ description: 'Continue without setting up additional workshops',
1812
+ });
1813
+ const { select } = await import('@inquirer/prompts');
1814
+ const selectionMethod = await select({
1815
+ message: 'How would you like to select workshops?',
1816
+ choices: selectionMethodChoices,
1817
+ });
1818
+ if (selectionMethod === '__SKIP__') {
1819
+ console.log(chalk.gray('\nSkipping workshop setup. Continuing...\n'));
1820
+ return;
1821
+ }
1822
+ let selectedWorkshops;
1823
+ if (selectionMethod === '__ALL_MY__') {
1824
+ // Select all workshops with products (that the user has access to)
1825
+ selectedWorkshops = workshopsWithProduct.map((w) => w.name);
1826
+ console.log(chalk.cyan(`\n✓ Selected all ${selectedWorkshops.length} workshops you have access to\n`));
1827
+ }
1828
+ else if (selectionMethod.startsWith('__PRODUCT__')) {
1829
+ // Select all workshops for this product
1830
+ const host = selectionMethod.replace('__PRODUCT__', '');
1831
+ selectedWorkshops = workshopsByProduct.get(host) || [];
1832
+ const productName = productDisplayNames[host]?.replace(/^[^\s]+\s/, '') || host;
1833
+ console.log(chalk.cyan(`\n✓ Selected ${selectedWorkshops.length} ${productName.replace('All ', '')}\n`));
1834
+ }
1835
+ else {
1836
+ // Show checkbox for individual selection
1837
+ const individualChoices = candidates.map((w) => {
1574
1838
  const productIcon = w.productHost
1575
1839
  ? PRODUCT_ICONS[w.productHost] || ''
1576
1840
  : '';
@@ -1588,65 +1852,70 @@ async function promptAndSetupAccessibleWorkshops() {
1588
1852
  name,
1589
1853
  value: w.name,
1590
1854
  description,
1591
- workshop: w,
1592
1855
  };
1593
1856
  });
1594
- return [
1595
- ...workshopChoices,
1596
- {
1597
- name: 'Done',
1598
- value: 'done',
1599
- description: 'Continue to the tutorial setup',
1600
- },
1601
- {
1602
- name: 'Skip workshop setup',
1603
- value: 'skip',
1604
- description: 'Continue without setting up more workshops',
1605
- },
1606
- ];
1607
- };
1608
- while (true) {
1609
- const choices = await buildChoices();
1610
- // If there are no remaining workshops, stop
1611
- if (choices.length <= 2)
1612
- return;
1613
- const selection = await search({
1614
- message: 'Select a workshop to set up (you can pick multiple):',
1615
- source: async (input) => {
1616
- if (!input)
1617
- return choices;
1618
- return matchSorter(choices, input, {
1619
- keys: [
1620
- { key: 'name', threshold: rankings.CONTAINS },
1621
- { key: 'value', threshold: rankings.CONTAINS },
1622
- {
1623
- key: 'workshop.productDisplayName',
1624
- threshold: rankings.CONTAINS,
1625
- },
1626
- { key: 'workshop.instructorName', threshold: rankings.CONTAINS },
1627
- { key: 'description', threshold: rankings.WORD_STARTS_WITH },
1628
- ],
1629
- });
1630
- },
1857
+ console.log(chalk.gray('\n Use space to select, enter to confirm your selection.\n'));
1858
+ selectedWorkshops = await checkbox({
1859
+ message: 'Select workshops to set up:',
1860
+ choices: individualChoices,
1631
1861
  });
1632
- if (selection === 'done' || selection === 'skip') {
1633
- console.log();
1862
+ }
1863
+ if (selectedWorkshops.length === 0) {
1864
+ console.log(chalk.gray('\nNo workshops selected. Continuing...\n'));
1865
+ return;
1866
+ }
1867
+ // Create a map from repo name to workshop title for nice display
1868
+ const repoToTitle = new Map();
1869
+ for (const w of candidates) {
1870
+ repoToTitle.set(w.name, w.title || w.name);
1871
+ }
1872
+ const getDisplayName = (repo) => repoToTitle.get(repo) || repo;
1873
+ // Confirm before setting up multiple workshops
1874
+ if (selectedWorkshops.length > 1) {
1875
+ const { confirm } = await import('@inquirer/prompts');
1876
+ console.log();
1877
+ const shouldProceed = await confirm({
1878
+ message: `You've selected to set up ${selectedWorkshops.length} workshops. This may take some time. Continue?`,
1879
+ default: true,
1880
+ });
1881
+ if (!shouldProceed) {
1882
+ console.log(chalk.gray('\nSetup cancelled. Continuing...\n'));
1634
1883
  return;
1635
1884
  }
1636
- // If already present, don’t treat that as an error
1637
- if (await workshopExists(selection)) {
1638
- console.log(chalk.gray(`• ${selection} (already set up)`));
1885
+ }
1886
+ console.log();
1887
+ let successCount = 0;
1888
+ let failCount = 0;
1889
+ // Set up each selected workshop
1890
+ for (const repoName of selectedWorkshops) {
1891
+ const displayName = getDisplayName(repoName);
1892
+ // If already present, don't treat that as an error
1893
+ if (await workshopExists(repoName)) {
1894
+ console.log(chalk.gray(`• ${displayName} (already set up)`));
1639
1895
  continue;
1640
1896
  }
1641
- const result = await add({ repoName: selection, silent: false });
1642
- if (!result.success) {
1643
- console.log(chalk.yellow(`⚠️ Failed to set up ${selection}. You can retry later with \`npx epicshop add ${selection}\`.`));
1897
+ console.log(chalk.cyan(`🏎️ Setting up ${chalk.bold(displayName)}...\n`));
1898
+ const result = await add({ repoName, silent: true });
1899
+ if (result.success) {
1900
+ successCount++;
1901
+ console.log(chalk.green(`🏁 Finished setting up ${chalk.bold(displayName)}\n`));
1902
+ }
1903
+ else {
1904
+ failCount++;
1905
+ console.log(chalk.yellow(`⚠️ Failed to set up ${displayName}. You can retry later with \`npx epicshop add ${repoName}\`.`));
1644
1906
  if (result.message)
1645
1907
  console.log(chalk.gray(` ${result.message}`));
1646
1908
  console.log();
1647
- continue;
1648
1909
  }
1649
1910
  }
1911
+ // Final summary for multiple workshops
1912
+ if (selectedWorkshops.length > 1 && successCount > 0) {
1913
+ console.log(chalk.green.bold(`🏁 🏁 Finished setting up all ${successCount} workshop${successCount > 1 ? 's' : ''}${failCount > 0 ? ` (${failCount} failed)` : ''}.\n`));
1914
+ console.log(chalk.white('Run:'));
1915
+ console.log(chalk.white(` ${chalk.cyan('npx epicshop open')} - open a workshop in your editor`));
1916
+ console.log(chalk.white(` ${chalk.cyan('npx epicshop start')} - start a workshop`));
1917
+ console.log();
1918
+ }
1650
1919
  }
1651
1920
  /**
1652
1921
  * Ensure the tutorial workshop exists and start it
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "epicshop",
3
- "version": "6.50.13",
3
+ "version": "6.50.14",
4
4
  "publishConfig": {
5
5
  "access": "public"
6
6
  },
@@ -75,7 +75,7 @@
75
75
  "build:watch": "nx watch --projects=epicshop -- nx run \\$NX_PROJECT_NAME:build"
76
76
  },
77
77
  "dependencies": {
78
- "@epic-web/workshop-utils": "6.50.13",
78
+ "@epic-web/workshop-utils": "6.50.14",
79
79
  "@inquirer/prompts": "^7.5.1",
80
80
  "chalk": "^5.6.2",
81
81
  "close-with-grace": "^2.3.0",