epicshop 6.84.7 → 6.85.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
@@ -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
- .example('$0 admin launch-readiness', 'Check workshop launch readiness (hidden command)');
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,14 +1,10 @@
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";
8
- function stripEpicAiSlugSuffix(value) {
9
- // EpicAI embeds sometimes include a `~...` suffix in the slug segment.
10
- return value.replace(/~[^ ]*$/, '');
11
- }
7
+ import { collectStepDirectories, formatProductLessonUrl, fetchRemoteWorkshopLessons, isDirectory, resolveMdxFile, stripEpicAiSlugSuffix, } from "./workshop-content-utils.js";
12
8
  function normalizeHost(host) {
13
9
  return host.toLowerCase().replace(/^www\./, '');
14
10
  }
@@ -59,10 +55,6 @@ function parseEpicLessonSlugFromEmbedUrl(urlString) {
59
55
  return parseSegments(segments);
60
56
  }
61
57
  }
62
- function formatProductLessonUrl({ productHost, productSlug, lessonSlug, }) {
63
- // The product site will typically redirect to a section-specific path when needed.
64
- return `https://${productHost}/workshops/${productSlug}/${lessonSlug}`;
65
- }
66
58
  function formatIssue(issue, workshopRoot) {
67
59
  const icon = issue.level === 'error' ? chalk.red('❌') : chalk.yellow('⚠️ ');
68
60
  const filePart = issue.file
@@ -70,20 +62,6 @@ function formatIssue(issue, workshopRoot) {
70
62
  : '';
71
63
  return `${icon} ${issue.message}${filePart}`;
72
64
  }
73
- async function isDirectory(targetPath) {
74
- try {
75
- return (await fs.stat(targetPath)).isDirectory();
76
- }
77
- catch {
78
- return false;
79
- }
80
- }
81
- async function resolveMdxFile(dir, baseName) {
82
- const mdx = path.join(dir, `${baseName}.mdx`);
83
- if (await pathExists(mdx))
84
- return mdx;
85
- return null;
86
- }
87
65
  async function buildExpectedFiles({ workshopRoot, exerciseDirName, }) {
88
66
  const issues = [];
89
67
  const files = [];
@@ -142,29 +120,7 @@ async function buildExpectedFiles({ workshopRoot, exerciseDirName, }) {
142
120
  });
143
121
  return { files, contentFiles, issues };
144
122
  }
