epicshop 6.75.1 → 6.76.0

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.
package/dist/cli.js CHANGED
@@ -280,11 +280,15 @@ const cli = yargs(args)
280
280
  .positional('subcommand', {
281
281
  describe: 'Config subcommand (reset)',
282
282
  type: 'string',
283
- choices: ['reset'],
283
+ choices: ['reset', 'editor'],
284
284
  })
285
285
  .option('repos-dir', {
286
286
  type: 'string',
287
287
  description: 'Set the default directory for workshop repos',
288
+ })
289
+ .option('editor', {
290
+ type: 'string',
291
+ description: 'Set the preferred editor command',
288
292
  })
289
293
  .option('silent', {
290
294
  alias: 's',
@@ -294,12 +298,19 @@ const cli = yargs(args)
294
298
  })
295
299
  .example('$0 config', 'View current configuration')
296
300
  .example('$0 config reset', 'Delete config file and reset to defaults')
297
- .example('$0 config --repos-dir ~/epicweb', 'Set the repos directory');
301
+ .example('$0 config --repos-dir ~/epicweb', 'Set the repos directory')
302
+ .example('$0 config editor', 'Choose a preferred editor')
303
+ .example('$0 config --editor code', 'Set preferred editor to VS Code');
298
304
  }, async (argv) => {
299
305
  const { config } = await import("./commands/workshops.js");
300
306
  const result = await config({
301
- subcommand: argv.subcommand === 'reset' ? 'reset' : undefined,
307
+ subcommand: argv.subcommand === 'reset'
308
+ ? 'reset'
309
+ : argv.subcommand === 'editor'
310
+ ? 'editor'
311
+ : undefined,
302
312
  reposDir: argv.reposDir,
313
+ preferredEditor: argv.editor,
303
314
  silent: argv.silent,
304
315
  });
305
316
  if (!result.success) {
@@ -6,6 +6,7 @@ import path from 'node:path';
6
6
  import { resolveCacheDir, resolveFallbackPath, resolvePrimaryDir, resolvePrimaryPath, } from '@epic-web/workshop-utils/data-storage.server';
7
7
  import { deleteWorkshop, getReposDirectory, getUnpushedChanges, } from '@epic-web/workshop-utils/workshops.server';
8
8
  import chalk from 'chalk';
9
+ import ora from 'ora';
9
10
  import { assertCanPrompt } from "../utils/cli-runtime.js";
10
11
  const CLEANUP_TARGETS = [
11
12
  {
@@ -63,6 +64,19 @@ function resolveWorkshopCleanupTargets(targets) {
63
64
  const allowed = new Set(WORKSHOP_CLEANUP_TARGETS.map((target) => target.value));
64
65
  return Array.from(new Set(targets.filter((target) => allowed.has(target))));
65
66
  }
67
+ function startSpinner(text, silent) {
68
+ if (silent)
69
+ return null;
70
+ return ora(text).start();
71
+ }
72
+ function updateSpinner(spinner, text) {
73
+ if (spinner)
74
+ spinner.text = text;
75
+ }
76
+ function stopSpinner(spinner) {
77
+ if (spinner?.isSpinning)
78
+ spinner.stop();
79
+ }
66
80
  async function resolveCleanupPaths(paths = {}) {
67
81
  const reposDir = paths.reposDir ?? (await getReposDirectory());
68
82
  const cacheDir = paths.cacheDir ?? resolveCacheDir();
@@ -403,9 +417,11 @@ async function getDataCleanupSizeSummary(dataPaths) {
403
417
  }
404
418
  return { preferencesBytes, authBytes };
405
419
  }
406
- async function getWorkshopSummaries({ workshops, cacheDir, }) {
420
+ async function getWorkshopSummaries({ workshops, cacheDir, onProgress, }) {
407
421
  const summaries = [];
408
- for (const workshop of workshops) {
422
+ const total = workshops.length;
423
+ for (const [index, workshop] of workshops.entries()) {
424
+ onProgress?.({ current: index + 1, total, workshop });
409
425
  const id = getWorkshopInstanceId(workshop.path);
410
426
  const sizeBytes = await getPathSize(workshop.path);
411
427
  const cacheBytes = await getPathSize(path.join(cacheDir, id));
@@ -520,17 +536,47 @@ export async function cleanup({ silent = false, force = false, targets, workshop
520
536
  selectedTargets.push('workshops');
521
537
  }
522
538
  }
523
- const { reposDir, cacheDir, legacyCacheDir, dataPaths, offlineVideosDir } = await resolveCleanupPaths(paths);
524
- const allWorkshops = await listWorkshopsInDirectory(reposDir);
525
- const workshopSummaries = await getWorkshopSummaries({
526
- workshops: allWorkshops,
527
- cacheDir,
528
- });
529
- const workshopBytes = workshopSummaries.reduce((total, workshop) => total + workshop.sizeBytes, 0);
530
- const legacyCacheBytes = await getPathSize(legacyCacheDir);
531
- const cacheBytes = (await getPathSize(cacheDir)) + legacyCacheBytes;
532
- const offlineVideosBytes = await getPathSize(offlineVideosDir);
533
- const { preferencesBytes, authBytes } = await getDataCleanupSizeSummary(dataPaths);
539
+ const analysisSpinner = startSpinner('Scanning local epicshop data...', silent);
540
+ let reposDir = '';
541
+ let cacheDir = '';
542
+ let legacyCacheDir = '';
543
+ let dataPaths = [];
544
+ let offlineVideosDir = '';
545
+ let workshopSummaries = [];
546
+ let workshopBytes = 0;
547
+ let legacyCacheBytes = 0;
548
+ let cacheBytes = 0;
549
+ let offlineVideosBytes = 0;
550
+ let preferencesBytes = 0;
551
+ let authBytes = 0;
552
+ try {
553
+ updateSpinner(analysisSpinner, 'Resolving cleanup locations...');
554
+ ({ reposDir, cacheDir, legacyCacheDir, dataPaths, offlineVideosDir } =
555
+ await resolveCleanupPaths(paths));
556
+ updateSpinner(analysisSpinner, 'Finding installed workshops...');
557
+ const allWorkshops = await listWorkshopsInDirectory(reposDir);
558
+ updateSpinner(analysisSpinner, 'Calculating workshop sizes...');
559
+ workshopSummaries = await getWorkshopSummaries({
560
+ workshops: allWorkshops,
561
+ cacheDir,
562
+ onProgress: (progress) => {
563
+ updateSpinner(analysisSpinner, `Calculating workshop sizes (${progress.current}/${progress.total}): ${progress.workshop.repoName}`);
564
+ },
565
+ });
566
+ workshopBytes = workshopSummaries.reduce((total, workshop) => total + workshop.sizeBytes, 0);
567
+ updateSpinner(analysisSpinner, 'Calculating cache sizes...');
568
+ legacyCacheBytes = await getPathSize(legacyCacheDir);
569
+ const cacheDirBytes = await getPathSize(cacheDir);
570
+ cacheBytes = cacheDirBytes + legacyCacheBytes;
571
+ updateSpinner(analysisSpinner, 'Calculating offline video sizes...');
572
+ offlineVideosBytes = await getPathSize(offlineVideosDir);
573
+ updateSpinner(analysisSpinner, 'Scanning preferences and auth data...');
574
+ ({ preferencesBytes, authBytes } =
575
+ await getDataCleanupSizeSummary(dataPaths));
576
+ }
577
+ finally {
578
+ stopSpinner(analysisSpinner);
579
+ }
534
580
  const cleanupChoices = CLEANUP_TARGETS.map((target) => {
535
581
  const sizeByTarget = {
536
582
  workshops: workshopBytes,
@@ -580,8 +626,17 @@ export async function cleanup({ silent = false, force = false, targets, workshop
580
626
  const selectedWorkshopIds = new Set(selectedWorkshops.map((workshop) => workshop.id));
581
627
  const workshopFileBytes = selectedWorkshops.reduce((total, workshop) => total + workshop.sizeBytes, 0);
582
628
  const workshopCacheBytes = selectedWorkshops.reduce((total, workshop) => total + workshop.cacheBytes, 0);
583
- const offlineVideoIndex = await readOfflineVideoIndex(offlineVideosDir);
584
- const workshopOfflineBytes = await estimateOfflineVideoBytesForWorkshops(offlineVideosDir, offlineVideoIndex, selectedWorkshopIds);
629
+ const selectionSpinner = startSpinner('Calculating workshop cleanup sizes...', silent);
630
+ let workshopOfflineBytes = 0;
631
+ try {
632
+ updateSpinner(selectionSpinner, 'Loading offline video index...');
633
+ const offlineVideoIndex = await readOfflineVideoIndex(offlineVideosDir);
634
+ updateSpinner(selectionSpinner, 'Calculating workshop offline video sizes...');
635
+ workshopOfflineBytes = await estimateOfflineVideoBytesForWorkshops(offlineVideosDir, offlineVideoIndex, selectedWorkshopIds);
636
+ }
637
+ finally {
638
+ stopSpinner(selectionSpinner);
639
+ }
585
640
  const workshopChoices = WORKSHOP_CLEANUP_TARGETS.map((target) => {
586
641
  const sizeByTarget = {
587
642
  files: workshopFileBytes,
@@ -618,14 +673,21 @@ export async function cleanup({ silent = false, force = false, targets, workshop
618
673
  console.log(chalk.gray(message));
619
674
  return { success: true, message, selectedTargets };
620
675
  }
621
- const unpushedSummaries = !silent &&
676
+ let unpushedSummaries = [];
677
+ if (!silent &&
622
678
  selectedWorkshopTargets.includes('files') &&
623
- selectedWorkshops.length > 0
624
- ? await Promise.all(selectedWorkshops.map(async (workshop) => ({
625
- workshop,
626
- unpushedChanges: await getUnpushedChanges(workshop.path),
627
- })))
628
- : [];
679
+ selectedWorkshops.length > 0) {
680
+ const unpushedSpinner = startSpinner('Checking for unpushed workshop changes...', silent);
681
+ try {
682
+ unpushedSummaries = await Promise.all(selectedWorkshops.map(async (workshop) => ({
683
+ workshop,
684
+ unpushedChanges: await getUnpushedChanges(workshop.path),
685
+ })));
686
+ }
687
+ finally {
688
+ stopSpinner(unpushedSpinner);
689
+ }
690
+ }
629
691
  if (!silent) {
630
692
  console.log(chalk.yellow('This will clean up the following:'));
631
693
  if (selectedWorkshopTargets.includes('files')) {
@@ -28,8 +28,9 @@ export type StartOptions = {
28
28
  };
29
29
  export type ConfigOptions = {
30
30
  reposDir?: string;
31
+ preferredEditor?: string;
31
32
  silent?: boolean;
32
- subcommand?: 'reset' | 'delete';
33
+ subcommand?: 'reset' | 'delete' | 'editor';
33
34
  };
34
35
  /**
35
36
  * Add a workshop by cloning from epicweb-dev GitHub org and running setup
@@ -78,6 +78,7 @@ const GitHubRepoSchema = z.object({
78
78
  stargazers_count: z.number(),
79
79
  topics: z.array(z.string()).default([]),
80
80
  archived: z.boolean(),
81
+ default_branch: z.string().optional(),
81
82
  });
82
83
  const GitHubSearchResponseSchema = z.object({
83
84
  total_count: z.number(),
@@ -99,6 +100,20 @@ function resolvePathWithTilde(inputPath) {
99
100
  }
100
101
  return trimmed;
101
102
  }
103
+ function formatEditorChoiceName(editor) {
104
+ return editor.label === editor.command
105
+ ? editor.label
106
+ : `${editor.label} (${editor.command})`;
107
+ }
108
+ async function getInstalledEditorChoices() {
109
+ const { getAvailableEditors } = await import('@epic-web/workshop-utils/launch-editor.server');
110
+ const editors = getAvailableEditors();
111
+ return editors.map((editor) => ({
112
+ name: formatEditorChoiceName(editor),
113
+ value: editor.command,
114
+ description: editor.label === editor.command ? undefined : editor.command,
115
+ }));
116
+ }
102
117
  function parseRepoSpecifier(value) {
103
118
  const trimmed = value.trim();
104
119
  const hashIndex = trimmed.indexOf('#');
@@ -113,8 +128,11 @@ function parseRepoSpecifier(value) {
113
128
  return { repoName, repoRef };
114
129
  }
115
130
  function getGitHubHeaders() {
131
+ return getGitHubHeadersWithAccept('application/vnd.github.v3+json');
132
+ }
133
+ function getGitHubHeadersWithAccept(accept) {
116
134
  const headers = {
117
- Accept: 'application/vnd.github.v3+json',
135
+ Accept: accept,
118
136
  'User-Agent': 'epicshop-cli',
119
137
  };
120
138
  if (GITHUB_TOKEN) {
@@ -122,6 +140,47 @@ function getGitHubHeaders() {
122
140
  }
123
141
  return headers;
124
142
  }
143
+ const DEFAULT_BRANCHES = ['main', 'master'];
144
+ function buildRawPackageJsonUrls(repoName, defaultBranch) {
145
+ const branches = [defaultBranch, ...DEFAULT_BRANCHES].filter((branch) => Boolean(branch));
146
+ const uniqueBranches = Array.from(new Set(branches));
147
+ return uniqueBranches.map((branch) => `https://raw.githubusercontent.com/${GITHUB_ORG}/${repoName}/${branch}/package.json`);
148
+ }
149
+ async function parsePackageJsonResponse(response) {
150
+ try {
151
+ const parsed = PackageJsonSchema.safeParse(await response.json());
152
+ return parsed.success ? parsed.data : null;
153
+ }
154
+ catch {
155
+ return null;
156
+ }
157
+ }
158
+ async function fetchPackageJsonFromUrl(url, headers) {
159
+ try {
160
+ const response = await fetch(url, { headers });
161
+ if (!response.ok)
162
+ return null;
163
+ return await parsePackageJsonResponse(response);
164
+ }
165
+ catch {
166
+ return null;
167
+ }
168
+ }
169
+ function normalizeProductHost(host) {
170
+ if (!host)
171
+ return undefined;
172
+ const normalized = host
173
+ .replace(/^https?:\/\//, '')
174
+ .replace(/\/$/, '')
175
+ .toLowerCase();
176
+ if (normalized === 'epicweb.dev')
177
+ return 'www.epicweb.dev';
178
+ if (normalized === 'epicreact.dev')
179
+ return 'www.epicreact.dev';
180
+ if (normalized === 'epicai.pro')
181
+ return 'www.epicai.pro';
182
+ return normalized;
183
+ }
125
184
  /**
126
185
  * Fetch available workshops from GitHub (epicweb-dev org with 'workshop' topic)
127
186
  */
@@ -175,31 +234,30 @@ async function fetchAvailableWorkshops() {
175
234
  /**
176
235
  * Fetch a workshop's package.json from GitHub raw content
177
236
  */
178
- async function fetchWorkshopPackageJson(repoName) {
237
+ async function fetchWorkshopPackageJson(repo) {
179
238
  return cachified({
180
- key: `github-package-json:${repoName}`,
239
+ key: `github-package-json:${repo.name}`,
181
240
  cache: githubCache,
182
241
  ttl: 1000 * 60 * 60 * 6, // 6 hours
183
242
  swr: 1000 * 60 * 60 * 24 * 30, // 30 days stale-while-revalidate
184
243
  checkValue: PackageJsonSchema.nullable(),
185
- async getFreshValue() {
186
- const url = `https://raw.githubusercontent.com/${GITHUB_ORG}/${repoName}/main/package.json`;
187
- const response = await fetch(url, {
188
- headers: {
189
- 'User-Agent': 'epicshop-cli',
190
- ...(GITHUB_TOKEN ? { Authorization: `Bearer ${GITHUB_TOKEN}` } : {}),
191
- },
192
- });
193
- if (!response.ok) {
194
- return null;
195
- }
196
- try {
197
- const parsed = PackageJsonSchema.safeParse(await response.json());
198
- return parsed.success ? parsed.data : null;
244
+ async getFreshValue(context) {
245
+ const rawHeaders = getGitHubHeadersWithAccept('application/vnd.github.raw');
246
+ const rawUrls = buildRawPackageJsonUrls(repo.name, repo.default_branch);
247
+ for (const url of rawUrls) {
248
+ const packageJson = await fetchPackageJsonFromUrl(url, rawHeaders);
249
+ if (packageJson) {
250
+ return packageJson;
251
+ }
199
252
  }
200
- catch {
201
- return null;
253
+ const apiUrl = `https://api.github.com/repos/${GITHUB_ORG}/${repo.name}/contents/package.json`;
254
+ const apiPackageJson = await fetchPackageJsonFromUrl(apiUrl, getGitHubHeadersWithAccept('application/vnd.github.raw'));
255
+ if (apiPackageJson) {
256
+ return apiPackageJson;
202
257
  }
258
+ context.metadata.ttl = 1000 * 60;
259
+ context.metadata.swr = 0;
260
+ return null;
203
261
  },
204
262
  });
205
263
  }
@@ -207,13 +265,17 @@ async function fetchWorkshopPackageJson(repoName) {
207
265
  * Enrich workshops with metadata from their package.json files
208
266
  */
209
267
  async function enrichWorkshopsWithMetadata(workshops) {
210
- const packageJsons = await Promise.all(workshops.map((w) => fetchWorkshopPackageJson(w.name)));
268
+ const packageJsons = await Promise.all(workshops.map((w) => fetchWorkshopPackageJson({
269
+ name: w.name,
270
+ default_branch: w.default_branch,
271
+ })));
211
272
  return workshops.map((workshop, index) => {
212
273
  const packageJson = packageJsons[index];
213
274
  const config = packageJson ? parseEpicshopConfig(packageJson) : null;
275
+ const productHost = normalizeProductHost(config?.product?.host);
214
276
  return {
215
277
  ...workshop,
216
- productHost: config?.product?.host,
278
+ productHost,
217
279
  productSlug: config?.product?.slug,
218
280
  productDisplayName: config?.product?.displayName,
219
281
  instructorName: config?.instructor?.name,
@@ -251,11 +313,14 @@ async function checkWorkshopDownloadStatus(workshops) {
251
313
  /**
252
314
  * Check access for workshops in parallel
253
315
  */
254
- async function checkWorkshopAccess(workshops) {
316
+ async function checkWorkshopAccess(workshops, authStatusMap) {
255
317
  const accessResults = await Promise.all(workshops.map(async (workshop) => {
256
318
  if (!workshop.productHost || !workshop.productSlug) {
257
319
  return undefined;
258
320
  }
321
+ if (authStatusMap?.get(workshop.productHost) === false) {
322
+ return undefined;
323
+ }
259
324
  return userHasAccessToWorkshop({
260
325
  productHost: workshop.productHost,
261
326
  workshopSlug: workshop.productSlug,
@@ -266,6 +331,57 @@ async function checkWorkshopAccess(workshops) {
266
331
  hasAccess: accessResults[index],
267
332
  }));
268
333
  }
334
+ async function resolvePreferredEditor({ silent, }) {
335
+ const { getPreferredEditor, setPreferredEditor } = await import('@epic-web/workshop-utils/workshops.server');
336
+ const { getDefaultEditorCommand, formatEditorLabel } = await import('@epic-web/workshop-utils/launch-editor.server');
337
+ const preferredEditor = await getPreferredEditor();
338
+ if (preferredEditor)
339
+ return preferredEditor;
340
+ const defaultEditor = getDefaultEditorCommand();
341
+ if (silent)
342
+ return defaultEditor;
343
+ assertCanPrompt({
344
+ reason: 'choose a preferred editor',
345
+ hints: ['Set it later with: npx epicshop config editor'],
346
+ });
347
+ const { select, confirm } = await import('@inquirer/prompts');
348
+ const availableEditors = await getInstalledEditorChoices();
349
+ if (defaultEditor) {
350
+ const defaultLabel = formatEditorLabel(defaultEditor);
351
+ if (availableEditors.length === 0) {
352
+ const useDefault = await confirm({
353
+ message: `Use ${defaultLabel} to open workshops?`,
354
+ default: true,
355
+ });
356
+ if (useDefault) {
357
+ await setPreferredEditor(defaultEditor);
358
+ return defaultEditor;
359
+ }
360
+ return null;
361
+ }
362
+ const decision = await select({
363
+ message: `Open workshops with ${defaultLabel}?`,
364
+ choices: [
365
+ { name: `Use ${defaultLabel}`, value: 'use' },
366
+ { name: 'Choose a different editor', value: 'choose' },
367
+ ],
368
+ });
369
+ if (decision === 'use') {
370
+ await setPreferredEditor(defaultEditor);
371
+ return defaultEditor;
372
+ }
373
+ }
374
+ if (availableEditors.length === 0) {
375
+ console.log(chalk.yellow('⚠️ No supported editors detected. Set EPICSHOP_EDITOR or install a supported editor.'));
376
+ return defaultEditor;
377
+ }
378
+ const selectedEditor = await select({
379
+ message: 'Select your preferred editor:',
380
+ choices: availableEditors,
381
+ });
382
+ await setPreferredEditor(selectedEditor);
383
+ return selectedEditor;
384
+ }
269
385
  /**
270
386
  * Helper function to add a single workshop by repo name
271
387
  * This handles the actual cloning and setup logic
@@ -497,7 +613,7 @@ export async function add(options) {
497
613
  console.log();
498
614
  }
499
615
  spinner.start('Checking access...');
500
- enrichedWorkshops = await checkWorkshopAccess(enrichedWorkshops);
616
+ enrichedWorkshops = await checkWorkshopAccess(enrichedWorkshops, authStatusMap);
501
617
  enrichedWorkshops.sort((a, b) => {
502
618
  const aHasAccess = a.hasAccess === true;
503
619
  const aIsDownloaded = a.isDownloaded === true;
@@ -1294,6 +1410,10 @@ export async function openWorkshop(options = {}) {
1294
1410
  console.log(chalk.red(`❌ ${message}`));
1295
1411
  return { success: false, message };
1296
1412
  }
1413
+ const preferredEditor = await resolvePreferredEditor({ silent });
1414
+ if (preferredEditor) {
1415
+ process.env.EPICSHOP_EDITOR = preferredEditor;
1416
+ }
1297
1417
  if (!silent) {
1298
1418
  console.log(chalk.cyan(`📂 Opening ${chalk.bold(workshopToOpen.title)} in your editor...`));
1299
1419
  console.log(chalk.gray(` Path: ${workshopToOpen.path}\n`));
@@ -1329,7 +1449,7 @@ export async function openWorkshop(options = {}) {
1329
1449
  export async function config(options = {}) {
1330
1450
  const { silent = false } = options;
1331
1451
  try {
1332
- const { getReposDirectory, setReposDirectory, isReposDirectoryConfigured, loadConfig, saveConfig, getDefaultReposDir, deleteConfig, } = await import('@epic-web/workshop-utils/workshops.server');
1452
+ const { getReposDirectory, setReposDirectory, isReposDirectoryConfigured, loadConfig, saveConfig, getDefaultReposDir, getPreferredEditor, setPreferredEditor, clearPreferredEditor, deleteConfig, } = await import('@epic-web/workshop-utils/workshops.server');
1333
1453
  // Handle reset subcommand
1334
1454
  if (options.subcommand === 'reset' || options.subcommand === 'delete') {
1335
1455
  if (silent) {
@@ -1369,6 +1489,13 @@ export async function config(options = {}) {
1369
1489
  if (!silent)
1370
1490
  console.log(chalk.green(`✅ ${message}`));
1371
1491
  }
1492
+ if (options.preferredEditor) {
1493
+ await setPreferredEditor(options.preferredEditor);
1494
+ const message = `Preferred editor set to: ${options.preferredEditor}`;
1495
+ messages.push(message);
1496
+ if (!silent)
1497
+ console.log(chalk.green(`✅ ${message}`));
1498
+ }
1372
1499
  // If either option was set, return now
1373
1500
  if (messages.length > 0) {
1374
1501
  return { success: true, message: messages.join('; ') };
@@ -1376,20 +1503,36 @@ export async function config(options = {}) {
1376
1503
  if (silent) {
1377
1504
  // In silent mode, just return current config
1378
1505
  const reposDir = await getReposDirectory();
1379
- return { success: true, message: `Repos directory: ${reposDir}` };
1506
+ const preferredEditor = await getPreferredEditor();
1507
+ const editorMessage = preferredEditor
1508
+ ? `Preferred editor: ${preferredEditor}`
1509
+ : 'Preferred editor: not set';
1510
+ return {
1511
+ success: true,
1512
+ message: `Repos directory: ${reposDir}; ${editorMessage}`,
1513
+ };
1380
1514
  }
1381
1515
  // Interactive config selection
1382
1516
  assertCanPrompt({
1383
1517
  reason: 'select a configuration option',
1384
1518
  hints: [
1385
1519
  'Set repos dir directly: npx epicshop config --repos-dir <path>',
1520
+ 'Set preferred editor: npx epicshop config --editor <command>',
1386
1521
  'Delete config non-interactively: npx epicshop config reset --silent',
1387
1522
  ],
1388
1523
  });
1389
- const { search, confirm } = await import('@inquirer/prompts');
1524
+ const { search, confirm, select } = await import('@inquirer/prompts');
1525
+ const { formatEditorLabel } = await import('@epic-web/workshop-utils/launch-editor.server');
1390
1526
  const reposDir = await getReposDirectory();
1391
1527
  const isConfigured = await isReposDirectoryConfigured();
1392
1528
  const defaultDir = getDefaultReposDir();
1529
+ const preferredEditor = await getPreferredEditor();
1530
+ const preferredEditorDescription = preferredEditor
1531
+ ? formatEditorChoiceName({
1532
+ label: formatEditorLabel(preferredEditor),
1533
+ command: preferredEditor,
1534
+ })
1535
+ : 'Not set';
1393
1536
  // Build config options
1394
1537
  const configOptions = [
1395
1538
  {
@@ -1397,13 +1540,92 @@ export async function config(options = {}) {
1397
1540
  value: 'repos-dir',
1398
1541
  description: isConfigured ? reposDir : `${reposDir} (default)`,
1399
1542
  },
1543
+ {
1544
+ name: 'Preferred editor',
1545
+ value: 'preferred-editor',
1546
+ description: preferredEditorDescription,
1547
+ },
1400
1548
  {
1401
1549
  name: `Reset config file`,
1402
1550
  value: 'reset',
1403
1551
  description: 'Delete config file and reset all settings to defaults',
1404
1552
  },
1405
1553
  ];
1554
+ const handlePreferredEditorConfig = async () => {
1555
+ console.log();
1556
+ console.log(chalk.bold(' Current value:'));
1557
+ if (preferredEditor) {
1558
+ console.log(chalk.white(` ${preferredEditorDescription}`));
1559
+ }
1560
+ else {
1561
+ console.log(chalk.gray(' Not set'));
1562
+ }
1563
+ console.log();
1564
+ const actionChoices = [
1565
+ {
1566
+ name: 'Edit',
1567
+ value: 'edit',
1568
+ description: 'Choose a preferred editor',
1569
+ },
1570
+ ...(preferredEditor
1571
+ ? [
1572
+ {
1573
+ name: 'Remove',
1574
+ value: 'remove',
1575
+ description: 'Clear the preferred editor',
1576
+ },
1577
+ ]
1578
+ : []),
1579
+ {
1580
+ name: 'Cancel',
1581
+ value: 'cancel',
1582
+ description: 'Go back without changes',
1583
+ },
1584
+ ];
1585
+ const action = await search({
1586
+ message: 'What would you like to do?',
1587
+ source: async (input) => {
1588
+ if (!input)
1589
+ return actionChoices;
1590
+ return matchSorter(actionChoices, input, {
1591
+ keys: ['name', 'value', 'description'],
1592
+ });
1593
+ },
1594
+ });
1595
+ if (action === 'edit') {
1596
+ const editorChoices = await getInstalledEditorChoices();
1597
+ if (editorChoices.length === 0) {
1598
+ console.log(chalk.yellow('⚠️ No supported editors detected. Set EPICSHOP_EDITOR or install a supported editor.'));
1599
+ return {
1600
+ success: true,
1601
+ message: 'No supported editors detected',
1602
+ };
1603
+ }
1604
+ const selectedEditor = await select({
1605
+ message: 'Select your preferred editor:',
1606
+ choices: editorChoices,
1607
+ });
1608
+ await setPreferredEditor(selectedEditor);
1609
+ console.log();
1610
+ console.log(chalk.green(`✅ Preferred editor set to: ${chalk.bold(selectedEditor)}`));
1611
+ return {
1612
+ success: true,
1613
+ message: `Preferred editor set to: ${selectedEditor}`,
1614
+ };
1615
+ }
1616
+ if (action === 'remove') {
1617
+ await clearPreferredEditor();
1618
+ console.log();
1619
+ console.log(chalk.green('✅ Preferred editor cleared.'));
1620
+ return { success: true, message: 'Preferred editor cleared' };
1621
+ }
1622
+ console.log(chalk.gray('\nNo changes made.'));
1623
+ return { success: true, message: 'Cancelled' };
1624
+ };
1406
1625
  console.log(chalk.bold.cyan('\n⚙️ Workshop Configuration\n'));
1626
+ if (options.subcommand === 'editor') {
1627
+ return await handlePreferredEditorConfig();
1628
+ }
1407
1629
  const selectedConfig = await search({
1408
1630
  message: 'Select a setting to configure:',
1409
1631
  source: async (input) => {
@@ -1433,6 +1655,9 @@ export async function config(options = {}) {
1433
1655
  return { success: true, message: 'Cancelled' };
1434
1656
  }
1435
1657
  }
1658
+ if (selectedConfig === 'preferred-editor') {
1659
+ return await handlePreferredEditorConfig();
1660
+ }
1436
1661
  if (selectedConfig === 'repos-dir') {
1437
1662
  // Show current value and actions
1438
1663
  console.log();
@@ -1842,7 +2067,7 @@ async function promptAndSetupAccessibleWorkshops() {
1842
2067
  else {
1843
2068
  spinner.start('Checking access...');
1844
2069
  }
1845
- enrichedWorkshops = await checkWorkshopAccess(enrichedWorkshops);
2070
+ enrichedWorkshops = await checkWorkshopAccess(enrichedWorkshops, authStatusMap);
1846
2071
  spinner.succeed(`Found ${enrichedWorkshops.length} available workshops`);
1847
2072
  }
1848
2073
  catch (error) {
@@ -1851,21 +2076,33 @@ async function promptAndSetupAccessibleWorkshops() {
1851
2076
  console.log(chalk.yellow(`⚠️ Could not load workshops right now. Skipping this step.\n`));
1852
2077
  return;
1853
2078
  }
1854
- const candidates = enrichedWorkshops.filter((w) => w.name !== TUTORIAL_REPO && w.hasAccess === true && !w.isDownloaded);
1855
- if (candidates.length === 0) {
2079
+ const availableWorkshops = enrichedWorkshops.filter((w) => w.name !== TUTORIAL_REPO && !w.isDownloaded);
2080
+ const accessibleWorkshops = availableWorkshops.filter((w) => w.hasAccess === true);
2081
+ const selectableWorkshops = accessibleWorkshops.length > 0
2082
+ ? accessibleWorkshops
2083
+ : availableWorkshops.filter((w) => w.hasAccess !== false);
2084
+ if (selectableWorkshops.length === 0) {
1856
2085
  console.log(chalk.gray('No additional workshops to set up right now (either none found, none accessible, or already downloaded).\n'));
1857
2086
  return;
1858
2087
  }
1859
2088
  console.log();
1860
- console.log(chalk.bold.cyan('Available Workshops You Have Access To\n'));
2089
+ const header = accessibleWorkshops.length > 0
2090
+ ? 'Available Workshops You Have Access To\n'
2091
+ : 'Available Workshops\n';
2092
+ console.log(chalk.bold.cyan(header));
1861
2093
  console.log(chalk.gray('Icon Key:'));
1862
2094
  console.log(chalk.gray(` 🚀 EpicReact.dev`));
1863
2095
  console.log(chalk.gray(` 🌌 EpicWeb.dev`));
1864
2096
  console.log(chalk.gray(` ⚡ EpicAI.pro`));
1865
2097
  console.log(chalk.gray(` 🔑 You have access to this workshop`));
1866
2098
  console.log();
2099
+ if (accessibleWorkshops.length === 0) {
2100
+ console.log(chalk.yellow('💡 We could not confirm access for available workshops. You can still select them to try setup.'));
2101
+ console.log(chalk.gray(` To verify access, log in with: ${chalk.cyan('npx epicshop auth')}`));
2102
+ console.log();
2103
+ }
1867
2104
  // Filter workshops that are part of a product (for "All My Workshops" option)
1868
- const workshopsWithProduct = candidates.filter((w) => w.productSlug);
2105
+ const workshopsWithProduct = accessibleWorkshops.filter((w) => w.productSlug);
1869
2106
  // Group workshops by product for quick-select options
1870
2107
  const workshopsByProduct = new Map();
1871
2108
  for (const w of workshopsWithProduct) {
@@ -1934,11 +2171,11 @@ async function promptAndSetupAccessibleWorkshops() {
1934
2171
  }
1935
2172
  else {
1936
2173
  // Show checkbox for individual selection
1937
- const individualChoices = candidates.map((w) => {
2174
+ const individualChoices = selectableWorkshops.map((w) => {
1938
2175
  const productIcon = w.productHost
1939
2176
  ? PRODUCT_ICONS[w.productHost] || ''
1940
2177
  : '';
1941
- const accessIcon = chalk.yellow('🔑');
2178
+ const accessIcon = w.hasAccess === true ? chalk.yellow('🔑') : '';
1942
2179
  const name = [productIcon, w.title || w.name, accessIcon]
1943
2180
  .filter(Boolean)
1944
2181
  .join(' ');
@@ -1966,7 +2203,7 @@ async function promptAndSetupAccessibleWorkshops() {
1966
2203
  }
1967
2204
  // Create a map from repo name to workshop title for nice display
1968
2205
  const repoToTitle = new Map();
1969
- for (const w of candidates) {
2206
+ for (const w of selectableWorkshops) {
1970
2207
  repoToTitle.set(w.name, w.title || w.name);
1971
2208
  }
1972
2209
  const getDisplayName = (repo) => repoToTitle.get(repo) || repo;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "epicshop",
3
- "version": "6.75.1",
3
+ "version": "6.76.0",
4
4
  "publishConfig": {
5
5
  "access": "public"
6
6
  },
@@ -99,7 +99,7 @@
99
99
  "build:watch": "nx watch --projects=epicshop -- nx run \\$NX_PROJECT_NAME:build"
100
100
  },
101
101
  "dependencies": {
102
- "@epic-web/workshop-utils": "6.75.1",
102
+ "@epic-web/workshop-utils": "6.76.0",
103
103
  "@inquirer/prompts": "^8.2.0",
104
104
  "@sentry/node": "^10.36.0",
105
105
  "chalk": "^5.6.2",