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 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,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 parseEpicWorkshopSlugFromEmbedUrl(urlString) {
18
+ function parseEpicProductSlugFromEmbedUrl(urlString) {
16
19
  const parseSegments = (segments) => {
17
- // Expected: /workshops/<workshopSlug>/...
18
- if (segments[0] !== 'workshops')
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 stepDirRegex = /^(?<stepNumber>\d+)\.(?<type>problem|solution)(\..*)?$/;
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] !== 'workshops') {
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 = parseEpicWorkshopSlugFromEmbedUrl(embedUrl);
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 fetchRemoteWorkshopLessonSlugs({
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 = parseEpicWorkshopSlugFromEmbedUrl(embedUrl);
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
+ }
@@ -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";
@@ -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
- allWorkshops = [workshopFromCwd];
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.84.8",
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.84.8",
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",