145
- const stepDirRegex = /^(?<stepNumber>\d+)\.(?<type>problem|solution)(\..*)?$/;
146
- const stepsByNumber = new Map();
147
- for (const entry of entries) {
148
- if (!entry.isDirectory())
149
- continue;
150
- const match = stepDirRegex.exec(entry.name);
151
- if (!match?.groups)
152
- continue;
153
- const stepNumber = Number(match.groups.stepNumber);
154
- const type = match.groups.type;
155
- if (!Number.isFinite(stepNumber) || stepNumber <= 0)
156
- continue;
157
- const current = stepsByNumber.get(stepNumber) ?? {
158
- problems: [],
159
- solutions: [],
160
- };
161
- const fullStepDir = path.join(exerciseRoot, entry.name);
162
- if (type === 'problem')
163
- current.problems.push(fullStepDir);
164
- if (type === 'solution')
165
- current.solutions.push(fullStepDir);
166
- stepsByNumber.set(stepNumber, current);
167
- }
123
+ const stepsByNumber = collectStepDirectories(entries, exerciseRoot);
168
124
  if (stepsByNumber.size === 0) {
169
125
  issues.push({
170
126
  level: 'warning',
@@ -253,96 +209,6 @@ async function buildExpectedFiles({ workshopRoot, exerciseDirName, }) {
253
209
  }
254
210
  return { files, contentFiles, issues };
255
211
  }
256
- async function fetchRemoteWorkshopLessonSlugs({ productHost, workshopSlug, }) {
257
- const url = `https://${productHost}/api/workshops/${encodeURIComponent(workshopSlug)}`;
258
- const fetchOnce = async (accessToken) => {
259
- const timeout = AbortSignal.timeout(15_000);
260
- const headers = {};
261
- if (accessToken)
262
- headers.authorization = `Bearer ${accessToken}`;
263
- return fetch(url, { headers, signal: timeout });
264
- };
265
- let response = null;
266
- try {
267
- response = await fetchOnce();
268
- }
269
- catch (error) {
270
- return {
271
- status: 'error',
272
- message: `Failed to fetch product workshop data: ${getErrorMessage(error)}`,
273
- };
274
- }
275
- if (response.status === 401 || response.status === 403) {
276
- const authInfo = await getAuthInfo({ productHost }).catch(() => null);
277
- const accessToken = authInfo?.tokenSet?.access_token;
278
- if (accessToken) {
279
- try {
280
- response = await fetchOnce(accessToken);
281
- }
282
- catch (error) {
283
- return {
284
- status: 'error',
285
- message: `Failed to fetch product workshop data (after auth): ${getErrorMessage(error)}`,
286
- };
287
- }
288
- }
289
- }
290
- if (!response.ok) {
291
- const body = await response.text().catch(() => '');
292
- const hint = response.status === 401 || response.status === 403
293
- ? ` (try: npx epicshop auth login ${productHost.replace(/^www\./, '')})`
294
- : response.status === 404
295
- ? ` (check epicshop.product.host + epicshop.product.slug)`
296
- : '';
297
- return {
298
- status: 'error',
299
- message: `Product API request failed: ${response.status} ${response.statusText}${hint}${body ? `\n${body}` : ''}`,
300
- };
301
- }
302
- let data;
303
- try {
304
- data = await response.json();
305
- }
306
- catch (error) {
307
- return {
308
- status: 'error',
309
- message: `Product API response was not valid JSON: ${getErrorMessage(error)}`,
310
- };
311
- }
312
- const resources = data?.resources;
313
- if (!Array.isArray(resources)) {
314
- return {
315
- status: 'error',
316
- message: `Product API response did not include an array "resources" field`,
317
- };
318
- }
319
- const lessonSlugs = [];
320
- for (const resource of resources) {
321
- if (!resource || typeof resource !== 'object')
322
- continue;
323
- const r = resource;
324
- if (r._type === 'lesson') {
325
- const slug = r.slug;
326
- if (typeof slug === 'string')
327
- lessonSlugs.push(slug);
328
- continue;
329
- }
330
- if (r._type === 'section') {
331
- const lessons = r.lessons;
332
- if (!Array.isArray(lessons))
333
- continue;
334
- for (const lesson of lessons) {
335
- if (!lesson || typeof lesson !== 'object')
336
- continue;
337
- const l = lesson;
338
- const slug = l.slug;
339
- if (typeof slug === 'string')
340
- lessonSlugs.push(slug);
341
- }
342
- }
343
- }
344
- return { status: 'success', lessonSlugs };
345
- }
346
212
  async function checkMinContentLength({ fullPath, minChars, }) {
347
213
  try {
348
214
  const raw = await fs.readFile(fullPath, 'utf8');
@@ -838,7 +704,7 @@ export async function launchReadiness(options = {}) {
838
704
  }
839
705
  localProductLessonSlugs.add(lessonSlug);
840
706
  }
841
- const remote = await fetchRemoteWorkshopLessonSlugs({
707
+ const remote = await fetchRemoteWorkshopLessons({
842
708
  productHost,
843
709
  workshopSlug: productSlug,
844
710
  });
@@ -850,7 +716,21 @@ export async function launchReadiness(options = {}) {
850
716
  });
851
717
  }
852
718
  else {
853
- const remoteLessonSlugs = Array.from(new Set(remote.lessonSlugs.map(stripEpicAiSlugSuffix)));
719
+ const remoteLessons = remote.lessons
720
+ .map((l) => ({
721
+ slug: stripEpicAiSlugSuffix(l.slug),
722
+ sectionSlug: l.sectionSlug
723
+ ? stripEpicAiSlugSuffix(l.sectionSlug)
724
+ : null,
725
+ }))
726
+ .filter((l) => l.slug.trim().length > 0);
727
+ // Preserve the first sectionSlug seen for a given lesson slug.
728
+ const remoteLessonBySlug = new Map();
729
+ for (const l of remoteLessons) {
730
+ if (!remoteLessonBySlug.has(l.slug))
731
+ remoteLessonBySlug.set(l.slug, l);
732
+ }
733
+ const remoteLessonSlugs = [...remoteLessonBySlug.keys()];
854
734
  if (remoteLessonSlugs.length === 0) {
855
735
  issues.push({
856
736
  level: 'error',
@@ -862,11 +742,15 @@ export async function launchReadiness(options = {}) {
862
742
  if (missing.length) {
863
743
  const formatted = missing
864
744
  .sort()
865
- .map((slug) => `- ${slug}: ${formatProductLessonUrl({
866
- productHost,
867
- productSlug,
868
- lessonSlug: slug,
869
- })}`)
745
+ .map((slug) => {
746
+ const remoteLesson = remoteLessonBySlug.get(slug);
747
+ return `- ${slug}: ${formatProductLessonUrl({
748
+ productHost,
749
+ productSlug,
750
+ lessonSlug: slug,
751
+ sectionSlug: remoteLesson?.sectionSlug ?? null,
752
+ })}`;
753
+ })
870
754
  .join('\n');
871
755
  issues.push({
872
756
  level: 'error',
@@ -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,462 @@
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
+ if (remoteLessons.length === 0) {
337
+ return fail('Product API returned no lessons. Is the workshop published on the product site?', {
338
+ warnings,
339
+ });
340
+ }
341
+ const { plans: fileLessonSlotPlans, requiredLessonSlots } = buildFileLessonSlotPlans(files);
342
+ if (remoteLessons.length < requiredLessonSlots) {
343
+ const assignedPairs = fileLessonSlotPlans
344
+ .filter((plan) => plan.lessonSlotIndex < remoteLessons.length)
345
+ .map((plan) => {
346
+ const lesson = remoteLessons[plan.lessonSlotIndex];
347
+ if (!lesson)
348
+ return `${plan.file.relativePath} -> (no lesson)`;
349
+ const lessonUrl = formatProductLessonUrl({
350
+ productHost,
351
+ productSlug,
352
+ lessonSlug: lesson.slug,
353
+ sectionSlug: lesson.sectionSlug,
354
+ });
355
+ return `${plan.file.relativePath} -> ${lessonUrl}`;
356
+ });
357
+ const unassignedLocalFiles = fileLessonSlotPlans
358
+ .filter((plan) => plan.lessonSlotIndex >= remoteLessons.length)
359
+ .map((plan) => `${plan.file.relativePath} (lesson slot ${plan.lessonSlotIndex + 1})`);
360
+ const remoteLessonsInOrder = remoteLessons.map((lesson) => {
361
+ const lessonPath = lesson.sectionSlug
362
+ ? `${lesson.sectionSlug}/${lesson.slug}`
363
+ : lesson.slug;
364
+ const lessonUrl = formatProductLessonUrl({
365
+ productHost,
366
+ productSlug,
367
+ lessonSlug: lesson.slug,
368
+ sectionSlug: lesson.sectionSlug,
369
+ });
370
+ return `${lessonPath} -> ${lessonUrl}`;
371
+ });
372
+ const requiredLocalFilesInOrder = fileLessonSlotPlans.map((plan) => `${plan.file.relativePath} (lesson slot ${plan.lessonSlotIndex + 1})`);
373
+ 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, {
374
+ startAt: assignedPairs.length + 1,
375
+ })}\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.`, {
376
+ warnings,
377
+ });
378
+ }
379
+ const plannedEdits = [];
380
+ const editErrors = [];
381
+ for (const plan of fileLessonSlotPlans) {
382
+ const lesson = remoteLessons[plan.lessonSlotIndex];
383
+ if (!lesson)
384
+ continue;
385
+ const targetUrl = formatProductLessonUrl({
386
+ productHost,
387
+ productSlug,
388
+ lessonSlug: lesson.slug,
389
+ sectionSlug: lesson.sectionSlug,
390
+ });
391
+ let currentContent = '';
392
+ try {
393
+ currentContent = await fs.readFile(plan.file.fullPath, 'utf8');
394
+ }
395
+ catch (error) {
396
+ editErrors.push(`Failed to read "${plan.file.relativePath}": ${getErrorMessage(error)}`);
397
+ continue;
398
+ }
399
+ const result = upsertTitleEpicVideo({
400
+ content: currentContent,
401
+ url: targetUrl,
402
+ });
403
+ if (result.status === 'error') {
404
+ editErrors.push(`${plan.file.relativePath}: ${result.message}`);
405
+ continue;
406
+ }
407
+ plannedEdits.push({
408
+ file: plan.file,
409
+ nextContent: result.nextContent,
410
+ outcome: result.outcome,
411
+ });
412
+ }
413
+ if (editErrors.length > 0) {
414
+ return fail(`Could not update videos for all files:\n- ${editErrors.join('\n- ')}`, {
415
+ warnings,
416
+ });
417
+ }
418
+ if (!dryRun) {
419
+ for (const edit of plannedEdits) {
420
+ if (edit.outcome === 'unchanged')
421
+ continue;
422
+ await fs.writeFile(edit.file.fullPath, edit.nextContent);
423
+ }
424
+ }
425
+ const inserted = plannedEdits.filter((edit) => edit.outcome === 'inserted').length;
426
+ const updated = plannedEdits.filter((edit) => edit.outcome === 'updated').length;
427
+ const unchanged = plannedEdits.filter((edit) => edit.outcome === 'unchanged').length;
428
+ if (remoteLessons.length > requiredLessonSlots) {
429
+ const extras = remoteLessons.slice(requiredLessonSlots).map((lesson) => formatProductLessonUrl({
430
+ productHost,
431
+ productSlug,
432
+ lessonSlug: lesson.slug,
433
+ sectionSlug: lesson.sectionSlug,
434
+ }));
435
+ warnings.push(`Product has ${extras.length} extra lesson(s) beyond mapped lesson slots:\n- ${extras.join('\n- ')}`);
436
+ }
437
+ if (!silent) {
438
+ console.log(chalk.bold.cyan('\n🛠️ Admin: Set videos\n'));
439
+ console.log(chalk.green(`✅ ${dryRun ? 'Planned' : 'Updated'} EpicVideo mappings (inserted: ${inserted}, updated: ${updated}, unchanged: ${unchanged})`));
440
+ if (dryRun) {
441
+ console.log(chalk.yellow('🧪 Dry run enabled: no files were modified.'));
442
+ }
443
+ if (warnings.length > 0) {
444
+ console.log();
445
+ for (const warning of warnings) {
446
+ console.log(chalk.yellow(`⚠️ ${warning}`));
447
+ }
448
+ }
449
+ console.log();
450
+ }
451
+ return {
452
+ success: true,
453
+ message: dryRun
454
+ ? 'Set videos dry run completed successfully'
455
+ : 'Set videos completed successfully',
456
+ inserted,
457
+ updated,
458
+ unchanged,
459
+ warnings,
460
+ dryRun,
461
+ };
462
+ }
@@ -0,0 +1,33 @@
1
+ import { type Dirent } from 'node:fs';
2
+ export type RemoteLesson = {
3
+ slug: string;
4
+ sectionSlug: string | null;
5
+ };
6
+ type NormalizeSlug = (value: string) => string;
7
+ export declare function stripEpicAiSlugSuffix(value: string): string;
8
+ export declare function formatProductLessonUrl({ productHost, productSlug, lessonSlug, sectionSlug, }: {
9
+ productHost: string;
10
+ productSlug: string;
11
+ lessonSlug: string;
12
+ sectionSlug: string | null;
13
+ }): string;
14
+ export declare function isDirectory(targetPath: string): Promise<boolean>;
15
+ export declare function resolveMdxFile(dir: string, baseName: 'README' | 'FINISHED'): Promise<string | null>;
16
+ export declare function collectStepDirectories(entries: Array<Dirent>, exerciseRoot: string): Map<number, {
17
+ problems: Array<string>;
18
+ solutions: Array<string>;
19
+ }>;
20
+ export declare function fetchRemoteWorkshopLessons({ productHost, workshopSlug, normalizeLessonSlug, normalizeSectionSlug, requireNonEmptyLessonSlug, }: {
21
+ productHost: string;
22
+ workshopSlug: string;
23
+ normalizeLessonSlug?: NormalizeSlug;
24
+ normalizeSectionSlug?: NormalizeSlug;
25
+ requireNonEmptyLessonSlug?: boolean;
26
+ }): Promise<{
27
+ status: 'success';
28
+ lessons: Array<RemoteLesson>;
29
+ } | {
30
+ status: 'error';
31
+ message: string;
32
+ }>;
33
+ export {};
@@ -0,0 +1,168 @@
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, lessonSlug, sectionSlug, }) {
11
+ // The product site will typically redirect to a section-specific path when needed.
12
+ return sectionSlug
13
+ ? `https://${productHost}/workshops/${productSlug}/${sectionSlug}/${lessonSlug}`
14
+ : `https://${productHost}/workshops/${productSlug}/${lessonSlug}`;
15
+ }
16
+ export async function isDirectory(targetPath) {
17
+ try {
18
+ return (await fs.stat(targetPath)).isDirectory();
19
+ }
20
+ catch {
21
+ return false;
22
+ }
23
+ }
24
+ export async function resolveMdxFile(dir, baseName) {
25
+ const mdx = path.join(dir, `${baseName}.mdx`);
26
+ if (await pathExists(mdx))
27
+ return mdx;
28
+ return null;
29
+ }
30
+ export function collectStepDirectories(entries, exerciseRoot) {
31
+ const stepDirRegex = /^(?<stepNumber>\d+)\.(?<type>problem|solution)(\..*)?$/;
32
+ const stepsByNumber = new Map();
33
+ for (const entry of entries) {
34
+ if (!entry.isDirectory())
35
+ continue;
36
+ const match = stepDirRegex.exec(entry.name);
37
+ if (!match?.groups)
38
+ continue;
39
+ const stepNumber = Number(match.groups.stepNumber);
40
+ const type = match.groups.type;
41
+ if (!Number.isFinite(stepNumber) || stepNumber <= 0)
42
+ continue;
43
+ const current = stepsByNumber.get(stepNumber) ?? {
44
+ problems: [],
45
+ solutions: [],
46
+ };
47
+ const fullStepDir = path.join(exerciseRoot, entry.name);
48
+ if (type === 'problem')
49
+ current.problems.push(fullStepDir);
50
+ if (type === 'solution')
51
+ current.solutions.push(fullStepDir);
52
+ stepsByNumber.set(stepNumber, current);
53
+ }
54
+ return stepsByNumber;
55
+ }
56
+ export async function fetchRemoteWorkshopLessons({ productHost, workshopSlug, normalizeLessonSlug, normalizeSectionSlug, requireNonEmptyLessonSlug = false, }) {
57
+ const url = `https://${productHost}/api/workshops/${encodeURIComponent(workshopSlug)}`;
58
+ const fetchOnce = async (accessToken) => {
59
+ const timeout = AbortSignal.timeout(15_000);
60
+ const headers = {};
61
+ if (accessToken)
62
+ headers.authorization = `Bearer ${accessToken}`;
63
+ return fetch(url, { headers, signal: timeout });
64
+ };
65
+ let response = null;
66
+ try {
67
+ response = await fetchOnce();
68
+ }
69
+ catch (error) {
70
+ return {
71
+ status: 'error',
72
+ message: `Failed to fetch product workshop data: ${getErrorMessage(error)}`,
73
+ };
74
+ }
75
+ if (response.status === 401 || response.status === 403) {
76
+ const authInfo = await getAuthInfo({ productHost }).catch(() => null);
77
+ const accessToken = authInfo?.tokenSet?.access_token;
78
+ if (accessToken) {
79
+ try {
80
+ response = await fetchOnce(accessToken);
81
+ }
82
+ catch (error) {
83
+ return {
84
+ status: 'error',
85
+ message: `Failed to fetch product workshop data (after auth): ${getErrorMessage(error)}`,
86
+ };
87
+ }
88
+ }
89
+ }
90
+ if (!response.ok) {
91
+ const body = await response.text().catch(() => '');
92
+ const hint = response.status === 401 || response.status === 403
93
+ ? ` (try: npx epicshop auth login ${productHost.replace(/^www\./, '')})`
94
+ : response.status === 404
95
+ ? ` (check epicshop.product.host + epicshop.product.slug)`
96
+ : '';
97
+ return {
98
+ status: 'error',
99
+ message: `Product API request failed: ${response.status} ${response.statusText}${hint}${body ? `\n${body}` : ''}`,
100
+ };
101
+ }
102
+ let data;
103
+ try {
104
+ data = await response.json();
105
+ }
106
+ catch (error) {
107
+ return {
108
+ status: 'error',
109
+ message: `Product API response was not valid JSON: ${getErrorMessage(error)}`,
110
+ };
111
+ }
112
+ const resources = data && typeof data === 'object' && 'resources' in data
113
+ ? data.resources
114
+ : null;
115
+ if (!Array.isArray(resources)) {
116
+ return {
117
+ status: 'error',
118
+ message: `Product API response did not include an array "resources" field`,
119
+ };
120
+ }
121
+ const applyNormalizer = (value, normalizer) => {
122
+ return normalizer ? normalizer(value) : value;
123
+ };
124
+ const lessons = [];
125
+ for (const resource of resources) {
126
+ if (!resource || typeof resource !== 'object')
127
+ continue;
128
+ const item = resource;
129
+ if (item._type === 'lesson') {
130
+ const slug = item.slug;
131
+ if (typeof slug === 'string') {
132
+ const normalizedSlug = applyNormalizer(slug, normalizeLessonSlug);
133
+ if (requireNonEmptyLessonSlug && normalizedSlug.trim().length === 0) {
134
+ continue;
135
+ }
136
+ lessons.push({ slug: normalizedSlug, sectionSlug: null });
137
+ }
138
+ continue;
139
+ }
140
+ if (item._type === 'section') {
141
+ let sectionSlug = null;
142
+ if (typeof item.slug === 'string') {
143
+ const normalizedSectionSlug = applyNormalizer(item.slug, normalizeSectionSlug);
144
+ const trimmedSectionSlug = normalizedSectionSlug.trim();
145
+ if (trimmedSectionSlug.length > 0) {
146
+ sectionSlug = trimmedSectionSlug;
147
+ }
148
+ }
149
+ const sectionLessons = item.lessons;
150
+ if (!Array.isArray(sectionLessons))
151
+ continue;
152
+ for (const lesson of sectionLessons) {
153
+ if (!lesson || typeof lesson !== 'object')
154
+ continue;
155
+ const lessonItem = lesson;
156
+ const slug = lessonItem.slug;
157
+ if (typeof slug === 'string') {
158
+ const normalizedSlug = applyNormalizer(slug, normalizeLessonSlug);
159
+ if (requireNonEmptyLessonSlug && normalizedSlug.trim().length === 0) {
160
+ continue;
161
+ }
162
+ lessons.push({ slug: normalizedSlug, sectionSlug });
163
+ }
164
+ }
165
+ }
166
+ }
167
+ return { status: 'success', lessons };
168
+ }
@@ -1 +1,2 @@
1
1
  export { launchReadiness, type LaunchReadinessOptions, type LaunchReadinessResult, } from "./admin/launch-readiness.js";
2
+ export { setVideos, type SetVideosOptions, type SetVideosResult, } from "./admin/set-videos.js";
@@ -1 +1,2 @@
1
1
  export { launchReadiness, } from "./admin/launch-readiness.js";
2
+ export { setVideos, } from "./admin/set-videos.js";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "epicshop",
3
- "version": "6.84.7",
3
+ "version": "6.85.0",
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.84.7",
108
+ "@epic-web/workshop-utils": "6.85.0",
109
109
  "@inquirer/prompts": "^8.2.0",
110
110
  "@sentry/node": "^10.38.0",
111
111
  "chalk": "^5.6.2",