epicshop 6.84.4 → 6.84.6

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
@@ -736,6 +736,68 @@ const cli = yargs(args)
736
736
  if (!result.success) {
737
737
  process.exit(1);
738
738
  }
739
+ })
740
+ .command('admin <subcommand>', false, (yargs) => {
741
+ return yargs
742
+ .positional('subcommand', {
743
+ describe: 'Admin subcommand',
744
+ type: 'string',
745
+ choices: ['launch-readiness'],
746
+ })
747
+ .option('workshop-dir', {
748
+ alias: 'w',
749
+ type: 'string',
750
+ description: 'Path to a workshop directory to use as context (instead of the current working directory)',
751
+ })
752
+ .option('silent', {
753
+ alias: 's',
754
+ type: 'boolean',
755
+ description: 'Run without output logs',
756
+ default: false,
757
+ })
758
+ .option('skip-remote', {
759
+ type: 'boolean',
760
+ description: 'Skip the remote "product lessons" check (only run local checks)',
761
+ default: false,
762
+ })
763
+ .option('skip-head', {
764
+ type: 'boolean',
765
+ description: 'Skip checking that EpicVideo urls return 200 to HEAD (network required)',
766
+ default: false,
767
+ })
768
+ .example('$0 admin launch-readiness', 'Check workshop launch readiness (hidden command)');
769
+ }, async (argv) => {
770
+ const { findWorkshopRoot } = await import("./commands/workshops.js");
771
+ const workshopRoot = await findWorkshopRoot(resolveWorkshopContextCwd(argv.workshopDir));
772
+ if (!workshopRoot) {
773
+ console.error(chalk.red('❌ Workshop not found. Please cd into a workshop directory or pass --workshop-dir.'));
774
+ process.exit(1);
775
+ }
776
+ const originalCwd = process.cwd();
777
+ process.chdir(workshopRoot);
778
+ process.env.EPICSHOP_CONTEXT_CWD = workshopRoot;
779
+ try {
780
+ switch (argv.subcommand) {
781
+ case 'launch-readiness': {
782
+ const { launchReadiness } = await import("./commands/admin.js");
783
+ const result = await launchReadiness({
784
+ silent: argv.silent,
785
+ skipRemote: argv.skipRemote,
786
+ skipHead: argv.skipHead,
787
+ });
788
+ if (!result.success)
789
+ process.exit(1);
790
+ break;
791
+ }
792
+ default: {
793
+ console.error(chalk.red(`❌ Unknown admin subcommand: ${argv.subcommand}`));
794
+ process.exit(1);
795
+ }
796
+ }
797
+ }
798
+ finally {
799
+ process.chdir(originalCwd);
800
+ }
739
801
  })
