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