epicshop 6.84.8 → 6.85.1
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 +19 -2
- package/dist/commands/admin/launch-readiness.js +16 -147
- package/dist/commands/admin/set-videos.d.ts +20 -0
- package/dist/commands/admin/set-videos.js +469 -0
- package/dist/commands/admin/workshop-content-utils.d.ts +36 -0
- package/dist/commands/admin/workshop-content-utils.js +175 -0
- package/dist/commands/admin.d.ts +1 -0
- package/dist/commands/admin.js +1 -0
- package/dist/commands/cleanup.js +11 -1
- package/package.json +2 -2
package/dist/cli.js
CHANGED
|
@@ -742,7 +742,7 @@ const cli = yargs(args)
|
|
|
742
742
|
.positional('subcommand', {
|
|
743
743
|
describe: 'Admin subcommand',
|
|
744
744
|
type: 'string',
|
|
745
|
-
choices: ['launch-readiness'],
|
|
745
|
+
choices: ['launch-readiness', 'set-videos'],
|
|
746
746
|
})
|
|
747
747
|
.option('workshop-dir', {
|
|
748
748
|
alias: 'w',
|
|
@@ -765,7 +765,14 @@ const cli = yargs(args)
|
|
|
765
765
|
description: 'Skip checking that EpicVideo urls return 200 to HEAD (network required)',
|
|
766
766
|
default: false,
|
|
767
767
|
})
|
|
768
|
-
.
|
|
768
|
+
.option('dry-run', {
|
|
769
|
+
type: 'boolean',
|
|
770
|
+
description: 'Preview set-videos changes without writing files (set-videos only)',
|
|
771
|
+
default: false,
|
|
772
|
+
})
|
|
773
|
+
.example('$0 admin launch-readiness', 'Check workshop launch readiness (hidden command)')
|
|
774
|
+
.example('$0 admin set-videos', 'Set top EpicVideo embeds from product lesson order (hidden command)')
|
|
775
|
+
.example('$0 admin set-videos --dry-run', 'Preview top EpicVideo changes without writing files');
|
|
769
776
|
}, async (argv) => {
|
|
770
777
|
const { findWorkshopRoot } = await import("./commands/workshops.js");
|
|
771
778
|
const workshopRoot = await findWorkshopRoot(resolveWorkshopContextCwd(argv.workshopDir));
|
|
@@ -789,6 +796,16 @@ const cli = yargs(args)
|
|
|
789
796
|
process.exit(1);
|
|
790
797
|
break;
|
|
791
798
|
}
|
|
799
|
+
case 'set-videos': {
|
|
800
|
+
const { setVideos } = await import("./commands/admin.js");
|
|
801
|
+
const result = await setVideos({
|
|
802
|
+
silent: argv.silent,
|
|
803
|
+
dryRun: argv.dryRun,
|
|
804
|
+
});
|
|
805
|
+
if (!result.success)
|
|
806
|
+
process.exit(1);
|
|
807
|
+
break;
|
|
808
|
+
}
|
|
792
809
|
default: {
|
|
793
810
|
console.error(chalk.red(`❌ Unknown admin subcommand: ${argv.subcommand}`));
|
|
794
811
|
process.exit(1);
|
|
@@ -1,21 +1,24 @@
|
|
|
1
1
|
import fs from 'node:fs/promises';
|
|
2
2
|
import path from 'node:path';
|
|
3
3
|
import { compileMdx } from '@epic-web/workshop-utils/compile-mdx.server';
|
|
4
|
-
import { getAuthInfo } from '@epic-web/workshop-utils/db.server';
|
|
5
4
|
import { getErrorMessage } from '@epic-web/workshop-utils/utils';
|
|
6
5
|
import chalk from 'chalk';
|
|
7
6
|
import { pathExists } from "../../utils/filesystem.js";
|
|
7
|
+
import { collectStepDirectories, formatProductLessonUrl, fetchRemoteWorkshopLessons, isDirectory, resolveMdxFile, } from "./workshop-content-utils.js";
|
|
8
8
|
function stripEpicAiSlugSuffix(value) {
|
|
9
9
|
// EpicAI embeds sometimes include a `~...` suffix in the slug segment.
|
|
10
10
|
return value.replace(/~[^ ]*$/, '');
|
|
11
11
|
}
|
|
12
|
+
function isProductLessonPathSegment(segment) {
|
|
13
|
+
return segment === 'workshops' || segment === 'tutorials';
|
|
14
|
+
}
|
|
12
15
|
function normalizeHost(host) {
|
|
13
16
|
return host.toLowerCase().replace(/^www\./, '');
|
|
14
17
|
}
|
|
15
|
-
function
|
|
18
|
+
function parseEpicProductSlugFromEmbedUrl(urlString) {
|
|
16
19
|
const parseSegments = (segments) => {
|
|
17
|
-
// Expected: /workshops/<
|
|
18
|
-
if (segments[0]
|
|
20
|
+
// Expected: /workshops/<slug>/... or /tutorials/<slug>/...
|
|
21
|
+
if (!isProductLessonPathSegment(segments[0]))
|
|
19
22
|
return null;
|
|
20
23
|
const workshopSlug = segments[1] ?? null;
|
|
21
24
|
return workshopSlug ? stripEpicAiSlugSuffix(workshopSlug) : null;
|
|
@@ -59,12 +62,6 @@ function parseEpicLessonSlugFromEmbedUrl(urlString) {
|
|
|
59
62
|
return parseSegments(segments);
|
|
60
63
|
}
|
|
61
64
|
}
|
|
62
|
-
function formatProductLessonUrl({ productHost, productSlug, lessonSlug, sectionSlug, }) {
|
|
63
|
-
// The product site will typically redirect to a section-specific path when needed.
|
|
64
|
-
return sectionSlug
|
|
65
|
-
? `https://${productHost}/workshops/${productSlug}/${sectionSlug}/${lessonSlug}`
|
|
66
|
-
: `https://${productHost}/workshops/${productSlug}/${lessonSlug}`;
|
|
67
|
-
}
|
|
68
65
|
function formatIssue(issue, workshopRoot) {
|
|
69
66
|
const icon = issue.level === 'error' ? chalk.red('❌') : chalk.yellow('⚠️ ');
|
|
70
67
|
const filePart = issue.file
|
|
@@ -72,20 +69,6 @@ function formatIssue(issue, workshopRoot) {
|
|
|
72
69
|
: '';
|
|
73
70
|
return `${icon} ${issue.message}${filePart}`;
|
|
74
71
|
}
|
|
75
|
-
async function isDirectory(targetPath) {
|
|
76
|
-
try {
|
|
77
|
-
return (await fs.stat(targetPath)).isDirectory();
|
|
78
|
-
}
|
|
79
|
-
catch {
|
|
80
|
-
return false;
|
|
81
|
-
}
|
|
82
|
-
}
|
|
83
|
-
async function resolveMdxFile(dir, baseName) {
|
|
84
|
-
const mdx = path.join(dir, `${baseName}.mdx`);
|
|
85
|
-
if (await pathExists(mdx))
|
|
86
|
-
return mdx;
|
|
87
|
-
return null;
|
|
88
|
-
}
|
|
89
72
|
async function buildExpectedFiles({ workshopRoot, exerciseDirName, }) {
|
|
90
73
|
const issues = [];
|
|
91
74
|
const files = [];
|
|
@@ -144,29 +127,7 @@ async function buildExpectedFiles({ workshopRoot, exerciseDirName, }) {
|
|
|
144
127
|
});
|
|
145
128
|
return { files, contentFiles, issues };
|
|
146
129
|
}
|
|
147
|
-
const
|
|
148
|
-
const stepsByNumber = new Map();
|
|
149
|
-
for (const entry of entries) {
|
|
150
|
-
if (!entry.isDirectory())
|
|
151
|
-
continue;
|
|
152
|
-
const match = stepDirRegex.exec(entry.name);
|
|
153
|
-
if (!match?.groups)
|
|
154
|
-
continue;
|
|
155
|
-
const stepNumber = Number(match.groups.stepNumber);
|
|
156
|
-
const type = match.groups.type;
|
|
157
|
-
if (!Number.isFinite(stepNumber) || stepNumber <= 0)
|
|
158
|
-
continue;
|
|
159
|
-
const current = stepsByNumber.get(stepNumber) ?? {
|
|
160
|
-
problems: [],
|
|
161
|
-
solutions: [],
|
|
162
|
-
};
|
|
163
|
-
const fullStepDir = path.join(exerciseRoot, entry.name);
|
|
164
|
-
if (type === 'problem')
|
|
165
|
-
current.problems.push(fullStepDir);
|
|
166
|
-
if (type === 'solution')
|
|
167
|
-
current.solutions.push(fullStepDir);
|
|
168
|
-
stepsByNumber.set(stepNumber, current);
|
|
169
|
-
}
|
|
130
|
+
const stepsByNumber = collectStepDirectories(entries, exerciseRoot);
|
|
170
131
|
if (stepsByNumber.size === 0) {
|
|
171
132
|
issues.push({
|
|
172
133
|
level: 'warning',
|
|
@@ -255,100 +216,6 @@ async function buildExpectedFiles({ workshopRoot, exerciseDirName, }) {
|
|
|
255
216
|
}
|
|
256
217
|
return { files, contentFiles, issues };
|
|
257
218
|
}
|
|
258
|
-
async function fetchRemoteWorkshopLessonSlugs({ productHost, workshopSlug, }) {
|
|
259
|
-
const url = `https://${productHost}/api/workshops/${encodeURIComponent(workshopSlug)}`;
|
|
260
|
-
const fetchOnce = async (accessToken) => {
|
|
261
|
-
const timeout = AbortSignal.timeout(15_000);
|
|
262
|
-
const headers = {};
|
|
263
|
-
if (accessToken)
|
|
264
|
-
headers.authorization = `Bearer ${accessToken}`;
|
|
265
|
-
return fetch(url, { headers, signal: timeout });
|
|
266
|
-
};
|
|
267
|
-
let response = null;
|
|
268
|
-
try {
|
|
269
|
-
response = await fetchOnce();
|
|
270
|
-
}
|
|
271
|
-
catch (error) {
|
|
272
|
-
return {
|
|
273
|
-
status: 'error',
|
|
274
|
-
message: `Failed to fetch product workshop data: ${getErrorMessage(error)}`,
|
|
275
|
-
};
|
|
276
|
-
}
|
|
277
|
-
if (response.status === 401 || response.status === 403) {
|
|
278
|
-
const authInfo = await getAuthInfo({ productHost }).catch(() => null);
|
|
279
|
-
const accessToken = authInfo?.tokenSet?.access_token;
|
|
280
|
-
if (accessToken) {
|
|
281
|
-
try {
|
|
282
|
-
response = await fetchOnce(accessToken);
|
|
283
|
-
}
|
|
284
|
-
catch (error) {
|
|
285
|
-
return {
|
|
286
|
-
status: 'error',
|
|
287
|
-
message: `Failed to fetch product workshop data (after auth): ${getErrorMessage(error)}`,
|
|
288
|
-
};
|
|
289
|
-
}
|
|
290
|
-
}
|
|
291
|
-
}
|
|
292
|
-
if (!response.ok) {
|
|
293
|
-
const body = await response.text().catch(() => '');
|
|
294
|
-
const hint = response.status === 401 || response.status === 403
|
|
295
|
-
? ` (try: npx epicshop auth login ${productHost.replace(/^www\./, '')})`
|
|
296
|
-
: response.status === 404
|
|
297
|
-
? ` (check epicshop.product.host + epicshop.product.slug)`
|
|
298
|
-
: '';
|
|
299
|
-
return {
|
|
300
|
-
status: 'error',
|
|
301
|
-
message: `Product API request failed: ${response.status} ${response.statusText}${hint}${body ? `\n${body}` : ''}`,
|
|
302
|
-
};
|
|
303
|
-
}
|
|
304
|
-
let data;
|
|
305
|
-
try {
|
|
306
|
-
data = await response.json();
|
|
307
|
-
}
|
|
308
|
-
catch (error) {
|
|
309
|
-
return {
|
|
310
|
-
status: 'error',
|
|
311
|
-
message: `Product API response was not valid JSON: ${getErrorMessage(error)}`,
|
|
312
|
-
};
|
|
313
|
-
}
|
|
314
|
-
const resources = data?.resources;
|
|
315
|
-
if (!Array.isArray(resources)) {
|
|
316
|
-
return {
|
|
317
|
-
status: 'error',
|
|
318
|
-
message: `Product API response did not include an array "resources" field`,
|
|
319
|
-
};
|
|
320
|
-
}
|
|
321
|
-
const lessons = [];
|
|
322
|
-
for (const resource of resources) {
|
|
323
|
-
if (!resource || typeof resource !== 'object')
|
|
324
|
-
continue;
|
|
325
|
-
const r = resource;
|
|
326
|
-
if (r._type === 'lesson') {
|
|
327
|
-
const slug = r.slug;
|
|
328
|
-
if (typeof slug === 'string')
|
|
329
|
-
lessons.push({ slug, sectionSlug: null });
|
|
330
|
-
continue;
|
|
331
|
-
}
|
|
332
|
-
if (r._type === 'section') {
|
|
333
|
-
const sectionSlug = typeof r.slug === 'string' && r.slug.trim().length > 0
|
|
334
|
-
? r.slug.trim()
|
|
335
|
-
: null;
|
|
336
|
-
const sectionLessons = r.lessons;
|
|
337
|
-
if (!Array.isArray(sectionLessons))
|
|
338
|
-
continue;
|
|
339
|
-
for (const lesson of sectionLessons) {
|
|
340
|
-
if (!lesson || typeof lesson !== 'object')
|
|
341
|
-
continue;
|
|
342
|
-
const l = lesson;
|
|
343
|
-
const slug = l.slug;
|
|
344
|
-
if (typeof slug === 'string') {
|
|
345
|
-
lessons.push({ slug, sectionSlug });
|
|
346
|
-
}
|
|
347
|
-
}
|
|
348
|
-
}
|
|
349
|
-
}
|
|
350
|
-
return { status: 'success', lessons };
|
|
351
|
-
}
|
|
352
219
|
async function checkMinContentLength({ fullPath, minChars, }) {
|
|
353
220
|
try {
|
|
354
221
|
const raw = await fs.readFile(fullPath, 'utf8');
|
|
@@ -793,13 +660,13 @@ export async function launchReadiness(options = {}) {
|
|
|
793
660
|
}
|
|
794
661
|
}
|
|
795
662
|
const segments = url.pathname.split('/').filter(Boolean);
|
|
796
|
-
// Expected: /workshops/<workshopSlug>/...
|
|
797
|
-
if (segments[0]
|
|
663
|
+
// Expected: /workshops/<workshopSlug>/... or /tutorials/<tutorialSlug>/...
|
|
664
|
+
if (!isProductLessonPathSegment(segments[0])) {
|
|
798
665
|
for (const file of usedBy) {
|
|
799
666
|
issues.push({
|
|
800
667
|
level: 'warning',
|
|
801
668
|
code: 'epic-video-url-unexpected-path',
|
|
802
|
-
message: 'EpicVideo url path does not start with /workshops/... (this may break progress tracking)',
|
|
669
|
+
message: 'EpicVideo url path does not start with /workshops/... or /tutorials/... (this may break progress tracking)',
|
|
803
670
|
file,
|
|
804
671
|
});
|
|
805
672
|
}
|
|
@@ -830,7 +697,7 @@ export async function launchReadiness(options = {}) {
|
|
|
830
697
|
const lessonSlug = parseEpicLessonSlugFromEmbedUrl(embedUrl);
|
|
831
698
|
if (!lessonSlug)
|
|
832
699
|
continue;
|
|
833
|
-
const workshopSlug =
|
|
700
|
+
const workshopSlug = parseEpicProductSlugFromEmbedUrl(embedUrl);
|
|
834
701
|
if (!workshopSlug || workshopSlug !== productSlug)
|
|
835
702
|
continue;
|
|
836
703
|
try {
|
|
@@ -844,7 +711,7 @@ export async function launchReadiness(options = {}) {
|
|
|
844
711
|
}
|
|
845
712
|
localProductLessonSlugs.add(lessonSlug);
|
|
846
713
|
}
|
|
847
|
-
const remote = await
|
|
714
|
+
const remote = await fetchRemoteWorkshopLessons({
|
|
848
715
|
productHost,
|
|
849
716
|
workshopSlug: productSlug,
|
|
850
717
|
});
|
|
@@ -856,6 +723,7 @@ export async function launchReadiness(options = {}) {
|
|
|
856
723
|
});
|
|
857
724
|
}
|
|
858
725
|
else {
|
|
726
|
+
const remoteModuleType = remote.moduleType;
|
|
859
727
|
const remoteLessons = remote.lessons
|
|
860
728
|
.map((l) => ({
|
|
861
729
|
slug: stripEpicAiSlugSuffix(l.slug),
|
|
@@ -887,6 +755,7 @@ export async function launchReadiness(options = {}) {
|
|
|
887
755
|
return `- ${slug}: ${formatProductLessonUrl({
|
|
888
756
|
productHost,
|
|
889
757
|
productSlug,
|
|
758
|
+
moduleType: remoteModuleType,
|
|
890
759
|
lessonSlug: slug,
|
|
891
760
|
sectionSlug: remoteLesson?.sectionSlug ?? null,
|
|
892
761
|
})}`;
|
|
@@ -903,7 +772,7 @@ export async function launchReadiness(options = {}) {
|
|
|
903
772
|
const lessonSlug = parseEpicLessonSlugFromEmbedUrl(embedUrl);
|
|
904
773
|
if (!lessonSlug)
|
|
905
774
|
continue;
|
|
906
|
-
const workshopSlug =
|
|
775
|
+
const workshopSlug = parseEpicProductSlugFromEmbedUrl(embedUrl);
|
|
907
776
|
if (!workshopSlug || workshopSlug !== productSlug)
|
|
908
777
|
continue;
|
|
909
778
|
try {
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
export type SetVideosOptions = {
|
|
2
|
+
/**
|
|
3
|
+
* Defaults to `process.env.EPICSHOP_CONTEXT_CWD ?? process.cwd()`.
|
|
4
|
+
* Primarily useful for tests.
|
|
5
|
+
*/
|
|
6
|
+
workshopRoot?: string;
|
|
7
|
+
silent?: boolean;
|
|
8
|
+
dryRun?: boolean;
|
|
9
|
+
};
|
|
10
|
+
export type SetVideosResult = {
|
|
11
|
+
success: boolean;
|
|
12
|
+
message: string;
|
|
13
|
+
error?: Error;
|
|
14
|
+
inserted: number;
|
|
15
|
+
updated: number;
|
|
16
|
+
unchanged: number;
|
|
17
|
+
warnings: Array<string>;
|
|
18
|
+
dryRun: boolean;
|
|
19
|
+
};
|
|
20
|
+
export declare function setVideos(options?: SetVideosOptions): Promise<SetVideosResult>;
|
|
@@ -0,0 +1,469 @@
|
|
|
1
|
+
import fs from 'node:fs/promises';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { getErrorMessage } from '@epic-web/workshop-utils/utils';
|
|
4
|
+
import chalk from 'chalk';
|
|
5
|
+
import { collectStepDirectories, formatProductLessonUrl, fetchRemoteWorkshopLessons, isDirectory, resolveMdxFile, stripEpicAiSlugSuffix, } from "./workshop-content-utils.js";
|
|
6
|
+
function createFailureResult(message, { dryRun = false } = {}) {
|
|
7
|
+
return {
|
|
8
|
+
success: false,
|
|
9
|
+
message,
|
|
10
|
+
error: new Error(message),
|
|
11
|
+
inserted: 0,
|
|
12
|
+
updated: 0,
|
|
13
|
+
unchanged: 0,
|
|
14
|
+
warnings: [],
|
|
15
|
+
dryRun,
|
|
16
|
+
};
|
|
17
|
+
}
|
|
18
|
+
async function collectOrderedVideoFiles({ workshopRoot, }) {
|
|
19
|
+
const files = [];
|
|
20
|
+
const errors = [];
|
|
21
|
+
const warnings = [];
|
|
22
|
+
const exercisesRoot = path.join(workshopRoot, 'exercises');
|
|
23
|
+
if (!(await isDirectory(exercisesRoot))) {
|
|
24
|
+
errors.push('Missing `exercises/` directory (required for a workshop)');
|
|
25
|
+
return { files, errors, warnings };
|
|
26
|
+
}
|
|
27
|
+
const workshopIntro = await resolveMdxFile(exercisesRoot, 'README');
|
|
28
|
+
if (!workshopIntro) {
|
|
29
|
+
errors.push('Missing workshop intro file `exercises/README.mdx`');
|
|
30
|
+
}
|
|
31
|
+
else {
|
|
32
|
+
files.push({
|
|
33
|
+
kind: 'workshop-intro',
|
|
34
|
+
fullPath: workshopIntro,
|
|
35
|
+
relativePath: path.relative(workshopRoot, workshopIntro),
|
|
36
|
+
});
|
|
37
|
+
}
|
|
38
|
+
const workshopWrapUp = await resolveMdxFile(exercisesRoot, 'FINISHED');
|
|
39
|
+
if (!workshopWrapUp) {
|
|
40
|
+
errors.push('Missing workshop wrap-up file `exercises/FINISHED.mdx`');
|
|
41
|
+
}
|
|
42
|
+
const exerciseEntries = await fs.readdir(exercisesRoot, {
|
|
43
|
+
withFileTypes: true,
|
|
44
|
+
});
|
|
45
|
+
const exerciseDirNames = exerciseEntries
|
|
46
|
+
.filter((e) => e.isDirectory() && /^\d+\./.test(e.name))
|
|
47
|
+
.map((e) => e.name)
|
|
48
|
+
.sort((a, b) => a.localeCompare(b, undefined, { numeric: true }));
|
|
49
|
+
if (exerciseDirNames.length === 0) {
|
|
50
|
+
warnings.push('No exercise directories found (expected folders like "01.my-exercise" under exercises/)');
|
|
51
|
+
}
|
|
52
|
+
for (const exerciseDirName of exerciseDirNames) {
|
|
53
|
+
const exerciseRoot = path.join(exercisesRoot, exerciseDirName);
|
|
54
|
+
const exerciseNumberMatch = /^(\d+)\./.exec(exerciseDirName);
|
|
55
|
+
const exerciseNumber = exerciseNumberMatch
|
|
56
|
+
? Number(exerciseNumberMatch[1])
|
|
57
|
+
: undefined;
|
|
58
|
+
const exerciseIntro = await resolveMdxFile(exerciseRoot, 'README');
|
|
59
|
+
if (!exerciseIntro) {
|
|
60
|
+
errors.push(`Missing exercise intro file (expected README.mdx): ${path.relative(workshopRoot, path.join(exerciseRoot, 'README.mdx'))}`);
|
|
61
|
+
}
|
|
62
|
+
else {
|
|
63
|
+
files.push({
|
|
64
|
+
kind: 'exercise-intro',
|
|
65
|
+
fullPath: exerciseIntro,
|
|
66
|
+
relativePath: path.relative(workshopRoot, exerciseIntro),
|
|
67
|
+
exerciseNumber,
|
|
68
|
+
});
|
|
69
|
+
}
|
|
70
|
+
let entries = [];
|
|
71
|
+
try {
|
|
72
|
+
entries = await fs.readdir(exerciseRoot, { withFileTypes: true });
|
|
73
|
+
}
|
|
74
|
+
catch (error) {
|
|
75
|
+
errors.push(`Failed to read exercise directory "${path.relative(workshopRoot, exerciseRoot)}": ${getErrorMessage(error)}`);
|
|
76
|
+
continue;
|
|
77
|
+
}
|
|
78
|
+
const stepsByNumber = collectStepDirectories(entries, exerciseRoot);
|
|
79
|
+
if (stepsByNumber.size === 0) {
|
|
80
|
+
warnings.push(`No step app directories found in "${path.relative(workshopRoot, exerciseRoot)}" (expected folders like "01.problem" and "01.solution")`);
|
|
81
|
+
}
|
|
82
|
+
for (const [stepNumber, dirs] of [...stepsByNumber.entries()].sort((a, b) => a[0] - b[0])) {
|
|
83
|
+
if (dirs.problems.length === 0) {
|
|
84
|
+
errors.push(`Missing problem app directory for step ${stepNumber} in ${path.relative(workshopRoot, exerciseRoot)}`);
|
|
85
|
+
}
|
|
86
|
+
if (dirs.solutions.length === 0) {
|
|
87
|
+
errors.push(`Missing solution app directory for step ${stepNumber} in ${path.relative(workshopRoot, exerciseRoot)}`);
|
|
88
|
+
}
|
|
89
|
+
if (dirs.problems.length > 1) {
|
|
90
|
+
warnings.push(`Multiple problem app directories found for step ${stepNumber} in ${path.relative(workshopRoot, exerciseRoot)}`);
|
|
91
|
+
}
|
|
92
|
+
if (dirs.solutions.length > 1) {
|
|
93
|
+
warnings.push(`Multiple solution app directories found for step ${stepNumber} in ${path.relative(workshopRoot, exerciseRoot)}`);
|
|
94
|
+
}
|
|
95
|
+
for (const problemDir of [...dirs.problems].sort((a, b) => a.localeCompare(b))) {
|
|
96
|
+
const problemReadme = await resolveMdxFile(problemDir, 'README');
|
|
97
|
+
if (!problemReadme) {
|
|
98
|
+
errors.push(`Missing step problem README.mdx: ${path.relative(workshopRoot, path.join(problemDir, 'README.mdx'))}`);
|
|
99
|
+
continue;
|
|
100
|
+
}
|
|
101
|
+
files.push({
|
|
102
|
+
kind: 'step-problem',
|
|
103
|
+
fullPath: problemReadme,
|
|
104
|
+
relativePath: path.relative(workshopRoot, problemReadme),
|
|
105
|
+
exerciseNumber,
|
|
106
|
+
stepNumber,
|
|
107
|
+
});
|
|
108
|
+
}
|
|
109
|
+
for (const solutionDir of [...dirs.solutions].sort((a, b) => a.localeCompare(b))) {
|
|
110
|
+
const solutionReadme = await resolveMdxFile(solutionDir, 'README');
|
|
111
|
+
if (!solutionReadme) {
|
|
112
|
+
errors.push(`Missing step solution README.mdx: ${path.relative(workshopRoot, path.join(solutionDir, 'README.mdx'))}`);
|
|
113
|
+
continue;
|
|
114
|
+
}
|
|
115
|
+
files.push({
|
|
116
|
+
kind: 'step-solution',
|
|
117
|
+
fullPath: solutionReadme,
|
|
118
|
+
relativePath: path.relative(workshopRoot, solutionReadme),
|
|
119
|
+
exerciseNumber,
|
|
120
|
+
stepNumber,
|
|
121
|
+
});
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
const exerciseSummary = await resolveMdxFile(exerciseRoot, 'FINISHED');
|
|
125
|
+
if (!exerciseSummary) {
|
|
126
|
+
errors.push(`Missing exercise summary file (expected FINISHED.mdx): ${path.relative(workshopRoot, path.join(exerciseRoot, 'FINISHED.mdx'))}`);
|
|
127
|
+
}
|
|
128
|
+
else {
|
|
129
|
+
files.push({
|
|
130
|
+
kind: 'exercise-summary',
|
|
131
|
+
fullPath: exerciseSummary,
|
|
132
|
+
relativePath: path.relative(workshopRoot, exerciseSummary),
|
|
133
|
+
exerciseNumber,
|
|
134
|
+
});
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
if (workshopWrapUp) {
|
|
138
|
+
files.push({
|
|
139
|
+
kind: 'workshop-wrap-up',
|
|
140
|
+
fullPath: workshopWrapUp,
|
|
141
|
+
relativePath: path.relative(workshopRoot, workshopWrapUp),
|
|
142
|
+
});
|
|
143
|
+
}
|
|
144
|
+
return { files, errors, warnings };
|
|
145
|
+
}
|
|
146
|
+
function setEpicVideoUrl({ epicVideoBlock, url, }) {
|
|
147
|
+
const openingTagMatch = epicVideoBlock.match(/<EpicVideo\b[\s\S]*?>/);
|
|
148
|
+
if (!openingTagMatch) {
|
|
149
|
+
return epicVideoBlock;
|
|
150
|
+
}
|
|
151
|
+
const openingTag = openingTagMatch[0];
|
|
152
|
+
const urlAttrMatch = openingTag.match(/\burl\s*=\s*("([^"]*)"|'([^']*)')/);
|
|
153
|
+
let nextOpeningTag = openingTag;
|
|
154
|
+
if (urlAttrMatch) {
|
|
155
|
+
const currentUrl = urlAttrMatch[2] ?? urlAttrMatch[3] ?? '';
|
|
156
|
+
if (currentUrl === url)
|
|
157
|
+
return epicVideoBlock;
|
|
158
|
+
const quote = urlAttrMatch[1]?.startsWith("'") ? "'" : '"';
|
|
159
|
+
nextOpeningTag = openingTag.replace(urlAttrMatch[0], `url=${quote}${url}${quote}`);
|
|
160
|
+
}
|
|
161
|
+
else {
|
|
162
|
+
nextOpeningTag = openingTag.replace(/\/?>$/, (suffix) => {
|
|
163
|
+
return ` url="${url}"${suffix}`;
|
|
164
|
+
});
|
|
165
|
+
}
|
|
166
|
+
return epicVideoBlock.replace(openingTag, nextOpeningTag);
|
|
167
|
+
}
|
|
168
|
+
function upsertTitleEpicVideo({ content, url, }) {
|
|
169
|
+
const lineEnding = content.includes('\r\n') ? '\r\n' : '\n';
|
|
170
|
+
const lines = content.split(/\r?\n/);
|
|
171
|
+
const titleLineIndex = lines.findIndex((line) => /^\s{0,3}#\s+/.test(line));
|
|
172
|
+
if (titleLineIndex < 0) {
|
|
173
|
+
return {
|
|
174
|
+
status: 'error',
|
|
175
|
+
message: 'Missing top-level H1 title (`# ...`)',
|
|
176
|
+
};
|
|
177
|
+
}
|
|
178
|
+
let firstNonEmptyAfterTitle = titleLineIndex + 1;
|
|
179
|
+
while (firstNonEmptyAfterTitle < lines.length &&
|
|
180
|
+
lines[firstNonEmptyAfterTitle]?.trim() === '') {
|
|
181
|
+
firstNonEmptyAfterTitle++;
|
|
182
|
+
}
|
|
183
|
+
const lineAfterTitle = lines[firstNonEmptyAfterTitle]?.trimStart() ?? '';
|
|
184
|
+
if (lineAfterTitle.startsWith('<EpicVideo')) {
|
|
185
|
+
const blockStart = firstNonEmptyAfterTitle;
|
|
186
|
+
let blockEnd = -1;
|
|
187
|
+
for (let index = blockStart; index < lines.length; index++) {
|
|
188
|
+
const line = lines[index]?.trim() ?? '';
|
|
189
|
+
if (line.includes('/>') || line.includes('</EpicVideo>')) {
|
|
190
|
+
blockEnd = index;
|
|
191
|
+
break;
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
if (blockEnd < 0) {
|
|
195
|
+
return {
|
|
196
|
+
status: 'error',
|
|
197
|
+
message: 'Found a top EpicVideo block but could not find its closing tag',
|
|
198
|
+
};
|
|
199
|
+
}
|
|
200
|
+
const currentBlock = lines.slice(blockStart, blockEnd + 1).join(lineEnding);
|
|
201
|
+
const nextBlock = setEpicVideoUrl({ epicVideoBlock: currentBlock, url });
|
|
202
|
+
if (nextBlock === currentBlock) {
|
|
203
|
+
return {
|
|
204
|
+
status: 'success',
|
|
205
|
+
outcome: 'unchanged',
|
|
206
|
+
nextContent: content,
|
|
207
|
+
};
|
|
208
|
+
}
|
|
209
|
+
const nextLines = [
|
|
210
|
+
...lines.slice(0, blockStart),
|
|
211
|
+
...nextBlock.split(/\r?\n/),
|
|
212
|
+
...lines.slice(blockEnd + 1),
|
|
213
|
+
];
|
|
214
|
+
return {
|
|
215
|
+
status: 'success',
|
|
216
|
+
outcome: 'updated',
|
|
217
|
+
nextContent: nextLines.join(lineEnding),
|
|
218
|
+
};
|
|
219
|
+
}
|
|
220
|
+
const epicVideoLine = `<EpicVideo url="${url}" />`;
|
|
221
|
+
const nextLines = [
|
|
222
|
+
...lines.slice(0, titleLineIndex + 1),
|
|
223
|
+
'',
|
|
224
|
+
epicVideoLine,
|
|
225
|
+
'',
|
|
226
|
+
...lines.slice(firstNonEmptyAfterTitle),
|
|
227
|
+
];
|
|
228
|
+
return {
|
|
229
|
+
status: 'success',
|
|
230
|
+
outcome: 'inserted',
|
|
231
|
+
nextContent: nextLines.join(lineEnding),
|
|
232
|
+
};
|
|
233
|
+
}
|
|
234
|
+
function formatNumberedList(items, { startAt = 1 } = {}) {
|
|
235
|
+
return items.map((item, index) => `${startAt + index}. ${item}`).join('\n');
|
|
236
|
+
}
|
|
237
|
+
function buildFileLessonSlotPlans(files) {
|
|
238
|
+
const plans = [];
|
|
239
|
+
const stepSlotByKey = new Map();
|
|
240
|
+
let nextLessonSlotIndex = 0;
|
|
241
|
+
for (const file of files) {
|
|
242
|
+
if ((file.kind === 'step-problem' || file.kind === 'step-solution') &&
|
|
243
|
+
typeof file.exerciseNumber === 'number' &&
|
|
244
|
+
typeof file.stepNumber === 'number') {
|
|
245
|
+
const key = `${file.exerciseNumber}:${file.stepNumber}`;
|
|
246
|
+
const existingSlot = stepSlotByKey.get(key);
|
|
247
|
+
if (typeof existingSlot === 'number') {
|
|
248
|
+
plans.push({ file, lessonSlotIndex: existingSlot });
|
|
249
|
+
continue;
|
|
250
|
+
}
|
|
251
|
+
const newSlot = nextLessonSlotIndex++;
|
|
252
|
+
stepSlotByKey.set(key, newSlot);
|
|
253
|
+
plans.push({ file, lessonSlotIndex: newSlot });
|
|
254
|
+
continue;
|
|
255
|
+
}
|
|
256
|
+
const slot = nextLessonSlotIndex++;
|
|
257
|
+
plans.push({ file, lessonSlotIndex: slot });
|
|
258
|
+
}
|
|
259
|
+
return { plans, requiredLessonSlots: nextLessonSlotIndex };
|
|
260
|
+
}
|
|
261
|
+
export async function setVideos(options = {}) {
|
|
262
|
+
const workshopRoot = path.resolve(options.workshopRoot ?? process.env.EPICSHOP_CONTEXT_CWD ?? process.cwd());
|
|
263
|
+
process.env.EPICSHOP_CONTEXT_CWD = workshopRoot;
|
|
264
|
+
const { silent = false, dryRun = false } = options;
|
|
265
|
+
const fail = (message, { warnings = [] } = {}) => {
|
|
266
|
+
if (!silent) {
|
|
267
|
+
console.log(chalk.bold.cyan('\n🛠️ Admin: Set videos\n'));
|
|
268
|
+
console.log(chalk.red(`❌ ${message}`));
|
|
269
|
+
if (warnings.length > 0) {
|
|
270
|
+
console.log();
|
|
271
|
+
for (const warning of warnings) {
|
|
272
|
+
console.log(chalk.yellow(`⚠️ ${warning}`));
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
console.log();
|
|
276
|
+
}
|
|
277
|
+
return {
|
|
278
|
+
...createFailureResult(message, { dryRun }),
|
|
279
|
+
warnings,
|
|
280
|
+
};
|
|
281
|
+
};
|
|
282
|
+
const packageJsonPath = path.join(workshopRoot, 'package.json');
|
|
283
|
+
let packageJson;
|
|
284
|
+
try {
|
|
285
|
+
const raw = await fs.readFile(packageJsonPath, 'utf8');
|
|
286
|
+
packageJson = JSON.parse(raw);
|
|
287
|
+
}
|
|
288
|
+
catch (error) {
|
|
289
|
+
return fail(`Failed to read/parse package.json: ${getErrorMessage(error)}`);
|
|
290
|
+
}
|
|
291
|
+
const product = packageJson && typeof packageJson === 'object'
|
|
292
|
+
? packageJson.epicshop?.product
|
|
293
|
+
: null;
|
|
294
|
+
const productHost = product &&
|
|
295
|
+
typeof product === 'object' &&
|
|
296
|
+
typeof product.host === 'string'
|
|
297
|
+
? product.host.trim()
|
|
298
|
+
: '';
|
|
299
|
+
const productSlug = product &&
|
|
300
|
+
typeof product === 'object' &&
|
|
301
|
+
typeof product.slug === 'string'
|
|
302
|
+
? product.slug.trim()
|
|
303
|
+
: '';
|
|
304
|
+
if (!productHost) {
|
|
305
|
+
return fail('Missing `epicshop.product.host` in package.json (required for set-videos)');
|
|
306
|
+
}
|
|
307
|
+
if (/^https?:\/\//i.test(productHost) || productHost.includes('/')) {
|
|
308
|
+
return fail('`epicshop.product.host` should be a host only (for example: "www.epicweb.dev")');
|
|
309
|
+
}
|
|
310
|
+
if (!productSlug) {
|
|
311
|
+
return fail('Missing `epicshop.product.slug` in package.json (required for set-videos)');
|
|
312
|
+
}
|
|
313
|
+
const { files, errors, warnings } = await collectOrderedVideoFiles({
|
|
314
|
+
workshopRoot,
|
|
315
|
+
});
|
|
316
|
+
if (errors.length > 0) {
|
|
317
|
+
const message = `Cannot set videos because workshop structure is invalid:\n- ${errors.join('\n- ')}`;
|
|
318
|
+
return fail(message, {
|
|
319
|
+
warnings,
|
|
320
|
+
});
|
|
321
|
+
}
|
|
322
|
+
const normalizeLessonSlug = (value) => stripEpicAiSlugSuffix(value.trim());
|
|
323
|
+
const remoteResult = await fetchRemoteWorkshopLessons({
|
|
324
|
+
productHost,
|
|
325
|
+
workshopSlug: productSlug,
|
|
326
|
+
normalizeLessonSlug,
|
|
327
|
+
normalizeSectionSlug: normalizeLessonSlug,
|
|
328
|
+
requireNonEmptyLessonSlug: true,
|
|
329
|
+
});
|
|
330
|
+
if (remoteResult.status === 'error') {
|
|
331
|
+
return fail(remoteResult.message, {
|
|
332
|
+
warnings,
|
|
333
|
+
});
|
|
334
|
+
}
|
|
335
|
+
const remoteLessons = remoteResult.lessons;
|
|
336
|
+
const remoteModuleType = remoteResult.moduleType;
|
|
337
|
+
if (remoteLessons.length === 0) {
|
|
338
|
+
return fail('Product API returned no lessons. Is the workshop published on the product site?', {
|
|
339
|
+
warnings,
|
|
340
|
+
});
|
|
341
|
+
}
|
|
342
|
+
const { plans: fileLessonSlotPlans, requiredLessonSlots } = buildFileLessonSlotPlans(files);
|
|
343
|
+
if (remoteLessons.length < requiredLessonSlots) {
|
|
344
|
+
const assignedPairs = fileLessonSlotPlans
|
|
345
|
+
.filter((plan) => plan.lessonSlotIndex < remoteLessons.length)
|
|
346
|
+
.map((plan) => {
|
|
347
|
+
const lesson = remoteLessons[plan.lessonSlotIndex];
|
|
348
|
+
if (!lesson)
|
|
349
|
+
return `${plan.file.relativePath} -> (no lesson)`;
|
|
350
|
+
const lessonUrl = formatProductLessonUrl({
|
|
351
|
+
productHost,
|
|
352
|
+
productSlug,
|
|
353
|
+
moduleType: remoteModuleType,
|
|
354
|
+
lessonSlug: lesson.slug,
|
|
355
|
+
sectionSlug: lesson.sectionSlug,
|
|
356
|
+
});
|
|
357
|
+
return `${plan.file.relativePath} -> ${lessonUrl}`;
|
|
358
|
+
});
|
|
359
|
+
const unassignedLocalFiles = fileLessonSlotPlans
|
|
360
|
+
.filter((plan) => plan.lessonSlotIndex >= remoteLessons.length)
|
|
361
|
+
.map((plan) => `${plan.file.relativePath} (lesson slot ${plan.lessonSlotIndex + 1})`);
|
|
362
|
+
const remoteLessonsInOrder = remoteLessons.map((lesson) => {
|
|
363
|
+
const lessonPath = lesson.sectionSlug
|
|
364
|
+
? `${lesson.sectionSlug}/${lesson.slug}`
|
|
365
|
+
: lesson.slug;
|
|
366
|
+
const lessonUrl = formatProductLessonUrl({
|
|
367
|
+
productHost,
|
|
368
|
+
productSlug,
|
|
369
|
+
moduleType: remoteModuleType,
|
|
370
|
+
lessonSlug: lesson.slug,
|
|
371
|
+
sectionSlug: lesson.sectionSlug,
|
|
372
|
+
});
|
|
373
|
+
return `${lessonPath} -> ${lessonUrl}`;
|
|
374
|
+
});
|
|
375
|
+
const requiredLocalFilesInOrder = fileLessonSlotPlans.map((plan) => `${plan.file.relativePath} (lesson slot ${plan.lessonSlotIndex + 1})`);
|
|
376
|
+
return fail(`Not enough product lessons to map onto workshop files.\nExpected at least ${requiredLessonSlots} lessons, but received ${remoteLessons.length}.\nMissing ${requiredLessonSlots - remoteLessons.length} lesson(s).\nThis mapping uses one lesson slot for workshop intro/wrap-up, exercise intro/summary, and one shared lesson slot per exercise step (applied to both problem + solution files).\n\nAssigned file/video pairs (in order):\n${assignedPairs.length > 0 ? formatNumberedList(assignedPairs) : '(none)'}\n\nUnassigned local files (in order):\n${formatNumberedList(unassignedLocalFiles, {
|
|
377
|
+
startAt: assignedPairs.length + 1,
|
|
378
|
+
})}\n\nProduct lessons returned by API (in order):\n${formatNumberedList(remoteLessonsInOrder)}\n\nRequired local files (in order):\n${formatNumberedList(requiredLocalFilesInOrder)}\n\nHint: verify the product workshop has all expected lessons published and in the same order as the local exercise/step instruction files.`, {
|
|
379
|
+
warnings,
|
|
380
|
+
});
|
|
381
|
+
}
|
|
382
|
+
const plannedEdits = [];
|
|
383
|
+
const editErrors = [];
|
|
384
|
+
for (const plan of fileLessonSlotPlans) {
|
|
385
|
+
const lesson = remoteLessons[plan.lessonSlotIndex];
|
|
386
|
+
if (!lesson)
|
|
387
|
+
continue;
|
|
388
|
+
const targetUrl = formatProductLessonUrl({
|
|
389
|
+
productHost,
|
|
390
|
+
productSlug,
|
|
391
|
+
moduleType: remoteModuleType,
|
|
392
|
+
lessonSlug: lesson.slug,
|
|
393
|
+
sectionSlug: lesson.sectionSlug,
|
|
394
|
+
});
|
|
395
|
+
let currentContent = '';
|
|
396
|
+
try {
|
|
397
|
+
currentContent = await fs.readFile(plan.file.fullPath, 'utf8');
|
|
398
|
+
}
|
|
399
|
+
catch (error) {
|
|
400
|
+
editErrors.push(`Failed to read "${plan.file.relativePath}": ${getErrorMessage(error)}`);
|
|
401
|
+
continue;
|
|
402
|
+
}
|
|
403
|
+
const result = upsertTitleEpicVideo({
|
|
404
|
+
content: currentContent,
|
|
405
|
+
url: targetUrl,
|
|
406
|
+
});
|
|
407
|
+
if (result.status === 'error') {
|
|
408
|
+
editErrors.push(`${plan.file.relativePath}: ${result.message}`);
|
|
409
|
+
continue;
|
|
410
|
+
}
|
|
411
|
+
plannedEdits.push({
|
|
412
|
+
file: plan.file,
|
|
413
|
+
nextContent: result.nextContent,
|
|
414
|
+
outcome: result.outcome,
|
|
415
|
+
});
|
|
416
|
+
}
|
|
417
|
+
if (editErrors.length > 0) {
|
|
418
|
+
return fail(`Could not update videos for all files:\n- ${editErrors.join('\n- ')}`, {
|
|
419
|
+
warnings,
|
|
420
|
+
});
|
|
421
|
+
}
|
|
422
|
+
if (!dryRun) {
|
|
423
|
+
for (const edit of plannedEdits) {
|
|
424
|
+
if (edit.outcome === 'unchanged')
|
|
425
|
+
continue;
|
|
426
|
+
await fs.writeFile(edit.file.fullPath, edit.nextContent);
|
|
427
|
+
}
|
|
428
|
+
}
|
|
429
|
+
const inserted = plannedEdits.filter((edit) => edit.outcome === 'inserted').length;
|
|
430
|
+
const updated = plannedEdits.filter((edit) => edit.outcome === 'updated').length;
|
|
431
|
+
const unchanged = plannedEdits.filter((edit) => edit.outcome === 'unchanged').length;
|
|
432
|
+
if (remoteLessons.length > requiredLessonSlots) {
|
|
433
|
+
const extras = remoteLessons
|
|
434
|
+
.slice(requiredLessonSlots)
|
|
435
|
+
.map((lesson) => formatProductLessonUrl({
|
|
436
|
+
productHost,
|
|
437
|
+
productSlug,
|
|
438
|
+
moduleType: remoteModuleType,
|
|
439
|
+
lessonSlug: lesson.slug,
|
|
440
|
+
sectionSlug: lesson.sectionSlug,
|
|
441
|
+
}));
|
|
442
|
+
warnings.push(`Product has ${extras.length} extra lesson(s) beyond mapped lesson slots:\n- ${extras.join('\n- ')}`);
|
|
443
|
+
}
|
|
444
|
+
if (!silent) {
|
|
445
|
+
console.log(chalk.bold.cyan('\n🛠️ Admin: Set videos\n'));
|
|
446
|
+
console.log(chalk.green(`✅ ${dryRun ? 'Planned' : 'Updated'} EpicVideo mappings (inserted: ${inserted}, updated: ${updated}, unchanged: ${unchanged})`));
|
|
447
|
+
if (dryRun) {
|
|
448
|
+
console.log(chalk.yellow('🧪 Dry run enabled: no files were modified.'));
|
|
449
|
+
}
|
|
450
|
+
if (warnings.length > 0) {
|
|
451
|
+
console.log();
|
|
452
|
+
for (const warning of warnings) {
|
|
453
|
+
console.log(chalk.yellow(`⚠️ ${warning}`));
|
|
454
|
+
}
|
|
455
|
+
}
|
|
456
|
+
console.log();
|
|
457
|
+
}
|
|
458
|
+
return {
|
|
459
|
+
success: true,
|
|
460
|
+
message: dryRun
|
|
461
|
+
? 'Set videos dry run completed successfully'
|
|
462
|
+
: 'Set videos completed successfully',
|
|
463
|
+
inserted,
|
|
464
|
+
updated,
|
|
465
|
+
unchanged,
|
|
466
|
+
warnings,
|
|
467
|
+
dryRun,
|
|
468
|
+
};
|
|
469
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { type Dirent } from 'node:fs';
|
|
2
|
+
export type RemoteLesson = {
|
|
3
|
+
slug: string;
|
|
4
|
+
sectionSlug: string | null;
|
|
5
|
+
};
|
|
6
|
+
export type ProductModuleType = 'workshop' | 'tutorial';
|
|
7
|
+
type NormalizeSlug = (value: string) => string;
|
|
8
|
+
export declare function stripEpicAiSlugSuffix(value: string): string;
|
|
9
|
+
export declare function formatProductLessonUrl({ productHost, productSlug, moduleType, lessonSlug, sectionSlug, }: {
|
|
10
|
+
productHost: string;
|
|
11
|
+
productSlug: string;
|
|
12
|
+
moduleType?: ProductModuleType;
|
|
13
|
+
lessonSlug: string;
|
|
14
|
+
sectionSlug: string | null;
|
|
15
|
+
}): string;
|
|
16
|
+
export declare function isDirectory(targetPath: string): Promise<boolean>;
|
|
17
|
+
export declare function resolveMdxFile(dir: string, baseName: 'README' | 'FINISHED'): Promise<string | null>;
|
|
18
|
+
export declare function collectStepDirectories(entries: Array<Dirent>, exerciseRoot: string): Map<number, {
|
|
19
|
+
problems: Array<string>;
|
|
20
|
+
solutions: Array<string>;
|
|
21
|
+
}>;
|
|
22
|
+
export declare function fetchRemoteWorkshopLessons({ productHost, workshopSlug, normalizeLessonSlug, normalizeSectionSlug, requireNonEmptyLessonSlug, }: {
|
|
23
|
+
productHost: string;
|
|
24
|
+
workshopSlug: string;
|
|
25
|
+
normalizeLessonSlug?: NormalizeSlug;
|
|
26
|
+
normalizeSectionSlug?: NormalizeSlug;
|
|
27
|
+
requireNonEmptyLessonSlug?: boolean;
|
|
28
|
+
}): Promise<{
|
|
29
|
+
status: 'success';
|
|
30
|
+
lessons: Array<RemoteLesson>;
|
|
31
|
+
moduleType: ProductModuleType;
|
|
32
|
+
} | {
|
|
33
|
+
status: 'error';
|
|
34
|
+
message: string;
|
|
35
|
+
}>;
|
|
36
|
+
export {};
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
import fs from 'node:fs/promises';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { getAuthInfo } from '@epic-web/workshop-utils/db.server';
|
|
4
|
+
import { getErrorMessage } from '@epic-web/workshop-utils/utils';
|
|
5
|
+
import { pathExists } from "../../utils/filesystem.js";
|
|
6
|
+
export function stripEpicAiSlugSuffix(value) {
|
|
7
|
+
// EpicAI embeds sometimes include a `~...` suffix in the slug segment.
|
|
8
|
+
return value.replace(/~[^ ]*$/, '');
|
|
9
|
+
}
|
|
10
|
+
export function formatProductLessonUrl({ productHost, productSlug, moduleType = 'workshop', lessonSlug, sectionSlug, }) {
|
|
11
|
+
const productPath = moduleType === 'tutorial' ? 'tutorials' : 'workshops';
|
|
12
|
+
// The product site will typically redirect to a section-specific path when needed.
|
|
13
|
+
return sectionSlug
|
|
14
|
+
? `https://${productHost}/${productPath}/${productSlug}/${sectionSlug}/${lessonSlug}`
|
|
15
|
+
: `https://${productHost}/${productPath}/${productSlug}/${lessonSlug}`;
|
|
16
|
+
}
|
|
17
|
+
export async function isDirectory(targetPath) {
|
|
18
|
+
try {
|
|
19
|
+
return (await fs.stat(targetPath)).isDirectory();
|
|
20
|
+
}
|
|
21
|
+
catch {
|
|
22
|
+
return false;
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
export async function resolveMdxFile(dir, baseName) {
|
|
26
|
+
const mdx = path.join(dir, `${baseName}.mdx`);
|
|
27
|
+
if (await pathExists(mdx))
|
|
28
|
+
return mdx;
|
|
29
|
+
return null;
|
|
30
|
+
}
|
|
31
|
+
export function collectStepDirectories(entries, exerciseRoot) {
|
|
32
|
+
const stepDirRegex = /^(?<stepNumber>\d+)\.(?<type>problem|solution)(\..*)?$/;
|
|
33
|
+
const stepsByNumber = new Map();
|
|
34
|
+
for (const entry of entries) {
|
|
35
|
+
if (!entry.isDirectory())
|
|
36
|
+
continue;
|
|
37
|
+
const match = stepDirRegex.exec(entry.name);
|
|
38
|
+
if (!match?.groups)
|
|
39
|
+
continue;
|
|
40
|
+
const stepNumber = Number(match.groups.stepNumber);
|
|
41
|
+
const type = match.groups.type;
|
|
42
|
+
if (!Number.isFinite(stepNumber) || stepNumber <= 0)
|
|
43
|
+
continue;
|
|
44
|
+
const current = stepsByNumber.get(stepNumber) ?? {
|
|
45
|
+
problems: [],
|
|
46
|
+
solutions: [],
|
|
47
|
+
};
|
|
48
|
+
const fullStepDir = path.join(exerciseRoot, entry.name);
|
|
49
|
+
if (type === 'problem')
|
|
50
|
+
current.problems.push(fullStepDir);
|
|
51
|
+
if (type === 'solution')
|
|
52
|
+
current.solutions.push(fullStepDir);
|
|
53
|
+
stepsByNumber.set(stepNumber, current);
|
|
54
|
+
}
|
|
55
|
+
return stepsByNumber;
|
|
56
|
+
}
|
|
57
|
+
export async function fetchRemoteWorkshopLessons({ productHost, workshopSlug, normalizeLessonSlug, normalizeSectionSlug, requireNonEmptyLessonSlug = false, }) {
|
|
58
|
+
const url = `https://${productHost}/api/workshops/${encodeURIComponent(workshopSlug)}`;
|
|
59
|
+
const fetchOnce = async (accessToken) => {
|
|
60
|
+
const timeout = AbortSignal.timeout(15_000);
|
|
61
|
+
const headers = {};
|
|
62
|
+
if (accessToken)
|
|
63
|
+
headers.authorization = `Bearer ${accessToken}`;
|
|
64
|
+
return fetch(url, { headers, signal: timeout });
|
|
65
|
+
};
|
|
66
|
+
let response = null;
|
|
67
|
+
try {
|
|
68
|
+
response = await fetchOnce();
|
|
69
|
+
}
|
|
70
|
+
catch (error) {
|
|
71
|
+
return {
|
|
72
|
+
status: 'error',
|
|
73
|
+
message: `Failed to fetch product workshop data: ${getErrorMessage(error)}`,
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
if (response.status === 401 || response.status === 403) {
|
|
77
|
+
const authInfo = await getAuthInfo({ productHost }).catch(() => null);
|
|
78
|
+
const accessToken = authInfo?.tokenSet?.access_token;
|
|
79
|
+
if (accessToken) {
|
|
80
|
+
try {
|
|
81
|
+
response = await fetchOnce(accessToken);
|
|
82
|
+
}
|
|
83
|
+
catch (error) {
|
|
84
|
+
return {
|
|
85
|
+
status: 'error',
|
|
86
|
+
message: `Failed to fetch product workshop data (after auth): ${getErrorMessage(error)}`,
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
if (!response.ok) {
|
|
92
|
+
const body = await response.text().catch(() => '');
|
|
93
|
+
const hint = response.status === 401 || response.status === 403
|
|
94
|
+
? ` (try: npx epicshop auth login ${productHost.replace(/^www\./, '')})`
|
|
95
|
+
: response.status === 404
|
|
96
|
+
? ` (check epicshop.product.host + epicshop.product.slug)`
|
|
97
|
+
: '';
|
|
98
|
+
return {
|
|
99
|
+
status: 'error',
|
|
100
|
+
message: `Product API request failed: ${response.status} ${response.statusText}${hint}${body ? `\n${body}` : ''}`,
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
let data;
|
|
104
|
+
try {
|
|
105
|
+
data = await response.json();
|
|
106
|
+
}
|
|
107
|
+
catch (error) {
|
|
108
|
+
return {
|
|
109
|
+
status: 'error',
|
|
110
|
+
message: `Product API response was not valid JSON: ${getErrorMessage(error)}`,
|
|
111
|
+
};
|
|
112
|
+
}
|
|
113
|
+
const resources = data && typeof data === 'object' && 'resources' in data
|
|
114
|
+
? data.resources
|
|
115
|
+
: null;
|
|
116
|
+
const moduleType = data &&
|
|
117
|
+
typeof data === 'object' &&
|
|
118
|
+
'moduleType' in data &&
|
|
119
|
+
data.moduleType === 'tutorial'
|
|
120
|
+
? 'tutorial'
|
|
121
|
+
: 'workshop';
|
|
122
|
+
if (!Array.isArray(resources)) {
|
|
123
|
+
return {
|
|
124
|
+
status: 'error',
|
|
125
|
+
message: `Product API response did not include an array "resources" field`,
|
|
126
|
+
};
|
|
127
|
+
}
|
|
128
|
+
const applyNormalizer = (value, normalizer) => {
|
|
129
|
+
return normalizer ? normalizer(value) : value;
|
|
130
|
+
};
|
|
131
|
+
const lessons = [];
|
|
132
|
+
for (const resource of resources) {
|
|
133
|
+
if (!resource || typeof resource !== 'object')
|
|
134
|
+
continue;
|
|
135
|
+
const item = resource;
|
|
136
|
+
if (item._type === 'lesson') {
|
|
137
|
+
const slug = item.slug;
|
|
138
|
+
if (typeof slug === 'string') {
|
|
139
|
+
const normalizedSlug = applyNormalizer(slug, normalizeLessonSlug);
|
|
140
|
+
if (requireNonEmptyLessonSlug && normalizedSlug.trim().length === 0) {
|
|
141
|
+
continue;
|
|
142
|
+
}
|
|
143
|
+
lessons.push({ slug: normalizedSlug, sectionSlug: null });
|
|
144
|
+
}
|
|
145
|
+
continue;
|
|
146
|
+
}
|
|
147
|
+
if (item._type === 'section') {
|
|
148
|
+
let sectionSlug = null;
|
|
149
|
+
if (typeof item.slug === 'string') {
|
|
150
|
+
const normalizedSectionSlug = applyNormalizer(item.slug, normalizeSectionSlug);
|
|
151
|
+
const trimmedSectionSlug = normalizedSectionSlug.trim();
|
|
152
|
+
if (trimmedSectionSlug.length > 0) {
|
|
153
|
+
sectionSlug = trimmedSectionSlug;
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
const sectionLessons = item.lessons;
|
|
157
|
+
if (!Array.isArray(sectionLessons))
|
|
158
|
+
continue;
|
|
159
|
+
for (const lesson of sectionLessons) {
|
|
160
|
+
if (!lesson || typeof lesson !== 'object')
|
|
161
|
+
continue;
|
|
162
|
+
const lessonItem = lesson;
|
|
163
|
+
const slug = lessonItem.slug;
|
|
164
|
+
if (typeof slug === 'string') {
|
|
165
|
+
const normalizedSlug = applyNormalizer(slug, normalizeLessonSlug);
|
|
166
|
+
if (requireNonEmptyLessonSlug && normalizedSlug.trim().length === 0) {
|
|
167
|
+
continue;
|
|
168
|
+
}
|
|
169
|
+
lessons.push({ slug: normalizedSlug, sectionSlug });
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
return { status: 'success', lessons, moduleType };
|
|
175
|
+
}
|
package/dist/commands/admin.d.ts
CHANGED
package/dist/commands/admin.js
CHANGED
package/dist/commands/cleanup.js
CHANGED
|
@@ -630,7 +630,12 @@ export async function cleanup({ silent = false, force = false, targets, workshop
|
|
|
630
630
|
// explicit `--workshops` were provided, we treat the current workshop
|
|
631
631
|
// as the only workshop candidate. This avoids suggesting other
|
|
632
632
|
// workshops in prompts and keeps size estimates accurate.
|
|
633
|
-
|
|
633
|
+
updateSpinner(analysisSpinner, 'Resolving current workshop...');
|
|
634
|
+
const repoWorkshops = await listWorkshopsInDirectory(reposDir);
|
|
635
|
+
const matchingRepoWorkshop = repoWorkshops.find((workshop) => workshop.repoName === workshopFromCwd.repoName);
|
|
636
|
+
// Prefer the repos-directory path when available so workshop IDs are
|
|
637
|
+
// stable even when cwd uses an equivalent symlinked path.
|
|
638
|
+
allWorkshops = [matchingRepoWorkshop ?? workshopFromCwd];
|
|
634
639
|
}
|
|
635
640
|
else {
|
|
636
641
|
updateSpinner(analysisSpinner, 'Finding installed workshops...');
|
|
@@ -655,6 +660,11 @@ export async function cleanup({ silent = false, force = false, targets, workshop
|
|
|
655
660
|
contextWorkshop =
|
|
656
661
|
workshopIdentities.find((workshop) => workshop.id === cwdId) ?? null;
|
|
657
662
|
}
|
|
663
|
+
if (!contextWorkshop &&
|
|
664
|
+
shouldScopeToCwdWorkshop &&
|
|
665
|
+
workshopIdentities.length === 1) {
|
|
666
|
+
contextWorkshop = workshopIdentities[0] ?? null;
|
|
667
|
+
}
|
|
658
668
|
}
|
|
659
669
|
if (selectedTargets.length === 0) {
|
|
660
670
|
if (allWorkshops.length > 0) {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "epicshop",
|
|
3
|
-
"version": "6.
|
|
3
|
+
"version": "6.85.1",
|
|
4
4
|
"publishConfig": {
|
|
5
5
|
"access": "public"
|
|
6
6
|
},
|
|
@@ -105,7 +105,7 @@
|
|
|
105
105
|
"build:watch": "nx watch --projects=epicshop -- nx run \\$NX_PROJECT_NAME:build"
|
|
106
106
|
},
|
|
107
107
|
"dependencies": {
|
|
108
|
-
"@epic-web/workshop-utils": "6.
|
|
108
|
+
"@epic-web/workshop-utils": "6.85.1",
|
|
109
109
|
"@inquirer/prompts": "^8.2.0",
|
|
110
110
|
"@sentry/node": "^10.38.0",
|
|
111
111
|
"chalk": "^5.6.2",
|