740
802
  .command('playground [subcommand] [target]', 'Manage the playground environment (context-aware)', (yargs) => {
741
803
  return yargs
@@ -0,0 +1,22 @@
1
+ export type LaunchReadinessOptions = {
2
+ /**
3
+ * Defaults to `process.env.EPICSHOP_CONTEXT_CWD ?? process.cwd()`.
4
+ * Primarily useful for tests.
5
+ */
6
+ workshopRoot?: string;
7
+ silent?: boolean;
8
+ /**
9
+ * Skip the remote "product lessons" check (only run local checks).
10
+ */
11
+ skipRemote?: boolean;
12
+ /**
13
+ * Skip checking that EpicVideo urls respond 200 to HEAD.
14
+ */
15
+ skipHead?: boolean;
16
+ };
17
+ export type LaunchReadinessResult = {
18
+ success: boolean;
19
+ message?: string;
20
+ error?: Error;
21
+ };
22
+ export declare function launchReadiness(options?: LaunchReadinessOptions): Promise<LaunchReadinessResult>;
@@ -0,0 +1,873 @@
1
+ import fs from 'node:fs/promises';
2
+ import path from 'node:path';
3
+ import { compileMdx } from '@epic-web/workshop-utils/compile-mdx.server';
4
+ import { getAuthInfo } from '@epic-web/workshop-utils/db.server';
5
+ import { getErrorMessage } from '@epic-web/workshop-utils/utils';
6
+ import chalk from 'chalk';
7
+ import { pathExists } from "../../utils/filesystem.js";
8
+ function stripEpicAiSlugSuffix(value) {
9
+ // EpicAI embeds sometimes include a `~...` suffix in the slug segment.
10
+ return value.replace(/~[^ ]*$/, '');
11
+ }
12
+ function normalizeHost(host) {
13
+ return host.toLowerCase().replace(/^www\./, '');
14
+ }
15
+ function parseEpicLessonSlugFromEmbedUrl(urlString) {
16
+ const parseSegments = (segments) => {
17
+ if (segments.length === 0)
18
+ return null;
19
+ const last = segments.at(-1) ?? null;
20
+ if (!last)
21
+ return null;
22
+ if (last === 'problem' || last === 'solution' || last === 'embed') {
23
+ const slug = segments.at(-2) ?? null;
24
+ return slug ? stripEpicAiSlugSuffix(slug) : null;
25
+ }
26
+ return stripEpicAiSlugSuffix(last);
27
+ };
28
+ try {
29
+ const url = new URL(urlString);
30
+ const segments = url.pathname.split('/').filter(Boolean);
31
+ return parseSegments(segments);
32
+ }
33
+ catch {
34
+ // Fall back to naive parsing (best-effort).
35
+ const withoutHash = urlString.split('#')[0] ?? urlString;
36
+ const withoutQuery = withoutHash.split('?')[0] ?? withoutHash;
37
+ const segments = withoutQuery.split('/').filter(Boolean);
38
+ return parseSegments(segments);
39
+ }
40
+ }
41
+ function formatIssue(issue, workshopRoot) {
42
+ const icon = issue.level === 'error' ? chalk.red('❌') : chalk.yellow('⚠️ ');
43
+ const filePart = issue.file
44
+ ? chalk.gray(` (${path.relative(workshopRoot, issue.file)})`)
45
+ : '';
46
+ return `${icon} ${issue.message}${filePart}`;
47
+ }
48
+ async function isDirectory(targetPath) {
49
+ try {
50
+ return (await fs.stat(targetPath)).isDirectory();
51
+ }
52
+ catch {
53
+ return false;
54
+ }
55
+ }
56
+ async function resolveMdxFile(dir, baseName) {
57
+ const mdx = path.join(dir, `${baseName}.mdx`);
58
+ if (await pathExists(mdx))
59
+ return mdx;
60
+ return null;
61
+ }
62
+ async function buildExpectedFiles({ workshopRoot, exerciseDirName, }) {
63
+ const issues = [];
64
+ const files = [];
65
+ const contentFiles = [];
66
+ const exerciseRoot = path.join(workshopRoot, 'exercises', exerciseDirName);
67
+ const exerciseIntro = await resolveMdxFile(exerciseRoot, 'README');
68
+ const exerciseSummary = await resolveMdxFile(exerciseRoot, 'FINISHED');
69
+ if (!exerciseIntro) {
70
+ issues.push({
71
+ level: 'error',
72
+ code: 'missing-exercise-readme',
73
+ message: `Missing exercise intro file (expected README.mdx)`,
74
+ file: path.join(exerciseRoot, 'README.mdx'),
75
+ });
76
+ }
77
+ else {
78
+ files.push({
79
+ kind: 'exercise-intro',
80
+ fullPath: exerciseIntro,
81
+ relativePath: path.relative(workshopRoot, exerciseIntro),
82
+ });
83
+ contentFiles.push({
84
+ fullPath: exerciseIntro,
85
+ relativePath: path.relative(workshopRoot, exerciseIntro),
86
+ });
87
+ }
88
+ if (!exerciseSummary) {
89
+ issues.push({
90
+ level: 'error',
91
+ code: 'missing-exercise-finished',
92
+ message: `Missing exercise summary file (expected FINISHED.mdx)`,
93
+ file: path.join(exerciseRoot, 'FINISHED.mdx'),
94
+ });
95
+ }
96
+ else {
97
+ files.push({
98
+ kind: 'exercise-summary',
99
+ fullPath: exerciseSummary,
100
+ relativePath: path.relative(workshopRoot, exerciseSummary),
101
+ });
102
+ contentFiles.push({
103
+ fullPath: exerciseSummary,
104
+ relativePath: path.relative(workshopRoot, exerciseSummary),
105
+ });
106
+ }
107
+ let entries = [];
108
+ try {
109
+ entries = await fs.readdir(exerciseRoot, { withFileTypes: true });
110
+ }
111
+ catch (error) {
112
+ issues.push({
113
+ level: 'error',
114
+ code: 'exercise-readdir-failed',
115
+ message: `Failed to read exercise directory contents: ${getErrorMessage(error)}`,
116
+ file: exerciseRoot,
117
+ });
118
+ return { files, contentFiles, issues };
119
+ }
120
+ const stepDirRegex = /^(?<stepNumber>\d+)\.(?<type>problem|solution)(\..*)?$/;
121
+ const stepsByNumber = new Map();
122
+ for (const entry of entries) {
123
+ if (!entry.isDirectory())
124
+ continue;
125
+ const match = stepDirRegex.exec(entry.name);
126
+ if (!match?.groups)
127
+ continue;
128
+ const stepNumber = Number(match.groups.stepNumber);
129
+ const type = match.groups.type;
130
+ if (!Number.isFinite(stepNumber) || stepNumber <= 0)
131
+ continue;
132
+ const current = stepsByNumber.get(stepNumber) ?? {
133
+ problems: [],
134
+ solutions: [],
135
+ };
136
+ const fullStepDir = path.join(exerciseRoot, entry.name);
137
+ if (type === 'problem')
138
+ current.problems.push(fullStepDir);
139
+ if (type === 'solution')
140
+ current.solutions.push(fullStepDir);
141
+ stepsByNumber.set(stepNumber, current);
142
+ }
143
+ if (stepsByNumber.size === 0) {
144
+ issues.push({
145
+ level: 'warning',
146
+ code: 'no-steps-found',
147
+ message: 'No step app directories found in this exercise (expected folders like "01.problem" and "01.solution")',
148
+ file: exerciseRoot,
149
+ });
150
+ }
151
+ for (const [stepNumber, dirs] of [...stepsByNumber.entries()].sort((a, b) => a[0] - b[0])) {
152
+ if (dirs.problems.length === 0) {
153
+ issues.push({
154
+ level: 'error',
155
+ code: 'missing-step-problem-dir',
156
+ message: `Missing problem app directory for step ${stepNumber}`,
157
+ file: exerciseRoot,
158
+ });
159
+ }
160
+ if (dirs.solutions.length === 0) {
161
+ issues.push({
162
+ level: 'error',
163
+ code: 'missing-step-solution-dir',
164
+ message: `Missing solution app directory for step ${stepNumber}`,
165
+ file: exerciseRoot,
166
+ });
167
+ }
168
+ if (dirs.problems.length > 1) {
169
+ issues.push({
170
+ level: 'warning',
171
+ code: 'multiple-step-problem-dirs',
172
+ message: `Multiple problem app directories found for step ${stepNumber}`,
173
+ file: exerciseRoot,
174
+ });
175
+ }
176
+ if (dirs.solutions.length > 1) {
177
+ issues.push({
178
+ level: 'warning',
179
+ code: 'multiple-step-solution-dirs',
180
+ message: `Multiple solution app directories found for step ${stepNumber}`,
181
+ file: exerciseRoot,
182
+ });
183
+ }
184
+ for (const problemDir of dirs.problems) {
185
+ const problemReadme = await resolveMdxFile(problemDir, 'README');
186
+ if (!problemReadme) {
187
+ issues.push({
188
+ level: 'error',
189
+ code: 'missing-step-problem-readme',
190
+ message: `Missing step problem README.mdx for step ${stepNumber}`,
191
+ file: path.join(problemDir, 'README.mdx'),
192
+ });
193
+ }
194
+ else {
195
+ files.push({
196
+ kind: 'step-problem',
197
+ fullPath: problemReadme,
198
+ relativePath: path.relative(workshopRoot, problemReadme),
199
+ });
200
+ contentFiles.push({
201
+ fullPath: problemReadme,
202
+ relativePath: path.relative(workshopRoot, problemReadme),
203
+ });
204
+ }
205
+ }
206
+ for (const solutionDir of dirs.solutions) {
207
+ const solutionReadme = await resolveMdxFile(solutionDir, 'README');
208
+ if (!solutionReadme) {
209
+ issues.push({
210
+ level: 'error',
211
+ code: 'missing-step-solution-readme',
212
+ message: `Missing step solution README.mdx for step ${stepNumber}`,
213
+ file: path.join(solutionDir, 'README.mdx'),
214
+ });
215
+ }
216
+ else {
217
+ files.push({
218
+ kind: 'step-solution',
219
+ fullPath: solutionReadme,
220
+ relativePath: path.relative(workshopRoot, solutionReadme),
221
+ });
222
+ contentFiles.push({
223
+ fullPath: solutionReadme,
224
+ relativePath: path.relative(workshopRoot, solutionReadme),
225
+ });
226
+ }
227
+ }
228
+ }
229
+ return { files, contentFiles, issues };
230
+ }
231
+ async function fetchRemoteWorkshopLessonSlugs({ productHost, workshopSlug, }) {
232
+ const url = `https://${productHost}/api/workshops/${encodeURIComponent(workshopSlug)}`;
233
+ const fetchOnce = async (accessToken) => {
234
+ const timeout = AbortSignal.timeout(15_000);
235
+ const headers = {};
236
+ if (accessToken)
237
+ headers.authorization = `Bearer ${accessToken}`;
238
+ return fetch(url, { headers, signal: timeout });
239
+ };
240
+ let response = null;
241
+ try {
242
+ response = await fetchOnce();
243
+ }
244
+ catch (error) {
245
+ return {
246
+ status: 'error',
247
+ message: `Failed to fetch product workshop data: ${getErrorMessage(error)}`,
248
+ };
249
+ }
250
+ if (response.status === 401 || response.status === 403) {
251
+ const authInfo = await getAuthInfo({ productHost }).catch(() => null);
252
+ const accessToken = authInfo?.tokenSet?.access_token;
253
+ if (accessToken) {
254
+ try {
255
+ response = await fetchOnce(accessToken);
256
+ }
257
+ catch (error) {
258
+ return {
259
+ status: 'error',
260
+ message: `Failed to fetch product workshop data (after auth): ${getErrorMessage(error)}`,
261
+ };
262
+ }
263
+ }
264
+ }
265
+ if (!response.ok) {
266
+ const body = await response.text().catch(() => '');
267
+ const hint = response.status === 401 || response.status === 403
268
+ ? ` (try: npx epicshop auth login ${productHost.replace(/^www\./, '')})`
269
+ : response.status === 404
270
+ ? ` (check epicshop.product.host + epicshop.product.slug)`
271
+ : '';
272
+ return {
273
+ status: 'error',
274
+ message: `Product API request failed: ${response.status} ${response.statusText}${hint}${body ? `\n${body}` : ''}`,
275
+ };
276
+ }
277
+ let data;
278
+ try {
279
+ data = await response.json();
280
+ }
281
+ catch (error) {
282
+ return {
283
+ status: 'error',
284
+ message: `Product API response was not valid JSON: ${getErrorMessage(error)}`,
285
+ };
286
+ }
287
+ const resources = data?.resources;
288
+ if (!Array.isArray(resources)) {
289
+ return {
290
+ status: 'error',
291
+ message: `Product API response did not include an array "resources" field`,
292
+ };
293
+ }
294
+ const lessonSlugs = [];
295
+ for (const resource of resources) {
296
+ if (!resource || typeof resource !== 'object')
297
+ continue;
298
+ const r = resource;
299
+ if (r._type === 'lesson') {
300
+ const slug = r.slug;
301
+ if (typeof slug === 'string')
302
+ lessonSlugs.push(slug);
303
+ continue;
304
+ }
305
+ if (r._type === 'section') {
306
+ const lessons = r.lessons;
307
+ if (!Array.isArray(lessons))
308
+ continue;
309
+ for (const lesson of lessons) {
310
+ if (!lesson || typeof lesson !== 'object')
311
+ continue;
312
+ const l = lesson;
313
+ const slug = l.slug;
314
+ if (typeof slug === 'string')
315
+ lessonSlugs.push(slug);
316
+ }
317
+ }
318
+ }
319
+ return { status: 'success', lessonSlugs };
320
+ }
321
+ async function checkMinContentLength({ fullPath, minChars, }) {
322
+ try {
323
+ const raw = await fs.readFile(fullPath, 'utf8');
324
+ const trimmed = raw.trim();
325
+ if (trimmed.length >= minChars)
326
+ return null;
327
+ return {
328
+ level: 'error',
329
+ code: 'mdx-too-short',
330
+ message: `File content too short (<${minChars} chars after trimming)`,
331
+ file: fullPath,
332
+ };
333
+ }
334
+ catch (error) {
335
+ return {
336
+ level: 'error',
337
+ code: 'mdx-read-failed',
338
+ message: `Failed to read file content: ${getErrorMessage(error)}`,
339
+ file: fullPath,
340
+ };
341
+ }
342
+ }
343
+ async function asyncPool(limit, items, mapper) {
344
+ const results = [];
345
+ let nextIndex = 0;
346
+ const workers = Array.from({ length: Math.max(1, limit) }, async () => {
347
+ while (true) {
348
+ const currentIndex = nextIndex++;
349
+ if (currentIndex >= items.length)
350
+ return;
351
+ results[currentIndex] = await mapper(items[currentIndex]);
352
+ }
353
+ });
354
+ await Promise.all(workers);
355
+ return results;
356
+ }
357
+ async function checkEpicVideoUrlsHead({ embedOccurrences, }) {
358
+ const urls = [...embedOccurrences.keys()];
359
+ const issues = [];
360
+ await asyncPool(8, urls, async (urlString) => {
361
+ const usedBy = embedOccurrences.get(urlString) ?? new Set();
362
+ const timeout = AbortSignal.timeout(10_000);
363
+ const headResult = await fetch(urlString, {
364
+ method: 'HEAD',
365
+ redirect: 'follow',
366
+ signal: timeout,
367
+ }).catch((error) => ({ error }));
368
+ if ('error' in headResult) {
369
+ for (const file of usedBy) {
370
+ issues.push({
371
+ level: 'error',
372
+ code: 'epic-video-head-failed',
373
+ message: `EpicVideo url HEAD request failed: ${getErrorMessage(headResult.error)} (${urlString})`,
374
+ file,
375
+ });
376
+ }
377
+ return null;
378
+ }
379
+ if (headResult.status === 200)
380
+ return null;
381
+ let extra = '';
382
+ if (headResult.status === 405) {
383
+ // Some origins disable HEAD. Try a small GET to provide actionable diagnostics.
384
+ const getTimeout = AbortSignal.timeout(10_000);
385
+ const getResult = await fetch(urlString, {
386
+ method: 'GET',
387
+ headers: { range: 'bytes=0-0' },
388
+ redirect: 'follow',
389
+ signal: getTimeout,
390
+ }).catch((error) => ({ error }));
391
+ if ('error' in getResult) {
392
+ extra = ` (GET fallback failed: ${getErrorMessage(getResult.error)})`;
393
+ }
394
+ else {
395
+ extra = ` (GET fallback status: ${getResult.status} ${getResult.statusText})`;
396
+ }
397
+ }
398
+ for (const file of usedBy) {
399
+ issues.push({
400
+ level: 'error',
401
+ code: 'epic-video-head-non-200',
402
+ message: `EpicVideo url HEAD status was ${headResult.status} ${headResult.statusText} (expected 200): ${urlString}${extra}`,
403
+ file,
404
+ });
405
+ }
406
+ return null;
407
+ });
408
+ return issues;
409
+ }
410
+ export async function launchReadiness(options = {}) {
411
+ const workshopRoot = path.resolve(options.workshopRoot ?? process.env.EPICSHOP_CONTEXT_CWD ?? process.cwd());
412
+ process.env.EPICSHOP_CONTEXT_CWD = workshopRoot;
413
+ const { silent = false, skipRemote = false, skipHead = false } = options;
414
+ const issues = [];
415
+ // ----------------------------
416
+ // 1) Configuration validation
417
+ // ----------------------------
418
+ let productHost = null;
419
+ let productSlug = null;
420
+ const packageJsonPath = path.join(workshopRoot, 'package.json');
421
+ let rawPackageJson = null;
422
+ let rawEpicshop = null;
423
+ let rawProduct = null;
424
+ try {
425
+ const raw = await fs.readFile(packageJsonPath, 'utf8');
426
+ rawPackageJson = JSON.parse(raw);
427
+ rawEpicshop =
428
+ rawPackageJson && typeof rawPackageJson === 'object'
429
+ ? rawPackageJson.epicshop
430
+ : null;
431
+ rawProduct =
432
+ rawEpicshop && typeof rawEpicshop === 'object'
433
+ ? rawEpicshop.product
434
+ : null;
435
+ }
436
+ catch (error) {
437
+ issues.push({
438
+ level: 'error',
439
+ code: 'invalid-package-json',
440
+ message: `Failed to read/parse package.json: ${getErrorMessage(error)}`,
441
+ file: packageJsonPath,
442
+ });
443
+ }
444
+ if (!rawEpicshop || typeof rawEpicshop !== 'object') {
445
+ issues.push({
446
+ level: 'error',
447
+ code: 'missing-epicshop-config',
448
+ message: 'Missing `epicshop` configuration in package.json',
449
+ file: packageJsonPath,
450
+ });
451
+ }
452
+ if (!rawProduct || typeof rawProduct !== 'object') {
453
+ issues.push({
454
+ level: 'error',
455
+ code: 'missing-epicshop-product-config',
456
+ message: 'Missing `epicshop.product` configuration in package.json',
457
+ file: packageJsonPath,
458
+ });
459
+ }
460
+ productHost =
461
+ typeof rawProduct?.host === 'string' && rawProduct.host.trim()
462
+ ? rawProduct.host.trim()
463
+ : null;
464
+ productSlug =
465
+ typeof rawProduct?.slug === 'string' && rawProduct.slug.trim()
466
+ ? rawProduct.slug.trim()
467
+ : null;
468
+ if (!productHost) {
469
+ issues.push({
470
+ level: 'error',
471
+ code: 'missing-product-host',
472
+ message: 'Missing `epicshop.product.host` in package.json (required for launch readiness)',
473
+ file: packageJsonPath,
474
+ });
475
+ }
476
+ else if (/^https?:\/\//i.test(productHost)) {
477
+ issues.push({
478
+ level: 'error',
479
+ code: 'invalid-product-host',
480
+ message: '`epicshop.product.host` should be a host (no protocol), e.g. "www.epicweb.dev"',
481
+ file: packageJsonPath,
482
+ });
483
+ productHost = null;
484
+ }
485
+ else if (productHost.includes('/')) {
486
+ issues.push({
487
+ level: 'error',
488
+ code: 'invalid-product-host',
489
+ message: '`epicshop.product.host` should not include a path, e.g. "www.epicweb.dev"',
490
+ file: packageJsonPath,
491
+ });
492
+ productHost = null;
493
+ }
494
+ if (!productSlug) {
495
+ issues.push({
496
+ level: 'error',
497
+ code: 'missing-product-slug',
498
+ message: 'Missing `epicshop.product.slug` in package.json (required for launch readiness)',
499
+ file: packageJsonPath,
500
+ });
501
+ }
502
+ else if (!/^[a-z0-9-]+$/i.test(productSlug)) {
503
+ issues.push({
504
+ level: 'warning',
505
+ code: 'suspicious-product-slug',
506
+ message: '`epicshop.product.slug` contains unusual characters; expected something like "full-stack-foundations"',
507
+ file: packageJsonPath,
508
+ });
509
+ }
510
+ const workshopTitle = typeof rawEpicshop?.title === 'string' ? rawEpicshop.title.trim() : '';
511
+ if (!workshopTitle) {
512
+ issues.push({
513
+ level: 'error',
514
+ code: 'missing-workshop-title',
515
+ message: 'Missing `epicshop.title` in package.json',
516
+ file: packageJsonPath,
517
+ });
518
+ }
519
+ const githubRepo = typeof rawEpicshop?.githubRepo === 'string'
520
+ ? rawEpicshop.githubRepo.trim()
521
+ : '';
522
+ const githubRoot = typeof rawEpicshop?.githubRoot === 'string'
523
+ ? rawEpicshop.githubRoot.trim()
524
+ : '';
525
+ if (!githubRepo && !githubRoot) {
526
+ issues.push({
527
+ level: 'error',
528
+ code: 'missing-github-root',
529
+ message: 'Missing `epicshop.githubRoot` (or `epicshop.githubRepo`) in package.json',
530
+ file: packageJsonPath,
531
+ });
532
+ }
533
+ const discordChannelId = typeof rawProduct?.discordChannelId === 'string'
534
+ ? rawProduct.discordChannelId.trim()
535
+ : '';
536
+ if (!discordChannelId) {
537
+ issues.push({
538
+ level: 'warning',
539
+ code: 'missing-discord-channel-id',
540
+ message: 'Missing `epicshop.product.discordChannelId` (chat UI will be disabled)',
541
+ file: packageJsonPath,
542
+ });
543
+ }
544
+ const discordTagsCount = Array.isArray(rawProduct?.discordTags)
545
+ ? rawProduct.discordTags.filter((tag) => {
546
+ return typeof tag === 'string' && tag.trim().length > 0;
547
+ }).length
548
+ : 0;
549
+ if (discordTagsCount === 0) {
550
+ issues.push({
551
+ level: 'warning',
552
+ code: 'missing-discord-tags',
553
+ message: 'Missing `epicshop.product.discordTags` (chat UI will be disabled or untagged)',
554
+ file: packageJsonPath,
555
+ });
556
+ }
557
+ // --------------------------------------
558
+ // 2) Local video coverage (launch check)
559
+ // --------------------------------------
560
+ const exercisesRoot = path.join(workshopRoot, 'exercises');
561
+ if (!(await isDirectory(exercisesRoot))) {
562
+ issues.push({
563
+ level: 'error',
564
+ code: 'missing-exercises-dir',
565
+ message: 'Missing `exercises/` directory (required for a workshop)',
566
+ file: exercisesRoot,
567
+ });
568
+ }
569
+ const filesToCheck = [];
570
+ const contentFilesToCheck = [];
571
+ // Workshop intro + wrap-up (launch doc)
572
+ const workshopIntro = await resolveMdxFile(exercisesRoot, 'README');
573
+ const workshopWrapUp = await resolveMdxFile(exercisesRoot, 'FINISHED');
574
+ if (!workshopIntro) {
575
+ issues.push({
576
+ level: 'error',
577
+ code: 'missing-workshop-readme',
578
+ message: 'Missing workshop intro file `exercises/README.mdx`',
579
+ file: path.join(exercisesRoot, 'README.mdx'),
580
+ });
581
+ }
582
+ else {
583
+ filesToCheck.push({
584
+ kind: 'workshop-intro',
585
+ fullPath: workshopIntro,
586
+ relativePath: path.relative(workshopRoot, workshopIntro),
587
+ });
588
+ contentFilesToCheck.push({
589
+ fullPath: workshopIntro,
590
+ relativePath: path.relative(workshopRoot, workshopIntro),
591
+ });
592
+ }
593
+ if (!workshopWrapUp) {
594
+ issues.push({
595
+ level: 'error',
596
+ code: 'missing-workshop-finished',
597
+ message: 'Missing workshop wrap-up file `exercises/FINISHED.mdx`',
598
+ file: path.join(exercisesRoot, 'FINISHED.mdx'),
599
+ });
600
+ }
601
+ else {
602
+ filesToCheck.push({
603
+ kind: 'workshop-wrap-up',
604
+ fullPath: workshopWrapUp,
605
+ relativePath: path.relative(workshopRoot, workshopWrapUp),
606
+ });
607
+ contentFilesToCheck.push({
608
+ fullPath: workshopWrapUp,
609
+ relativePath: path.relative(workshopRoot, workshopWrapUp),
610
+ });
611
+ }
612
+ // Exercise + step files
613
+ let exerciseDirNames = [];
614
+ if (await isDirectory(exercisesRoot)) {
615
+ const exerciseEntries = await fs.readdir(exercisesRoot, {
616
+ withFileTypes: true,
617
+ });
618
+ exerciseDirNames = exerciseEntries
619
+ .filter((e) => e.isDirectory() && /^\d+\./.test(e.name))
620
+ .map((e) => e.name)
621
+ .sort((a, b) => a.localeCompare(b, undefined, { numeric: true }));
622
+ }
623
+ if (exerciseDirNames.length === 0) {
624
+ issues.push({
625
+ level: 'warning',
626
+ code: 'no-exercises-found',
627
+ message: 'No exercise directories found (expected folders like "01.my-exercise" under exercises/)',
628
+ file: exercisesRoot,
629
+ });
630
+ }
631
+ for (const exerciseDirName of exerciseDirNames) {
632
+ const { files, contentFiles, issues: fileIssues, } = await buildExpectedFiles({
633
+ workshopRoot,
634
+ exerciseDirName,
635
+ });
636
+ issues.push(...fileIssues);
637
+ filesToCheck.push(...files);
638
+ contentFilesToCheck.push(...contentFiles);
639
+ }
640
+ // --------------------------------------
641
+ // 2a) MDX content exists and is non-trivial
642
+ // --------------------------------------
643
+ {
644
+ const minChars = 30;
645
+ const uniqueContentFiles = new Map();
646
+ for (const file of contentFilesToCheck) {
647
+ uniqueContentFiles.set(file.fullPath, file);
648
+ }
649
+ for (const file of uniqueContentFiles.values()) {
650
+ if (!(await pathExists(file.fullPath)))
651
+ continue;
652
+ const issue = await checkMinContentLength({
653
+ fullPath: file.fullPath,
654
+ minChars,
655
+ });
656
+ if (issue)
657
+ issues.push(issue);
658
+ }
659
+ }
660
+ const embedOccurrences = new Map(); // url -> files
661
+ for (const file of filesToCheck) {
662
+ if (!(await pathExists(file.fullPath))) {
663
+ issues.push({
664
+ level: 'error',
665
+ code: 'missing-file',
666
+ message: `Missing file`,
667
+ file: file.fullPath,
668
+ });
669
+ continue;
670
+ }
671
+ try {
672
+ const compiled = await compileMdx(file.fullPath);
673
+ const embeds = compiled.epicVideoEmbeds ?? [];
674
+ if (embeds.length === 0) {
675
+ issues.push({
676
+ level: 'error',
677
+ code: 'missing-epic-video-embed',
678
+ message: 'No <EpicVideo url="..."> embed found (required for launch readiness)',
679
+ file: file.fullPath,
680
+ });
681
+ continue;
682
+ }
683
+ for (const embed of embeds) {
684
+ const set = embedOccurrences.get(embed) ?? new Set();
685
+ set.add(file.fullPath);
686
+ embedOccurrences.set(embed, set);
687
+ }
688
+ }
689
+ catch (error) {
690
+ issues.push({
691
+ level: 'error',
692
+ code: 'mdx-compile-failed',
693
+ message: `Failed to compile MDX: ${getErrorMessage(error)}`,
694
+ file: file.fullPath,
695
+ });
696
+ }
697
+ }
698
+ // Also scan the remaining required MDX files for EpicVideo embeds,
699
+ // but do not require that they include a video.
700
+ {
701
+ const videoFilePaths = new Set(filesToCheck.map((f) => f.fullPath));
702
+ const extraContentFilePaths = new Set(contentFilesToCheck
703
+ .map((f) => f.fullPath)
704
+ .filter((p) => !videoFilePaths.has(p)));
705
+ for (const fullPath of extraContentFilePaths) {
706
+ if (!(await pathExists(fullPath)))
707
+ continue;
708
+ try {
709
+ const compiled = await compileMdx(fullPath);
710
+ for (const embed of compiled.epicVideoEmbeds ?? []) {
711
+ const set = embedOccurrences.get(embed) ?? new Set();
712
+ set.add(fullPath);
713
+ embedOccurrences.set(embed, set);
714
+ }
715
+ }
716
+ catch (error) {
717
+ issues.push({
718
+ level: 'error',
719
+ code: 'mdx-compile-failed',
720
+ message: `Failed to compile MDX: ${getErrorMessage(error)}`,
721
+ file: fullPath,
722
+ });
723
+ }
724
+ }
725
+ }
726
+ // ------------------------------------------------
727
+ // 3) HEAD-check EpicVideo urls
728
+ // ------------------------------------------------
729
+ if (!skipHead) {
730
+ issues.push(...(await checkEpicVideoUrlsHead({ embedOccurrences })));
731
+ }
732
+ // ------------------------------------------------
733
+ // 4) Validate embed URLs match the configured host
734
+ // ------------------------------------------------
735
+ if (productHost && productSlug) {
736
+ const normalizedConfigHost = normalizeHost(productHost);
737
+ for (const [embedUrl, usedBy] of embedOccurrences.entries()) {
738
+ let url;
739
+ try {
740
+ url = new URL(embedUrl);
741
+ }
742
+ catch (error) {
743
+ for (const file of usedBy) {
744
+ issues.push({
745
+ level: 'error',
746
+ code: 'invalid-epic-video-url',
747
+ message: `Invalid EpicVideo url: ${getErrorMessage(error)}`,
748
+ file,
749
+ });
750
+ }
751
+ continue;
752
+ }
753
+ const embedHost = normalizeHost(url.host);
754
+ if (embedHost !== normalizedConfigHost) {
755
+ for (const file of usedBy) {
756
+ issues.push({
757
+ level: 'error',
758
+ code: 'epic-video-host-mismatch',
759
+ message: `EpicVideo url host mismatch (expected ${productHost}, got ${url.host})`,
760
+ file,
761
+ });
762
+ }
763
+ }
764
+ const segments = url.pathname.split('/').filter(Boolean);
765
+ // Expected: /workshops/<workshopSlug>/...
766
+ if (segments[0] !== 'workshops') {
767
+ for (const file of usedBy) {
768
+ issues.push({
769
+ level: 'warning',
770
+ code: 'epic-video-url-unexpected-path',
771
+ message: 'EpicVideo url path does not start with /workshops/... (this may break progress tracking)',
772
+ file,
773
+ });
774
+ }
775
+ continue;
776
+ }
777
+ if (segments[1] !== productSlug) {
778
+ for (const file of usedBy) {
779
+ issues.push({
780
+ level: 'error',
781
+ code: 'epic-video-workshop-slug-mismatch',
782
+ message: `EpicVideo url workshop slug mismatch (expected ${productSlug}, got ${segments[1] ?? '(missing)'})`,
783
+ file,
784
+ });
785
+ }
786
+ }
787
+ }
788
+ }
789
+ // -------------------------------------------------------
790
+ // 4) Remote product lesson list vs local embedded videos
791
+ // -------------------------------------------------------
792
+ const localLessonSlugs = new Set();
793
+ for (const embedUrl of embedOccurrences.keys()) {
794
+ const slug = parseEpicLessonSlugFromEmbedUrl(embedUrl);
795
+ if (slug)
796
+ localLessonSlugs.add(slug);
797
+ }
798
+ if (!skipRemote) {
799
+ if (productHost && productSlug) {
800
+ const remote = await fetchRemoteWorkshopLessonSlugs({
801
+ productHost,
802
+ workshopSlug: productSlug,
803
+ });
804
+ if (remote.status === 'error') {
805
+ issues.push({
806
+ level: 'error',
807
+ code: 'remote-product-lessons-unavailable',
808
+ message: remote.message,
809
+ });
810
+ }
811
+ else {
812
+ const remoteLessonSlugs = Array.from(new Set(remote.lessonSlugs.map(stripEpicAiSlugSuffix)));
813
+ if (remoteLessonSlugs.length === 0) {
814
+ issues.push({
815
+ level: 'error',
816
+ code: 'remote-product-lessons-empty',
817
+ message: 'Product API returned no lessons. Is the workshop published on the product site?',
818
+ });
819
+ }
820
+ const missing = remoteLessonSlugs.filter((slug) => !localLessonSlugs.has(slug));
821
+ if (missing.length) {
822
+ issues.push({
823
+ level: 'error',
824
+ code: 'missing-product-videos-in-workshop',
825
+ message: `Missing videos in workshop for product lessons: ${missing
826
+ .sort()
827
+ .join(', ')}`,
828
+ });
829
+ }
830
+ const extras = [...localLessonSlugs].filter((slug) => !remoteLessonSlugs.includes(slug));
831
+ if (extras.length) {
832
+ issues.push({
833
+ level: 'warning',
834
+ code: 'extra-local-videos',
835
+ message: `Found EpicVideo embeds for lesson slugs not present in the product lesson list: ${extras
836
+ .sort()
837
+ .join(', ')}`,
838
+ });
839
+ }
840
+ }
841
+ }
842
+ }
843
+ const errorCount = issues.filter((i) => i.level === 'error').length;
844
+ const warningCount = issues.filter((i) => i.level === 'warning').length;
845
+ const success = errorCount === 0;
846
+ if (!silent) {
847
+ console.log(chalk.bold.cyan('\n🛠️ Admin: Launch readiness\n'));
848
+ console.log(`${success ? chalk.green('✅') : chalk.red('❌')} Result: ${success ? chalk.green('PASS') : chalk.red('FAIL')}`);
849
+ console.log(chalk.gray(`(${errorCount} error${errorCount === 1 ? '' : 's'}, ${warningCount} warning${warningCount === 1 ? '' : 's'})`));
850
+ console.log();
851
+ if (issues.length) {
852
+ for (const issue of issues) {
853
+ console.log(formatIssue(issue, workshopRoot));
854
+ }
855
+ console.log();
856
+ }
857
+ if (!skipRemote && productHost && productSlug) {
858
+ console.log(chalk.gray(`Remote lesson check: https://${productHost}/api/workshops/${productSlug}`));
859
+ console.log();
860
+ }
861
+ if (!success) {
862
+ console.log(chalk.gray(`Docs: https://github.com/epicweb-dev/epicshop/blob/main/docs/launch.md`));
863
+ console.log();
864
+ }
865
+ }
866
+ return success
867
+ ? { success: true, message: 'Launch readiness checks passed' }
868
+ : {
869
+ success: false,
870
+ message: 'Launch readiness checks failed',
871
+ error: new Error(`Launch readiness failed with ${errorCount} error${errorCount === 1 ? '' : 's'}`),
872
+ };
873
+ }
@@ -0,0 +1 @@
1
+ export { launchReadiness, type LaunchReadinessOptions, type LaunchReadinessResult, } from "./admin/launch-readiness.js";
@@ -0,0 +1 @@
1
+ export { launchReadiness, } from "./admin/launch-readiness.js";
@@ -0,0 +1,17 @@
1
+ export type DirectorySizeResult = {
2
+ bytes: number;
3
+ files: number;
4
+ directories: number;
5
+ };
6
+ /**
7
+ * Calculate the total size of a directory recursively.
8
+ */
9
+ export declare function getDirectorySize(targetPath: string): Promise<DirectorySizeResult>;
10
+ /**
11
+ * Format bytes into a human-readable string.
12
+ */
13
+ export declare function formatBytes(bytes: number): string;
14
+ /**
15
+ * Check if a path exists.
16
+ */
17
+ export declare function pathExists(targetPath: string): Promise<boolean>;
@@ -0,0 +1,76 @@
1
+ import fs from 'node:fs/promises';
2
+ import path from 'node:path';
3
+ /**
4
+ * Calculate the total size of a directory recursively.
5
+ */
6
+ export async function getDirectorySize(targetPath) {
7
+ const result = {
8
+ bytes: 0,
9
+ files: 0,
10
+ directories: 0,
11
+ };
12
+ try {
13
+ const stat = await fs.stat(targetPath);
14
+ if (stat.isFile()) {
15
+ result.bytes = stat.size;
16
+ result.files = 1;
17
+ return result;
18
+ }
19
+ if (!stat.isDirectory()) {
20
+ return result;
21
+ }
22
+ result.directories = 1;
23
+ const entries = await fs.readdir(targetPath, { withFileTypes: true });
24
+ for (const entry of entries) {
25
+ const entryPath = path.join(targetPath, entry.name);
26
+ if (entry.isFile()) {
27
+ try {
28
+ const fileStat = await fs.stat(entryPath);
29
+ result.bytes += fileStat.size;
30
+ result.files += 1;
31
+ }
32
+ catch {
33
+ // Skip files we can't access
34
+ }
35
+ }
36
+ else if (entry.isDirectory()) {
37
+ try {
38
+ const subResult = await getDirectorySize(entryPath);
39
+ result.bytes += subResult.bytes;
40
+ result.files += subResult.files;
41
+ result.directories += subResult.directories;
42
+ }
43
+ catch {
44
+ // Skip directories we can't access
45
+ }
46
+ }
47
+ }
48
+ return result;
49
+ }
50
+ catch {
51
+ return result;
52
+ }
53
+ }
54
+ /**
55
+ * Format bytes into a human-readable string.
56
+ */
57
+ export function formatBytes(bytes) {
58
+ if (bytes === 0)
59
+ return '0 B';
60
+ const k = 1024;
61
+ const sizes = ['B', 'KB', 'MB', 'GB', 'TB'];
62
+ const i = Math.floor(Math.log(bytes) / Math.log(k));
63
+ return `${(bytes / Math.pow(k, i)).toFixed(2)} ${sizes[i]}`;
64
+ }
65
+ /**
66
+ * Check if a path exists.
67
+ */
68
+ export async function pathExists(targetPath) {
69
+ try {
70
+ await fs.access(targetPath);
71
+ return true;
72
+ }
73
+ catch {
74
+ return false;
75
+ }
76
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "epicshop",
3
- "version": "6.84.4",
3
+ "version": "6.84.6",
4
4
  "publishConfig": {
5
5
  "access": "public"
6
6
  },
@@ -23,7 +23,8 @@
23
23
  "./progress": "./src/commands/progress.ts",
24
24
  "./diff": "./src/commands/diff.ts",
25
25
  "./exercises": "./src/commands/exercises.ts",
26
- "./auth": "./src/commands/auth.ts"
26
+ "./auth": "./src/commands/auth.ts",
27
+ "./admin": "./src/commands/admin.ts"
27
28
  },
28
29
  "bin": "./src/cli.ts"
29
30
  },
@@ -83,6 +84,11 @@
83
84
  "import": "./dist/commands/auth.js",
84
85
  "types": "./dist/commands/auth.d.ts",
85
86
  "default": "./dist/commands/auth.js"
87
+ },
88
+ "./admin": {
89
+ "import": "./dist/commands/admin.js",
90
+ "types": "./dist/commands/admin.d.ts",
91
+ "default": "./dist/commands/admin.js"
86
92
  }
87
93
  },
88
94
  "files": [
@@ -99,7 +105,7 @@
99
105
  "build:watch": "nx watch --projects=epicshop -- nx run \\$NX_PROJECT_NAME:build"
100
106
  },
101
107
  "dependencies": {
102
- "@epic-web/workshop-utils": "6.84.4",
108
+ "@epic-web/workshop-utils": "6.84.6",
103
109
  "@inquirer/prompts": "^8.2.0",
104
110
  "@sentry/node": "^10.38.0",
105
111
  "chalk": "^5.6.2",