@lessonkit/lxpack 1.4.0 → 1.6.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/README.md +49 -7
- package/block-tree.v1.json +40 -0
- package/dist/bridge.cjs +229 -55
- package/dist/bridge.d.cts +59 -11
- package/dist/bridge.d.ts +59 -11
- package/dist/bridge.js +162 -42
- package/dist/chunk-HTZR4CF3.js +94 -0
- package/dist/index.cjs +1527 -209
- package/dist/index.d.cts +184 -5
- package/dist/index.d.ts +184 -5
- package/dist/index.js +1472 -183
- package/dist/telemetry-0fIWoomS.d.cts +17 -0
- package/dist/telemetry-0fIWoomS.d.ts +17 -0
- package/lessonkit-manifest.v1.json +6 -3
- package/lkcourse-format.v1.json +21 -0
- package/package.json +14 -8
- package/dist/chunk-DYQI222N.js +0 -41
- package/dist/telemetry-gCxlwc7I.d.cts +0 -9
- package/dist/telemetry-gCxlwc7I.d.ts +0 -9
package/dist/index.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import {
|
|
2
2
|
telemetryEventToLessonkit
|
|
3
|
-
} from "./chunk-
|
|
3
|
+
} from "./chunk-HTZR4CF3.js";
|
|
4
4
|
|
|
5
5
|
// src/descriptor/normalize.ts
|
|
6
6
|
import { validateId } from "@lessonkit/core";
|
|
@@ -99,10 +99,18 @@ function parseAssessmentDescriptor(raw) {
|
|
|
99
99
|
};
|
|
100
100
|
const kind = raw.kind;
|
|
101
101
|
if (kind === "trueFalse") {
|
|
102
|
+
let answer;
|
|
103
|
+
if (typeof raw.answer === "boolean") {
|
|
104
|
+
answer = raw.answer;
|
|
105
|
+
} else if (raw.answer === "true") {
|
|
106
|
+
answer = true;
|
|
107
|
+
} else if (raw.answer === "false") {
|
|
108
|
+
answer = false;
|
|
109
|
+
}
|
|
102
110
|
return {
|
|
103
111
|
kind: "trueFalse",
|
|
104
112
|
...base,
|
|
105
|
-
answer
|
|
113
|
+
answer
|
|
106
114
|
};
|
|
107
115
|
}
|
|
108
116
|
if (kind === "fillInBlanks") {
|
|
@@ -280,6 +288,98 @@ function isResolvedPathUnderRoot(root, target) {
|
|
|
280
288
|
return !rel.startsWith("..") && !isAbsolute(rel);
|
|
281
289
|
}
|
|
282
290
|
|
|
291
|
+
// src/validateProjectPaths.ts
|
|
292
|
+
import { existsSync as existsSync2, realpathSync as realpathSync2 } from "fs";
|
|
293
|
+
import { isAbsolute as isAbsolute2, resolve as resolve2 } from "path";
|
|
294
|
+
var RESERVED_OUTPUT_SEGMENTS = /* @__PURE__ */ new Set([".git", "node_modules", ".github"]);
|
|
295
|
+
function isReservedOutputPath(value) {
|
|
296
|
+
let normalized = value.replace(/\\/g, "/");
|
|
297
|
+
while (normalized.startsWith("/")) normalized = normalized.slice(1);
|
|
298
|
+
while (normalized.endsWith("/")) normalized = normalized.slice(0, -1);
|
|
299
|
+
const segments = normalized.split("/").filter(Boolean);
|
|
300
|
+
return segments.some((segment) => RESERVED_OUTPUT_SEGMENTS.has(segment));
|
|
301
|
+
}
|
|
302
|
+
function isReservedResolvedOutputPath(projectRoot, resolved) {
|
|
303
|
+
const rootResolved = resolveComparablePath(projectRoot);
|
|
304
|
+
const targetResolved = resolveComparablePath(resolved);
|
|
305
|
+
try {
|
|
306
|
+
const rootReal = existsSync2(rootResolved) ? realpathSync2(rootResolved) : rootResolved;
|
|
307
|
+
const targetReal = existsSync2(targetResolved) ? realpathSync2(targetResolved) : targetResolved;
|
|
308
|
+
const rel = relativePathUnderRoot(rootReal, targetReal);
|
|
309
|
+
return isReservedOutputPath(rel);
|
|
310
|
+
} catch {
|
|
311
|
+
return isReservedOutputPath(resolved);
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
function validatePathField(value, fieldPath, projectRoot, issues, options) {
|
|
315
|
+
if (!isSafeRelativeSpaPath(value)) {
|
|
316
|
+
issues.push({
|
|
317
|
+
path: fieldPath,
|
|
318
|
+
message: "path must be relative without '..' segments or absolute prefixes"
|
|
319
|
+
});
|
|
320
|
+
return;
|
|
321
|
+
}
|
|
322
|
+
if (options?.rejectReserved && isReservedOutputPath(value)) {
|
|
323
|
+
issues.push({
|
|
324
|
+
path: fieldPath,
|
|
325
|
+
message: "path must not target reserved directories (.git, node_modules, .github)"
|
|
326
|
+
});
|
|
327
|
+
return;
|
|
328
|
+
}
|
|
329
|
+
try {
|
|
330
|
+
assertRealPathUnderRoot(projectRoot, resolve2(projectRoot, value));
|
|
331
|
+
} catch {
|
|
332
|
+
issues.push({
|
|
333
|
+
path: fieldPath,
|
|
334
|
+
message: "path must resolve inside the project root"
|
|
335
|
+
});
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
function validateProjectPaths(projectRoot, paths) {
|
|
339
|
+
const issues = [];
|
|
340
|
+
const root = resolve2(projectRoot);
|
|
341
|
+
if (paths.spaDistDir?.trim()) {
|
|
342
|
+
validatePathField(paths.spaDistDir.trim(), "paths.spaDistDir", root, issues, {
|
|
343
|
+
rejectReserved: true
|
|
344
|
+
});
|
|
345
|
+
}
|
|
346
|
+
if (paths.lxpackOutDir?.trim()) {
|
|
347
|
+
validatePathField(paths.lxpackOutDir.trim(), "paths.lxpackOutDir", root, issues, {
|
|
348
|
+
rejectReserved: true
|
|
349
|
+
});
|
|
350
|
+
}
|
|
351
|
+
if (paths.outputBaseDir?.trim()) {
|
|
352
|
+
validatePathField(paths.outputBaseDir.trim(), "paths.outputBaseDir", root, issues, {
|
|
353
|
+
rejectReserved: true
|
|
354
|
+
});
|
|
355
|
+
}
|
|
356
|
+
return issues;
|
|
357
|
+
}
|
|
358
|
+
function resolveSafePackageOutputOverride(projectRoot, override) {
|
|
359
|
+
const root = resolve2(projectRoot);
|
|
360
|
+
const trimmed = override.trim();
|
|
361
|
+
if (!trimmed) {
|
|
362
|
+
throw new Error("output override must be a non-empty path");
|
|
363
|
+
}
|
|
364
|
+
if (isAbsolute2(trimmed)) {
|
|
365
|
+
const resolved2 = resolve2(trimmed);
|
|
366
|
+
assertRealPathUnderRoot(root, resolved2);
|
|
367
|
+
if (isReservedOutputPath(trimmed) || isReservedResolvedOutputPath(root, resolved2)) {
|
|
368
|
+
throw new Error(`unsafe output path: ${override} targets a reserved directory`);
|
|
369
|
+
}
|
|
370
|
+
return resolved2;
|
|
371
|
+
}
|
|
372
|
+
if (!isSafeRelativeSpaPath(trimmed)) {
|
|
373
|
+
throw new Error(`unsafe output path: ${override}`);
|
|
374
|
+
}
|
|
375
|
+
const resolved = resolve2(root, trimmed);
|
|
376
|
+
assertRealPathUnderRoot(root, resolved);
|
|
377
|
+
if (isReservedOutputPath(trimmed) || isReservedResolvedOutputPath(root, resolved)) {
|
|
378
|
+
throw new Error(`unsafe output path: ${override} targets a reserved directory`);
|
|
379
|
+
}
|
|
380
|
+
return resolved;
|
|
381
|
+
}
|
|
382
|
+
|
|
283
383
|
// src/theme.ts
|
|
284
384
|
import { getPresetTheme, themeToCssVariables } from "@lessonkit/themes";
|
|
285
385
|
function themeToLxpackRuntime(input) {
|
|
@@ -344,8 +444,52 @@ var ASSESSMENT_VALIDATORS = {
|
|
|
344
444
|
}
|
|
345
445
|
},
|
|
346
446
|
fillInBlanks: (assessment, path, issues) => {
|
|
347
|
-
if (assessment.kind
|
|
447
|
+
if (assessment.kind !== "fillInBlanks") return;
|
|
448
|
+
if (!assessment.template?.trim()) {
|
|
348
449
|
issues.push({ path: `${path}.template`, message: "template is required for fillInBlanks" });
|
|
450
|
+
return;
|
|
451
|
+
}
|
|
452
|
+
const templateBlankCount = countStarDelimitedBlanks(assessment.template);
|
|
453
|
+
if (templateBlankCount === 0) {
|
|
454
|
+
issues.push({
|
|
455
|
+
path: `${path}.template`,
|
|
456
|
+
message: "template must include at least one blank wrapped in asterisks for fillInBlanks"
|
|
457
|
+
});
|
|
458
|
+
}
|
|
459
|
+
const explicitBlanks = [];
|
|
460
|
+
if (assessment.blanks !== void 0) {
|
|
461
|
+
for (let i = 0; i < assessment.blanks.length; i++) {
|
|
462
|
+
const blank = assessment.blanks[i];
|
|
463
|
+
if (!blank || typeof blank !== "object") {
|
|
464
|
+
issues.push({
|
|
465
|
+
path: `${path}.blanks[${i}]`,
|
|
466
|
+
message: "blank entry must be an object with non-empty id and answer"
|
|
467
|
+
});
|
|
468
|
+
continue;
|
|
469
|
+
}
|
|
470
|
+
const id = blank.id?.trim() ?? "";
|
|
471
|
+
const answer = blank.answer?.trim() ?? "";
|
|
472
|
+
if (!id || !answer) {
|
|
473
|
+
issues.push({
|
|
474
|
+
path: `${path}.blanks[${i}]`,
|
|
475
|
+
message: "blank entry must include non-empty id and answer"
|
|
476
|
+
});
|
|
477
|
+
continue;
|
|
478
|
+
}
|
|
479
|
+
explicitBlanks.push({ id, answer });
|
|
480
|
+
}
|
|
481
|
+
}
|
|
482
|
+
if (assessment.blanks !== void 0 && explicitBlanks.length === 0 && !issues.some((issue) => issue.path?.startsWith(`${path}.blanks`))) {
|
|
483
|
+
issues.push({
|
|
484
|
+
path: `${path}.blanks`,
|
|
485
|
+
message: "blanks must include at least one entry with non-empty id and answer"
|
|
486
|
+
});
|
|
487
|
+
}
|
|
488
|
+
if (explicitBlanks.length > 0 && explicitBlanks.length !== templateBlankCount) {
|
|
489
|
+
issues.push({
|
|
490
|
+
path: `${path}.blanks`,
|
|
491
|
+
message: `blanks length (${explicitBlanks.length}) must match template blank count (${templateBlankCount})`
|
|
492
|
+
});
|
|
349
493
|
}
|
|
350
494
|
},
|
|
351
495
|
findHotspot: (assessment, path, issues) => {
|
|
@@ -483,6 +627,20 @@ function validateCourseDescriptor(input) {
|
|
|
483
627
|
});
|
|
484
628
|
}
|
|
485
629
|
}
|
|
630
|
+
const descriptorSpaDistDir = input.spaDistDir?.trim();
|
|
631
|
+
if (descriptorSpaDistDir) {
|
|
632
|
+
if (!isSafeRelativeSpaPath(descriptorSpaDistDir)) {
|
|
633
|
+
issues.push({
|
|
634
|
+
path: "spaDistDir",
|
|
635
|
+
message: "spaDistDir must be a relative path without '..' segments or absolute prefixes"
|
|
636
|
+
});
|
|
637
|
+
} else if (isReservedOutputPath(descriptorSpaDistDir)) {
|
|
638
|
+
issues.push({
|
|
639
|
+
path: "spaDistDir",
|
|
640
|
+
message: "spaDistDir must not target reserved directories (.git, node_modules, .github)"
|
|
641
|
+
});
|
|
642
|
+
}
|
|
643
|
+
}
|
|
486
644
|
if (layout === "single-spa" && (input.lessons?.length ?? 0) > 1) {
|
|
487
645
|
issues.push({
|
|
488
646
|
path: "lessons",
|
|
@@ -543,27 +701,49 @@ function validateCourseDescriptor(input) {
|
|
|
543
701
|
}
|
|
544
702
|
|
|
545
703
|
// src/assessments.ts
|
|
704
|
+
var DEFAULT_SHELL_PASSING_SCORE = 1;
|
|
705
|
+
function escapeShellText(text) {
|
|
706
|
+
return text.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """).replace(/'/g, "'");
|
|
707
|
+
}
|
|
708
|
+
function decodeShellEntities(text) {
|
|
709
|
+
return text.replace(/&/gi, "&").replace(/</gi, "<").replace(/>/gi, ">").replace(/"/gi, '"').replace(/'/gi, "'").replace(/&#x([0-9a-f]+);/gi, (_, hex) => String.fromCharCode(parseInt(hex, 16))).replace(/&#(\d+);/g, (_, num) => String.fromCharCode(Number(num)));
|
|
710
|
+
}
|
|
711
|
+
function containsUnsafeShellMarkup(text) {
|
|
712
|
+
const decoded = decodeShellEntities(text);
|
|
713
|
+
return /<\/script/i.test(decoded) || /<!--/.test(decoded) || /<[a-zA-Z!/]/.test(decoded);
|
|
714
|
+
}
|
|
715
|
+
function sanitizeShellField(text) {
|
|
716
|
+
if (containsUnsafeShellMarkup(text)) return null;
|
|
717
|
+
return escapeShellText(text);
|
|
718
|
+
}
|
|
546
719
|
function slugChoiceId(text, index) {
|
|
547
720
|
const base = text.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "").slice(0, 32);
|
|
548
721
|
const stem = base.length ? base : "choice";
|
|
549
722
|
return `${stem}-${index + 1}`;
|
|
550
723
|
}
|
|
551
724
|
function mcqToLxpack(assessment) {
|
|
725
|
+
const checkId = sanitizeShellField(assessment.checkId);
|
|
726
|
+
const prompt = sanitizeShellField(assessment.question);
|
|
727
|
+
if (!checkId || !prompt) return null;
|
|
728
|
+
const normalizedAnswer = assessment.answer.trim();
|
|
552
729
|
const choices = assessment.choices.map((text, index) => {
|
|
730
|
+
const sanitizedText = sanitizeShellField(text);
|
|
731
|
+
if (!sanitizedText) return null;
|
|
553
732
|
const id = slugChoiceId(text, index);
|
|
554
733
|
return {
|
|
555
734
|
id,
|
|
556
|
-
text,
|
|
557
|
-
correct: text ===
|
|
735
|
+
text: sanitizedText,
|
|
736
|
+
correct: text.trim() === normalizedAnswer
|
|
558
737
|
};
|
|
559
738
|
});
|
|
739
|
+
if (choices.some((choice) => choice === null)) return null;
|
|
560
740
|
return {
|
|
561
|
-
id:
|
|
562
|
-
passingScore: assessment.passingScore ??
|
|
741
|
+
id: checkId,
|
|
742
|
+
passingScore: assessment.passingScore ?? DEFAULT_SHELL_PASSING_SCORE,
|
|
563
743
|
questions: [
|
|
564
744
|
{
|
|
565
745
|
id: "q1",
|
|
566
|
-
prompt
|
|
746
|
+
prompt,
|
|
567
747
|
choices
|
|
568
748
|
}
|
|
569
749
|
]
|
|
@@ -604,11 +784,14 @@ function extractAssessments(descriptor) {
|
|
|
604
784
|
// src/descriptor/validateInjectableAssessments.ts
|
|
605
785
|
function validateInjectableAssessments(descriptor) {
|
|
606
786
|
const issues = [];
|
|
787
|
+
const spaOnlyKinds = /* @__PURE__ */ new Set(["fillInBlanks", "findHotspot", "findMultipleHotspots"]);
|
|
607
788
|
(descriptor.assessments ?? []).forEach((assessment, index) => {
|
|
608
789
|
if (assessmentDescriptorToLxpack(assessment) === null) {
|
|
790
|
+
const kind = assessment.kind ?? "mcq";
|
|
791
|
+
const hint = spaOnlyKinds.has(kind) ? " \u2014 score in the SPA only; remove from lessonkit.json for LMS targets or use an injectable kind (mcq, trueFalse)" : "";
|
|
609
792
|
issues.push({
|
|
610
793
|
path: `assessments[${index}]`,
|
|
611
|
-
message: `assessment kind "${
|
|
794
|
+
message: `assessment kind "${kind}" (checkId "${assessment.checkId}") is not injected into LMS shell quizzes${hint}`
|
|
612
795
|
});
|
|
613
796
|
}
|
|
614
797
|
});
|
|
@@ -623,22 +806,29 @@ var LMS_SHELL_TARGETS = /* @__PURE__ */ new Set([
|
|
|
623
806
|
"xapi",
|
|
624
807
|
"cmi5"
|
|
625
808
|
]);
|
|
809
|
+
function appendActivityIriIssues(issues, descriptor, target) {
|
|
810
|
+
const hasXapiTracking = Boolean(descriptor.tracking?.xapi?.activityIri?.trim());
|
|
811
|
+
const requiresForTarget = target === "xapi" || target === "cmi5";
|
|
812
|
+
if (!hasXapiTracking && !requiresForTarget) return;
|
|
813
|
+
const activityIri = descriptor.tracking?.xapi?.activityIri?.trim();
|
|
814
|
+
const targetSuffix = target === "xapi" || target === "cmi5" ? ` for ${target} export targets` : " when tracking.xapi is configured";
|
|
815
|
+
if (!activityIri) {
|
|
816
|
+
issues.push({
|
|
817
|
+
path: "tracking.xapi.activityIri",
|
|
818
|
+
message: `tracking.xapi.activityIri is required${targetSuffix}`
|
|
819
|
+
});
|
|
820
|
+
return;
|
|
821
|
+
}
|
|
822
|
+
if (!/^https:\/\/.+/i.test(activityIri)) {
|
|
823
|
+
issues.push({
|
|
824
|
+
path: "tracking.xapi.activityIri",
|
|
825
|
+
message: `tracking.xapi.activityIri must be an HTTPS URL${targetSuffix}`
|
|
826
|
+
});
|
|
827
|
+
}
|
|
828
|
+
}
|
|
626
829
|
function validateDescriptorForExportTarget(descriptor, target) {
|
|
627
830
|
const issues = [];
|
|
628
|
-
|
|
629
|
-
const activityIri = descriptor.tracking?.xapi?.activityIri?.trim();
|
|
630
|
-
if (!activityIri) {
|
|
631
|
-
issues.push({
|
|
632
|
-
path: "tracking.xapi.activityIri",
|
|
633
|
-
message: "tracking.xapi.activityIri is required for xapi and cmi5 export targets"
|
|
634
|
-
});
|
|
635
|
-
} else if (!/^https:\/\/.+/i.test(activityIri)) {
|
|
636
|
-
issues.push({
|
|
637
|
-
path: "tracking.xapi.activityIri",
|
|
638
|
-
message: "tracking.xapi.activityIri must be an HTTPS URL for xapi and cmi5 export targets"
|
|
639
|
-
});
|
|
640
|
-
}
|
|
641
|
-
}
|
|
831
|
+
appendActivityIriIssues(issues, descriptor, target);
|
|
642
832
|
if (LMS_SHELL_TARGETS.has(target)) {
|
|
643
833
|
issues.push(...validateInjectableAssessments(descriptor).map((issue) => ({
|
|
644
834
|
...issue,
|
|
@@ -672,19 +862,53 @@ function validateDescriptorForTarget(input, target) {
|
|
|
672
862
|
}
|
|
673
863
|
|
|
674
864
|
// src/validateReactParity.ts
|
|
675
|
-
import { readFileSync, existsSync as
|
|
865
|
+
import { readFileSync, existsSync as existsSync3, readdirSync, lstatSync } from "fs";
|
|
676
866
|
import { join as join2, relative as relative2 } from "path";
|
|
677
867
|
var SCANNABLE_EXTENSIONS = [".tsx", ".ts", ".jsx", ".js"];
|
|
678
|
-
function collectSourceUnderSrc(projectRoot) {
|
|
868
|
+
function collectSourceUnderSrc(projectRoot, issues) {
|
|
679
869
|
const srcDir = join2(projectRoot, "src");
|
|
680
|
-
if (!
|
|
870
|
+
if (!existsSync3(srcDir)) return [];
|
|
681
871
|
const results = [];
|
|
682
872
|
const walk = (dir) => {
|
|
683
873
|
for (const entry of readdirSync(dir)) {
|
|
684
874
|
const abs = join2(dir, entry);
|
|
685
|
-
|
|
875
|
+
let stat2;
|
|
876
|
+
try {
|
|
877
|
+
stat2 = lstatSync(abs);
|
|
878
|
+
} catch {
|
|
879
|
+
continue;
|
|
880
|
+
}
|
|
881
|
+
if (stat2.isSymbolicLink()) {
|
|
882
|
+
issues.push({
|
|
883
|
+
path: relative2(projectRoot, abs),
|
|
884
|
+
message: `Source tree contains symlink (rejected for parity scan): ${relative2(projectRoot, abs)}`,
|
|
885
|
+
severity: "error"
|
|
886
|
+
});
|
|
887
|
+
continue;
|
|
888
|
+
}
|
|
889
|
+
if (stat2.isDirectory()) {
|
|
890
|
+
try {
|
|
891
|
+
assertRealPathUnderRoot(projectRoot, abs);
|
|
892
|
+
} catch {
|
|
893
|
+
issues.push({
|
|
894
|
+
path: relative2(projectRoot, abs),
|
|
895
|
+
message: `Source directory escapes project root: ${relative2(projectRoot, abs)}`,
|
|
896
|
+
severity: "error"
|
|
897
|
+
});
|
|
898
|
+
continue;
|
|
899
|
+
}
|
|
686
900
|
walk(abs);
|
|
687
901
|
} else if (SCANNABLE_EXTENSIONS.some((ext) => entry.endsWith(ext))) {
|
|
902
|
+
try {
|
|
903
|
+
assertRealPathUnderRoot(projectRoot, abs);
|
|
904
|
+
} catch {
|
|
905
|
+
issues.push({
|
|
906
|
+
path: relative2(projectRoot, abs),
|
|
907
|
+
message: `Source file escapes project root: ${relative2(projectRoot, abs)}`,
|
|
908
|
+
severity: "error"
|
|
909
|
+
});
|
|
910
|
+
continue;
|
|
911
|
+
}
|
|
688
912
|
results.push(relative2(projectRoot, abs));
|
|
689
913
|
}
|
|
690
914
|
}
|
|
@@ -692,20 +916,69 @@ function collectSourceUnderSrc(projectRoot) {
|
|
|
692
916
|
walk(srcDir);
|
|
693
917
|
return results;
|
|
694
918
|
}
|
|
695
|
-
function readAppSources(projectRoot, appSources) {
|
|
696
|
-
return appSources.map((rel) =>
|
|
919
|
+
function readAppSources(projectRoot, appSources, issues, customSourcesProvided) {
|
|
920
|
+
return appSources.map((rel) => {
|
|
921
|
+
if (!isSafeRelativeSpaPath(rel)) {
|
|
922
|
+
if (customSourcesProvided) {
|
|
923
|
+
issues.push({
|
|
924
|
+
path: rel,
|
|
925
|
+
message: `Unsafe appSources path skipped: ${rel}`,
|
|
926
|
+
severity: "warning"
|
|
927
|
+
});
|
|
928
|
+
}
|
|
929
|
+
return null;
|
|
930
|
+
}
|
|
931
|
+
const abs = join2(projectRoot, rel);
|
|
932
|
+
try {
|
|
933
|
+
assertRealPathUnderRoot(projectRoot, abs);
|
|
934
|
+
if (existsSync3(abs) && lstatSync(abs).isSymbolicLink()) {
|
|
935
|
+
issues.push({
|
|
936
|
+
path: rel,
|
|
937
|
+
message: `appSources path is a symlink: ${rel}`,
|
|
938
|
+
severity: "error"
|
|
939
|
+
});
|
|
940
|
+
return null;
|
|
941
|
+
}
|
|
942
|
+
} catch {
|
|
943
|
+
issues.push({
|
|
944
|
+
path: rel,
|
|
945
|
+
message: `appSources path escapes project root: ${rel}`,
|
|
946
|
+
severity: "error"
|
|
947
|
+
});
|
|
948
|
+
return null;
|
|
949
|
+
}
|
|
950
|
+
if (!existsSync3(abs)) return null;
|
|
951
|
+
return readFileSync(abs, "utf8");
|
|
952
|
+
}).filter((content) => content != null).join("\n");
|
|
697
953
|
}
|
|
698
954
|
function stripComments(source) {
|
|
699
955
|
return source.replace(/\/\*[\s\S]*?\*\//g, " ").replace(/\/\/[^\n]*/g, " ");
|
|
700
956
|
}
|
|
701
|
-
function
|
|
702
|
-
return [
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
957
|
+
function maskUnrelatedStringLiterals(source) {
|
|
958
|
+
return source.replace(/(["'`])(?:\\.|(?!\1).)*\1/g, (match, _quote, offset, full) => {
|
|
959
|
+
const before = full.slice(Math.max(0, offset - 24), offset);
|
|
960
|
+
if (/\b(?:courseId|checkId|lessonId)\s*=\s*$/.test(before)) {
|
|
961
|
+
return match;
|
|
962
|
+
}
|
|
963
|
+
return '""';
|
|
964
|
+
});
|
|
965
|
+
}
|
|
966
|
+
function idPropPresent(source, prop, id) {
|
|
967
|
+
const stripped = stripComments(source);
|
|
968
|
+
const masked = maskUnrelatedStringLiterals(stripped);
|
|
969
|
+
return jsxPropRegex(prop, id).test(masked);
|
|
970
|
+
}
|
|
971
|
+
function escapeRegExp(value) {
|
|
972
|
+
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
973
|
+
}
|
|
974
|
+
function jsxPropRegex(prop, id) {
|
|
975
|
+
const escapedId = escapeRegExp(id);
|
|
976
|
+
return new RegExp(
|
|
977
|
+
`(?<![A-Za-z0-9_$])${prop}\\s*=\\s*(?:"${escapedId}"|'${escapedId}'|\\{\\s*["'\`]${escapedId}["'\`]\\s*\\}|\\{\\s*\`${escapedId}\`\\s*\\})`
|
|
978
|
+
);
|
|
979
|
+
}
|
|
980
|
+
function maskStringLiterals(source) {
|
|
981
|
+
return source.replace(/(["'`])(?:\\.|(?!\1).)*\1/g, '""');
|
|
709
982
|
}
|
|
710
983
|
function extractStringConstants(source) {
|
|
711
984
|
const stripped = stripComments(source);
|
|
@@ -716,7 +989,9 @@ function extractStringConstants(source) {
|
|
|
716
989
|
}
|
|
717
990
|
return map;
|
|
718
991
|
}
|
|
719
|
-
function idUsedViaConstant(
|
|
992
|
+
function idUsedViaConstant(source, prop, id, constants) {
|
|
993
|
+
const stripped = stripComments(source);
|
|
994
|
+
const masked = maskStringLiterals(stripped);
|
|
720
995
|
for (const [name, value] of constants) {
|
|
721
996
|
if (value !== id) continue;
|
|
722
997
|
const jsxPatterns = [
|
|
@@ -725,40 +1000,72 @@ function idUsedViaConstant(stripped, prop, id, constants) {
|
|
|
725
1000
|
`${prop}={${name} }`,
|
|
726
1001
|
`${prop}={ ${name}}`
|
|
727
1002
|
];
|
|
728
|
-
if (jsxPatterns.some((p) =>
|
|
729
|
-
const objPatterns = [`${prop}: ${name}`, `${prop}:${name}`];
|
|
730
|
-
if (objPatterns.some((p) => stripped.includes(p))) return true;
|
|
1003
|
+
if (jsxPatterns.some((p) => masked.includes(p))) return true;
|
|
731
1004
|
}
|
|
732
1005
|
return false;
|
|
733
1006
|
}
|
|
734
|
-
function
|
|
1007
|
+
function lessonIdInDataLiteral(source, lessonId) {
|
|
735
1008
|
const stripped = stripComments(source);
|
|
736
|
-
|
|
737
|
-
return
|
|
1009
|
+
const escaped = escapeRegExp(lessonId);
|
|
1010
|
+
return new RegExp(`\\bid\\s*:\\s*["'\`]${escaped}["'\`]`).test(stripped);
|
|
738
1011
|
}
|
|
739
|
-
function
|
|
1012
|
+
function lessonIdPresent(source, lessonId) {
|
|
1013
|
+
if (idPropPresent(source, "lessonId", lessonId)) return true;
|
|
1014
|
+
if (idUsedViaConstant(source, "lessonId", lessonId, extractStringConstants(source))) return true;
|
|
1015
|
+
return lessonIdInDataLiteral(source, lessonId);
|
|
1016
|
+
}
|
|
1017
|
+
function courseConfigCourseIdPresent(source, courseId) {
|
|
1018
|
+
const stripped = stripComments(source);
|
|
1019
|
+
const escaped = escapeRegExp(courseId);
|
|
1020
|
+
const literalPattern = new RegExp(
|
|
1021
|
+
`(?<![A-Za-z0-9_$])courseId\\s*:\\s*(?:"${escaped}"|'${escaped}')`
|
|
1022
|
+
);
|
|
1023
|
+
if (literalPattern.test(stripped)) return true;
|
|
1024
|
+
return idUsedViaConstant(source, "courseId", courseId, extractStringConstants(source));
|
|
1025
|
+
}
|
|
1026
|
+
function courseMetaCourseIdPresent(source, courseId) {
|
|
1027
|
+
const constants = extractStringConstants(source);
|
|
740
1028
|
const stripped = stripComments(source);
|
|
741
|
-
|
|
742
|
-
|
|
1029
|
+
for (const [name, value] of constants) {
|
|
1030
|
+
if (value !== courseId) continue;
|
|
1031
|
+
if (!new RegExp(`\\bcourseId\\s*:\\s*${name}\\b`).test(stripped)) continue;
|
|
1032
|
+
if (/\blessons\s*:\s*\S/.test(stripped)) return true;
|
|
1033
|
+
}
|
|
1034
|
+
return false;
|
|
1035
|
+
}
|
|
1036
|
+
function courseIdPresent(source, courseId) {
|
|
1037
|
+
if (idPropPresent(source, "courseId", courseId)) return true;
|
|
1038
|
+
if (idUsedViaConstant(source, "courseId", courseId, extractStringConstants(source))) return true;
|
|
1039
|
+
if (courseMetaCourseIdPresent(source, courseId)) return true;
|
|
1040
|
+
return courseConfigCourseIdPresent(source, courseId);
|
|
1041
|
+
}
|
|
1042
|
+
function checkIdPresent(source, checkId) {
|
|
1043
|
+
if (idPropPresent(source, "checkId", checkId)) return true;
|
|
1044
|
+
return idUsedViaConstant(source, "checkId", checkId, extractStringConstants(source));
|
|
743
1045
|
}
|
|
744
1046
|
var ID_SYNC_DOC = "https://lessonkit.readthedocs.io/en/latest/guides/react-developers/quickstart.html#keep-react-ids-in-sync-with-lessonkitjson";
|
|
745
1047
|
function parityHint(message) {
|
|
746
1048
|
return `${message} See ${ID_SYNC_DOC}`;
|
|
747
1049
|
}
|
|
748
1050
|
function validateReactManifestParity(opts) {
|
|
749
|
-
const
|
|
750
|
-
const
|
|
1051
|
+
const issues = [];
|
|
1052
|
+
const customSourcesProvided = opts.appSources !== void 0;
|
|
1053
|
+
const appSources = opts.appSources ?? collectSourceUnderSrc(opts.projectRoot, issues);
|
|
1054
|
+
const source = readAppSources(
|
|
1055
|
+
opts.projectRoot,
|
|
1056
|
+
appSources,
|
|
1057
|
+
issues,
|
|
1058
|
+
customSourcesProvided
|
|
1059
|
+
);
|
|
751
1060
|
const hasDescriptorIds = Boolean(opts.descriptor.courseId) || (opts.descriptor.assessments?.length ?? 0) > 0;
|
|
752
1061
|
if (!source.trim()) {
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
];
|
|
1062
|
+
issues.push({
|
|
1063
|
+
path: appSources.length > 0 ? appSources.join(", ") : "src/",
|
|
1064
|
+
message: hasDescriptorIds ? "React app source required for ID parity check when descriptor defines courseId or assessments" : "React app source not found for ID parity check",
|
|
1065
|
+
severity: hasDescriptorIds ? "error" : "warning"
|
|
1066
|
+
});
|
|
1067
|
+
return issues;
|
|
760
1068
|
}
|
|
761
|
-
const issues = [];
|
|
762
1069
|
const courseId = opts.descriptor.courseId;
|
|
763
1070
|
if (!courseIdPresent(source, courseId)) {
|
|
764
1071
|
issues.push({
|
|
@@ -769,6 +1076,19 @@ function validateReactManifestParity(opts) {
|
|
|
769
1076
|
severity: "error"
|
|
770
1077
|
});
|
|
771
1078
|
}
|
|
1079
|
+
for (const lesson of opts.descriptor.lessons ?? []) {
|
|
1080
|
+
const lessonId = lesson.id;
|
|
1081
|
+
if (!lessonId) continue;
|
|
1082
|
+
if (!lessonIdPresent(source, lessonId)) {
|
|
1083
|
+
issues.push({
|
|
1084
|
+
path: `lessons.id:${lessonId}`,
|
|
1085
|
+
message: parityHint(
|
|
1086
|
+
`React app source missing lessonId="${lessonId}" declared in lessonkit.json.`
|
|
1087
|
+
),
|
|
1088
|
+
severity: "error"
|
|
1089
|
+
});
|
|
1090
|
+
}
|
|
1091
|
+
}
|
|
772
1092
|
for (const assessment of opts.descriptor.assessments ?? []) {
|
|
773
1093
|
const checkId = assessment.checkId;
|
|
774
1094
|
if (!checkId) continue;
|
|
@@ -785,58 +1105,6 @@ function validateReactManifestParity(opts) {
|
|
|
785
1105
|
return issues;
|
|
786
1106
|
}
|
|
787
1107
|
|
|
788
|
-
// src/validateProjectPaths.ts
|
|
789
|
-
import { isAbsolute as isAbsolute2, resolve as resolve2 } from "path";
|
|
790
|
-
function validatePathField(value, fieldPath, projectRoot, issues) {
|
|
791
|
-
if (!isSafeRelativeSpaPath(value)) {
|
|
792
|
-
issues.push({
|
|
793
|
-
path: fieldPath,
|
|
794
|
-
message: "path must be relative without '..' segments or absolute prefixes"
|
|
795
|
-
});
|
|
796
|
-
return;
|
|
797
|
-
}
|
|
798
|
-
try {
|
|
799
|
-
assertRealPathUnderRoot(projectRoot, resolve2(projectRoot, value));
|
|
800
|
-
} catch {
|
|
801
|
-
issues.push({
|
|
802
|
-
path: fieldPath,
|
|
803
|
-
message: "path must resolve inside the project root"
|
|
804
|
-
});
|
|
805
|
-
}
|
|
806
|
-
}
|
|
807
|
-
function validateProjectPaths(projectRoot, paths) {
|
|
808
|
-
const issues = [];
|
|
809
|
-
const root = resolve2(projectRoot);
|
|
810
|
-
if (paths.spaDistDir?.trim()) {
|
|
811
|
-
validatePathField(paths.spaDistDir.trim(), "paths.spaDistDir", root, issues);
|
|
812
|
-
}
|
|
813
|
-
if (paths.lxpackOutDir?.trim()) {
|
|
814
|
-
validatePathField(paths.lxpackOutDir.trim(), "paths.lxpackOutDir", root, issues);
|
|
815
|
-
}
|
|
816
|
-
if (paths.outputBaseDir?.trim()) {
|
|
817
|
-
validatePathField(paths.outputBaseDir.trim(), "paths.outputBaseDir", root, issues);
|
|
818
|
-
}
|
|
819
|
-
return issues;
|
|
820
|
-
}
|
|
821
|
-
function resolveSafePackageOutputOverride(projectRoot, override) {
|
|
822
|
-
const root = resolve2(projectRoot);
|
|
823
|
-
const trimmed = override.trim();
|
|
824
|
-
if (!trimmed) {
|
|
825
|
-
throw new Error("output override must be a non-empty path");
|
|
826
|
-
}
|
|
827
|
-
if (isAbsolute2(trimmed)) {
|
|
828
|
-
const resolved2 = resolve2(trimmed);
|
|
829
|
-
assertRealPathUnderRoot(root, resolved2);
|
|
830
|
-
return resolved2;
|
|
831
|
-
}
|
|
832
|
-
if (!isSafeRelativeSpaPath(trimmed)) {
|
|
833
|
-
throw new Error(`unsafe output path: ${override}`);
|
|
834
|
-
}
|
|
835
|
-
const resolved = resolve2(root, trimmed);
|
|
836
|
-
assertRealPathUnderRoot(root, resolved);
|
|
837
|
-
return resolved;
|
|
838
|
-
}
|
|
839
|
-
|
|
840
1108
|
// src/mapIds.ts
|
|
841
1109
|
import { assertValidId } from "@lessonkit/core";
|
|
842
1110
|
function mapLessonkitIds(descriptor) {
|
|
@@ -968,7 +1236,7 @@ async function resolveSpaDirs(options) {
|
|
|
968
1236
|
|
|
969
1237
|
// src/spaDistValidation.ts
|
|
970
1238
|
import { lstat, readdir } from "fs/promises";
|
|
971
|
-
import { realpathSync as
|
|
1239
|
+
import { realpathSync as realpathSync3 } from "fs";
|
|
972
1240
|
import { join as join4 } from "path";
|
|
973
1241
|
async function assertSpaDistContentsSafe(spaDirs, projectRoot) {
|
|
974
1242
|
for (const [label, dir] of Object.entries(spaDirs)) {
|
|
@@ -979,7 +1247,7 @@ async function assertSpaDistContentsSafe(spaDirs, projectRoot) {
|
|
|
979
1247
|
}
|
|
980
1248
|
let rootReal;
|
|
981
1249
|
try {
|
|
982
|
-
rootReal =
|
|
1250
|
+
rootReal = realpathSync3(dirResolved);
|
|
983
1251
|
} catch {
|
|
984
1252
|
throw new Error(`spa dist for "${label}" is not readable: ${dir}`);
|
|
985
1253
|
}
|
|
@@ -1008,9 +1276,12 @@ async function walkDistDir(rootReal, current, label) {
|
|
|
1008
1276
|
}
|
|
1009
1277
|
let entryReal;
|
|
1010
1278
|
try {
|
|
1011
|
-
entryReal =
|
|
1012
|
-
} catch {
|
|
1013
|
-
|
|
1279
|
+
entryReal = realpathSync3(entryPath);
|
|
1280
|
+
} catch (err) {
|
|
1281
|
+
throw new Error(
|
|
1282
|
+
`spa dist for "${label}" could not resolve path: ${entryPath}`,
|
|
1283
|
+
{ cause: err }
|
|
1284
|
+
);
|
|
1014
1285
|
}
|
|
1015
1286
|
assertResolvedPathUnderRoot(rootReal, entryReal);
|
|
1016
1287
|
if (stat2.isDirectory()) {
|
|
@@ -1030,12 +1301,12 @@ async function writeLxpackProject(options) {
|
|
|
1030
1301
|
const descriptor = validation.descriptor;
|
|
1031
1302
|
const injectableIssues = validateInjectableAssessments(descriptor);
|
|
1032
1303
|
if (injectableIssues.length > 0) {
|
|
1033
|
-
throw new Error(
|
|
1304
|
+
throw new Error(
|
|
1305
|
+
injectableIssues.map((i) => `${i.path ?? "assessments"}: ${i.message}`).join("; ")
|
|
1306
|
+
);
|
|
1034
1307
|
}
|
|
1035
1308
|
const outDir = resolve4(options.outDir);
|
|
1036
|
-
|
|
1037
|
-
assertRealPathUnderRoot(resolve4(options.projectRoot), outDir);
|
|
1038
|
-
}
|
|
1309
|
+
assertRealPathUnderRoot(resolve4(options.projectRoot), outDir);
|
|
1039
1310
|
const spaDirs = await resolveSpaDirs({ ...options, descriptor });
|
|
1040
1311
|
await assertSpaDistContentsSafe(spaDirs, options.projectRoot);
|
|
1041
1312
|
const interchange = descriptorToInterchange(descriptor);
|
|
@@ -1098,6 +1369,19 @@ function validatePackageInputs(options) {
|
|
|
1098
1369
|
]
|
|
1099
1370
|
};
|
|
1100
1371
|
}
|
|
1372
|
+
if (isReservedOutputPath(outDir) || isReservedResolvedOutputPath(projectRoot, outDir)) {
|
|
1373
|
+
return {
|
|
1374
|
+
ok: false,
|
|
1375
|
+
courseDir: outDir,
|
|
1376
|
+
target,
|
|
1377
|
+
issues: [
|
|
1378
|
+
{
|
|
1379
|
+
path: "outDir",
|
|
1380
|
+
message: "outDir must not target reserved directories (.git, node_modules, .github)"
|
|
1381
|
+
}
|
|
1382
|
+
]
|
|
1383
|
+
};
|
|
1384
|
+
}
|
|
1101
1385
|
if (outputBaseDir && !isSafeRelativeSpaPath(outputBaseDir)) {
|
|
1102
1386
|
return {
|
|
1103
1387
|
ok: false,
|
|
@@ -1155,6 +1439,19 @@ function validatePackageInputs(options) {
|
|
|
1155
1439
|
]
|
|
1156
1440
|
};
|
|
1157
1441
|
}
|
|
1442
|
+
if (isReservedOutputPath(outputBaseDir) || isReservedResolvedOutputPath(projectRoot, resolvedOutputBase)) {
|
|
1443
|
+
return {
|
|
1444
|
+
ok: false,
|
|
1445
|
+
courseDir: outDir,
|
|
1446
|
+
target,
|
|
1447
|
+
issues: [
|
|
1448
|
+
{
|
|
1449
|
+
path: "outputBaseDir",
|
|
1450
|
+
message: "outputBaseDir must not target reserved directories (.git, node_modules, .github)"
|
|
1451
|
+
}
|
|
1452
|
+
]
|
|
1453
|
+
};
|
|
1454
|
+
}
|
|
1158
1455
|
}
|
|
1159
1456
|
if (output) {
|
|
1160
1457
|
const resolvedOutput = isAbsolute3(output) ? resolve5(output) : resolve5(projectRoot, output);
|
|
@@ -1176,22 +1473,51 @@ function validatePackageInputs(options) {
|
|
|
1176
1473
|
]
|
|
1177
1474
|
};
|
|
1178
1475
|
}
|
|
1179
|
-
|
|
1180
|
-
|
|
1181
|
-
|
|
1182
|
-
|
|
1183
|
-
|
|
1184
|
-
|
|
1185
|
-
|
|
1186
|
-
|
|
1187
|
-
|
|
1188
|
-
|
|
1189
|
-
|
|
1190
|
-
|
|
1191
|
-
|
|
1192
|
-
}
|
|
1193
|
-
|
|
1194
|
-
|
|
1476
|
+
const outputRel = isAbsolute3(output) ? output : output;
|
|
1477
|
+
if (isReservedOutputPath(outputRel) || isReservedResolvedOutputPath(projectRoot, resolvedOutput)) {
|
|
1478
|
+
return {
|
|
1479
|
+
ok: false,
|
|
1480
|
+
courseDir: outDir,
|
|
1481
|
+
target,
|
|
1482
|
+
issues: [
|
|
1483
|
+
{
|
|
1484
|
+
path: "output",
|
|
1485
|
+
message: "output must not target reserved directories (.git, node_modules, .github)"
|
|
1486
|
+
}
|
|
1487
|
+
]
|
|
1488
|
+
};
|
|
1489
|
+
}
|
|
1490
|
+
try {
|
|
1491
|
+
relativePathUnderRoot(outDir, resolvedOutput);
|
|
1492
|
+
} catch {
|
|
1493
|
+
return {
|
|
1494
|
+
ok: false,
|
|
1495
|
+
courseDir: outDir,
|
|
1496
|
+
target,
|
|
1497
|
+
issues: [
|
|
1498
|
+
{
|
|
1499
|
+
path: "output",
|
|
1500
|
+
message: "output must resolve inside outDir"
|
|
1501
|
+
}
|
|
1502
|
+
]
|
|
1503
|
+
};
|
|
1504
|
+
}
|
|
1505
|
+
}
|
|
1506
|
+
return { ok: true, outDir, projectRoot };
|
|
1507
|
+
}
|
|
1508
|
+
function validateArtifactInStaging(stagingRoot, artifactPath, field) {
|
|
1509
|
+
if (!artifactPath) return null;
|
|
1510
|
+
const resolved = resolveComparablePath(artifactPath);
|
|
1511
|
+
if (!isResolvedPathUnderRoot(stagingRoot, resolved)) {
|
|
1512
|
+
return {
|
|
1513
|
+
path: field,
|
|
1514
|
+
message: `${field} is outside the staging directory: ${artifactPath}`
|
|
1515
|
+
};
|
|
1516
|
+
}
|
|
1517
|
+
return null;
|
|
1518
|
+
}
|
|
1519
|
+
function remapArtifactPaths(stagingRoot, outDir, artifactPath) {
|
|
1520
|
+
if (!artifactPath) return void 0;
|
|
1195
1521
|
const resolved = resolveComparablePath(artifactPath);
|
|
1196
1522
|
if (!isResolvedPathUnderRoot(stagingRoot, resolved)) {
|
|
1197
1523
|
throw new Error(`${artifactPath} is outside the staging directory`);
|
|
@@ -1234,33 +1560,53 @@ function promoteLockPath(outDir) {
|
|
|
1234
1560
|
const hash = createHash("sha256").update(resolve6(outDir)).digest("hex").slice(0, 16);
|
|
1235
1561
|
return join7(parent, `.lk-promote-lock-${hash}`);
|
|
1236
1562
|
}
|
|
1237
|
-
var
|
|
1563
|
+
var STALE_ARTIFACT_TTL_MS = 5 * 60 * 1e3;
|
|
1564
|
+
var MAX_LOCK_AGE_MS = 30 * 60 * 1e3;
|
|
1565
|
+
var LOCK_TOKEN_RE = /^(\d+)\n([0-9a-f-]{36})(?:\n(\d+))?\n?$/i;
|
|
1238
1566
|
async function isStalePromoteLock(lockPath) {
|
|
1239
1567
|
try {
|
|
1568
|
+
const stat2 = await fsp.stat(lockPath);
|
|
1240
1569
|
const content = await fsp.readFile(lockPath, "utf8");
|
|
1241
|
-
const
|
|
1242
|
-
|
|
1243
|
-
|
|
1244
|
-
|
|
1245
|
-
|
|
1246
|
-
|
|
1247
|
-
return true;
|
|
1570
|
+
const match = content.match(LOCK_TOKEN_RE);
|
|
1571
|
+
let lockAgeMs = Date.now() - stat2.mtimeMs;
|
|
1572
|
+
if (match?.[3]) {
|
|
1573
|
+
const startedAt = Number.parseInt(match[3], 10);
|
|
1574
|
+
if (Number.isFinite(startedAt) && startedAt > 0) {
|
|
1575
|
+
lockAgeMs = Date.now() - startedAt;
|
|
1248
1576
|
}
|
|
1249
1577
|
}
|
|
1250
|
-
|
|
1251
|
-
|
|
1578
|
+
if (lockAgeMs > MAX_LOCK_AGE_MS) {
|
|
1579
|
+
return true;
|
|
1580
|
+
}
|
|
1581
|
+
if (match) {
|
|
1582
|
+
const pid = Number.parseInt(match[1], 10);
|
|
1583
|
+
if (Number.isFinite(pid) && pid > 0) {
|
|
1584
|
+
try {
|
|
1585
|
+
process.kill(pid, 0);
|
|
1586
|
+
return false;
|
|
1587
|
+
} catch {
|
|
1588
|
+
return true;
|
|
1589
|
+
}
|
|
1590
|
+
}
|
|
1591
|
+
}
|
|
1592
|
+
return lockAgeMs > STALE_ARTIFACT_TTL_MS;
|
|
1252
1593
|
} catch {
|
|
1253
1594
|
return true;
|
|
1254
1595
|
}
|
|
1255
1596
|
}
|
|
1597
|
+
var PROMOTE_LOCK_TIMEOUT_MS = 15e3;
|
|
1256
1598
|
async function withPromoteLock(outDir, fn) {
|
|
1257
1599
|
const lockPath = promoteLockPath(outDir);
|
|
1258
1600
|
await fsp.mkdir(dirname(outDir), { recursive: true });
|
|
1259
1601
|
let lockHandle;
|
|
1260
|
-
|
|
1602
|
+
const maxAttempts = 400;
|
|
1603
|
+
const started = Date.now();
|
|
1604
|
+
for (let attempt = 0; attempt < maxAttempts; attempt++) {
|
|
1261
1605
|
try {
|
|
1262
1606
|
lockHandle = await fsp.open(lockPath, "wx");
|
|
1263
1607
|
await lockHandle.writeFile(`${process.pid}
|
|
1608
|
+
${randomUUID()}
|
|
1609
|
+
${Date.now()}
|
|
1264
1610
|
`, "utf8");
|
|
1265
1611
|
break;
|
|
1266
1612
|
} catch (err) {
|
|
@@ -1273,7 +1619,9 @@ async function withPromoteLock(outDir, fn) {
|
|
|
1273
1619
|
);
|
|
1274
1620
|
continue;
|
|
1275
1621
|
}
|
|
1276
|
-
|
|
1622
|
+
if (Date.now() - started >= PROMOTE_LOCK_TIMEOUT_MS) break;
|
|
1623
|
+
const delayMs = Math.min(25 * 2 ** Math.floor(attempt / 20), 250);
|
|
1624
|
+
await new Promise((resolveWait) => setTimeout(resolveWait, delayMs));
|
|
1277
1625
|
}
|
|
1278
1626
|
}
|
|
1279
1627
|
if (!lockHandle) {
|
|
@@ -1292,22 +1640,77 @@ async function withPromoteLock(outDir, fn) {
|
|
|
1292
1640
|
);
|
|
1293
1641
|
}
|
|
1294
1642
|
}
|
|
1295
|
-
async function
|
|
1643
|
+
async function removeStaleLegacyPromoteArtifacts(outDir) {
|
|
1296
1644
|
const legacyTmp = `${outDir}.tmp-promote`;
|
|
1297
1645
|
const legacyBak = `${outDir}.bak`;
|
|
1298
|
-
const
|
|
1299
|
-
|
|
1300
|
-
|
|
1301
|
-
|
|
1646
|
+
const blocked = [];
|
|
1647
|
+
for (const legacyPath of [legacyTmp, legacyBak]) {
|
|
1648
|
+
if (!await pathExists(legacyPath)) continue;
|
|
1649
|
+
try {
|
|
1650
|
+
const stat2 = await fsp.stat(legacyPath);
|
|
1651
|
+
if (Date.now() - stat2.mtimeMs > STALE_ARTIFACT_TTL_MS) {
|
|
1652
|
+
await fsp.rm(legacyPath, { recursive: true, force: true }).catch(
|
|
1653
|
+
/* v8 ignore next */
|
|
1654
|
+
() => void 0
|
|
1655
|
+
);
|
|
1656
|
+
continue;
|
|
1657
|
+
}
|
|
1658
|
+
} catch {
|
|
1659
|
+
}
|
|
1660
|
+
blocked.push(legacyPath);
|
|
1661
|
+
}
|
|
1662
|
+
if (blocked.length) {
|
|
1663
|
+
const rmHint = blocked.map((p) => `rm -rf ${JSON.stringify(p)}`).join("; ");
|
|
1302
1664
|
throw new Error(
|
|
1303
|
-
`[lessonkit/lxpack] cannot promote: remove stale packaging artifacts from a previous failed run: ${
|
|
1665
|
+
`[lessonkit/lxpack] cannot promote: remove stale packaging artifacts from a previous failed run: ${blocked.join(", ")}. Try: ${rmHint}`
|
|
1304
1666
|
);
|
|
1305
1667
|
}
|
|
1306
1668
|
}
|
|
1307
|
-
async function
|
|
1669
|
+
async function listRelativePaths(root, dir = root) {
|
|
1670
|
+
const entries = await fsp.readdir(dir, { withFileTypes: true });
|
|
1671
|
+
const paths = [];
|
|
1672
|
+
for (const entry of entries) {
|
|
1673
|
+
const full = join7(dir, entry.name);
|
|
1674
|
+
if (entry.isDirectory()) {
|
|
1675
|
+
paths.push(...await listRelativePaths(root, full));
|
|
1676
|
+
} else if (entry.isFile()) {
|
|
1677
|
+
paths.push(full.slice(root.length + 1));
|
|
1678
|
+
} else {
|
|
1679
|
+
}
|
|
1680
|
+
}
|
|
1681
|
+
return paths;
|
|
1682
|
+
}
|
|
1683
|
+
async function mergePreservedOutArtifacts(priorArtifactsDir, destArtifactsDir, newArtifactPaths) {
|
|
1684
|
+
if (!await pathExists(priorArtifactsDir)) return;
|
|
1685
|
+
for (const rel of await listRelativePaths(priorArtifactsDir)) {
|
|
1686
|
+
if (newArtifactPaths.has(rel)) continue;
|
|
1687
|
+
const src = join7(priorArtifactsDir, rel);
|
|
1688
|
+
const dest = join7(destArtifactsDir, rel);
|
|
1689
|
+
await fsp.mkdir(dirname(dest), { recursive: true });
|
|
1690
|
+
await fsp.cp(src, dest, { force: true });
|
|
1691
|
+
}
|
|
1692
|
+
}
|
|
1693
|
+
async function promoteStagingToOutDir(stagingDir, outDir, options) {
|
|
1694
|
+
const outputBaseDir = options?.outputBaseDir ?? ".lxpack/out";
|
|
1695
|
+
if (options?.projectRoot) {
|
|
1696
|
+
assertRealPathUnderRoot(resolve6(options.projectRoot), resolve6(outDir));
|
|
1697
|
+
}
|
|
1308
1698
|
return withPromoteLock(outDir, async () => {
|
|
1309
|
-
await
|
|
1699
|
+
await removeStaleLegacyPromoteArtifacts(outDir);
|
|
1700
|
+
const stagingArtifactsDir = join7(stagingDir, outputBaseDir);
|
|
1701
|
+
const newArtifactPaths = /* @__PURE__ */ new Set();
|
|
1702
|
+
if (await pathExists(stagingArtifactsDir)) {
|
|
1703
|
+
for (const rel of await listRelativePaths(stagingArtifactsDir)) {
|
|
1704
|
+
newArtifactPaths.add(rel);
|
|
1705
|
+
}
|
|
1706
|
+
}
|
|
1310
1707
|
const parent = dirname(outDir);
|
|
1708
|
+
let priorArtifactsBackup;
|
|
1709
|
+
const existingArtifactsDir = join7(outDir, outputBaseDir);
|
|
1710
|
+
if (await pathExists(outDir) && await pathExists(existingArtifactsDir)) {
|
|
1711
|
+
priorArtifactsBackup = await fsp.mkdtemp(join7(parent, ".lk-prior-out-"));
|
|
1712
|
+
await fsp.cp(existingArtifactsDir, join7(priorArtifactsBackup, outputBaseDir), { recursive: true });
|
|
1713
|
+
}
|
|
1311
1714
|
const tmpPromote = await fsp.mkdtemp(join7(parent, ".lk-promote-"));
|
|
1312
1715
|
await renameOrCopy(stagingDir, tmpPromote);
|
|
1313
1716
|
const hadOutDir = await pathExists(outDir);
|
|
@@ -1364,6 +1767,20 @@ async function promoteStagingToOutDir(stagingDir, outDir) {
|
|
|
1364
1767
|
}
|
|
1365
1768
|
throw promoteError;
|
|
1366
1769
|
}
|
|
1770
|
+
if (priorArtifactsBackup) {
|
|
1771
|
+
try {
|
|
1772
|
+
await mergePreservedOutArtifacts(
|
|
1773
|
+
join7(priorArtifactsBackup, outputBaseDir),
|
|
1774
|
+
join7(outDir, outputBaseDir),
|
|
1775
|
+
newArtifactPaths
|
|
1776
|
+
);
|
|
1777
|
+
} finally {
|
|
1778
|
+
await fsp.rm(priorArtifactsBackup, { recursive: true, force: true }).catch(
|
|
1779
|
+
/* v8 ignore next */
|
|
1780
|
+
() => void 0
|
|
1781
|
+
);
|
|
1782
|
+
}
|
|
1783
|
+
}
|
|
1367
1784
|
if (backup) {
|
|
1368
1785
|
await fsp.rm(backup, { recursive: true, force: true }).catch(
|
|
1369
1786
|
/* v8 ignore next */
|
|
@@ -1381,6 +1798,7 @@ import { packageLessonkit } from "@lxpack/api";
|
|
|
1381
1798
|
async function buildStagingPackage(options) {
|
|
1382
1799
|
const { target, output, dir, outputBaseDir, descriptor, ...writeOpts } = options;
|
|
1383
1800
|
const stagingDir = await fsp2.mkdtemp(join8(tmpdir(), "lessonkit-lxpack-"));
|
|
1801
|
+
let succeeded = false;
|
|
1384
1802
|
try {
|
|
1385
1803
|
let spaDirs;
|
|
1386
1804
|
try {
|
|
@@ -1436,6 +1854,7 @@ async function buildStagingPackage(options) {
|
|
|
1436
1854
|
}))
|
|
1437
1855
|
};
|
|
1438
1856
|
}
|
|
1857
|
+
succeeded = true;
|
|
1439
1858
|
return {
|
|
1440
1859
|
ok: true,
|
|
1441
1860
|
stagingDir,
|
|
@@ -1449,6 +1868,13 @@ async function buildStagingPackage(options) {
|
|
|
1449
1868
|
() => void 0
|
|
1450
1869
|
);
|
|
1451
1870
|
throw err;
|
|
1871
|
+
} finally {
|
|
1872
|
+
if (!succeeded) {
|
|
1873
|
+
await fsp2.rm(stagingDir, { recursive: true, force: true }).catch(
|
|
1874
|
+
/* v8 ignore next */
|
|
1875
|
+
() => void 0
|
|
1876
|
+
);
|
|
1877
|
+
}
|
|
1452
1878
|
}
|
|
1453
1879
|
}
|
|
1454
1880
|
async function ensureOutDirParent(outDir) {
|
|
@@ -1463,6 +1889,12 @@ function isPackagingErrorIssue(issue) {
|
|
|
1463
1889
|
function findPackagingErrorIssues(issues) {
|
|
1464
1890
|
return (issues ?? []).filter(isPackagingErrorIssue);
|
|
1465
1891
|
}
|
|
1892
|
+
function isPackagingWarningIssue(issue) {
|
|
1893
|
+
return issue.severity?.toLowerCase() === "warning";
|
|
1894
|
+
}
|
|
1895
|
+
function findPackagingWarningIssues(issues) {
|
|
1896
|
+
return (issues ?? []).filter(isPackagingWarningIssue);
|
|
1897
|
+
}
|
|
1466
1898
|
|
|
1467
1899
|
// src/packageCourse.ts
|
|
1468
1900
|
async function validateLessonkitProject(options) {
|
|
@@ -1513,33 +1945,46 @@ async function packageLessonkitCourse(options) {
|
|
|
1513
1945
|
};
|
|
1514
1946
|
}
|
|
1515
1947
|
const descriptor = descriptorValidation.descriptor;
|
|
1516
|
-
|
|
1517
|
-
|
|
1518
|
-
|
|
1519
|
-
|
|
1948
|
+
const parityIssues = validateReactManifestParity({
|
|
1949
|
+
projectRoot: writeOpts.projectRoot,
|
|
1950
|
+
descriptor
|
|
1951
|
+
});
|
|
1952
|
+
const parityFailures = writeOpts.strictParity ? parityIssues : parityIssues.filter((i) => i.severity === "error");
|
|
1953
|
+
if (parityFailures.length > 0) {
|
|
1954
|
+
return {
|
|
1955
|
+
ok: false,
|
|
1956
|
+
courseDir: outDir,
|
|
1957
|
+
target,
|
|
1958
|
+
issues: parityFailures.map((i) => ({
|
|
1959
|
+
path: i.path,
|
|
1960
|
+
message: i.message,
|
|
1961
|
+
severity: i.severity
|
|
1962
|
+
}))
|
|
1963
|
+
};
|
|
1964
|
+
}
|
|
1965
|
+
let staged;
|
|
1966
|
+
try {
|
|
1967
|
+
staged = await buildStagingPackage({
|
|
1968
|
+
...writeOpts,
|
|
1969
|
+
descriptor,
|
|
1970
|
+
target,
|
|
1971
|
+
output,
|
|
1972
|
+
dir,
|
|
1973
|
+
outputBaseDir
|
|
1520
1974
|
});
|
|
1521
|
-
|
|
1522
|
-
|
|
1523
|
-
|
|
1524
|
-
|
|
1525
|
-
|
|
1526
|
-
|
|
1527
|
-
|
|
1528
|
-
path:
|
|
1529
|
-
message:
|
|
1530
|
-
|
|
1531
|
-
|
|
1532
|
-
|
|
1533
|
-
}
|
|
1975
|
+
} catch (err) {
|
|
1976
|
+
return {
|
|
1977
|
+
ok: false,
|
|
1978
|
+
courseDir: outDir,
|
|
1979
|
+
target,
|
|
1980
|
+
issues: [
|
|
1981
|
+
{
|
|
1982
|
+
path: "staging",
|
|
1983
|
+
message: err instanceof Error ? err.message : String(err)
|
|
1984
|
+
}
|
|
1985
|
+
]
|
|
1986
|
+
};
|
|
1534
1987
|
}
|
|
1535
|
-
const staged = await buildStagingPackage({
|
|
1536
|
-
...writeOpts,
|
|
1537
|
-
descriptor,
|
|
1538
|
-
target,
|
|
1539
|
-
output,
|
|
1540
|
-
dir,
|
|
1541
|
-
outputBaseDir
|
|
1542
|
-
});
|
|
1543
1988
|
if (!staged.ok) {
|
|
1544
1989
|
await fsp3.rm(staged.stagingDir, { recursive: true, force: true }).catch(
|
|
1545
1990
|
/* v8 ignore next */
|
|
@@ -1589,11 +2034,30 @@ async function packageLessonkitCourse(options) {
|
|
|
1589
2034
|
ok: false,
|
|
1590
2035
|
courseDir: outDir,
|
|
1591
2036
|
target,
|
|
1592
|
-
validation: { ok:
|
|
2037
|
+
validation: { ok: false, manifest: build.manifest, issues: build.issues },
|
|
1593
2038
|
build,
|
|
1594
2039
|
issues: artifactIssues
|
|
1595
2040
|
};
|
|
1596
2041
|
}
|
|
2042
|
+
const buildWarningIssues = findPackagingWarningIssues(build.issues);
|
|
2043
|
+
if (options.strictBuild && buildWarningIssues.length > 0) {
|
|
2044
|
+
await fsp3.rm(stagingDir, { recursive: true, force: true }).catch(
|
|
2045
|
+
/* v8 ignore next */
|
|
2046
|
+
() => void 0
|
|
2047
|
+
);
|
|
2048
|
+
return {
|
|
2049
|
+
ok: false,
|
|
2050
|
+
courseDir: outDir,
|
|
2051
|
+
target,
|
|
2052
|
+
validation: { ok: false, manifest: build.manifest, issues: build.issues },
|
|
2053
|
+
build,
|
|
2054
|
+
issues: buildWarningIssues.map((i) => ({
|
|
2055
|
+
path: i.path ?? "build",
|
|
2056
|
+
message: i.message ?? "build warning",
|
|
2057
|
+
severity: i.severity
|
|
2058
|
+
}))
|
|
2059
|
+
};
|
|
2060
|
+
}
|
|
1597
2061
|
const remappedOutputPath = remapArtifactPaths(stagingRoot, outDir, staged.outputPath);
|
|
1598
2062
|
const remappedOutputDir = remapArtifactPaths(stagingRoot, outDir, staged.outputDir);
|
|
1599
2063
|
const validation = {
|
|
@@ -1603,7 +2067,10 @@ async function packageLessonkitCourse(options) {
|
|
|
1603
2067
|
};
|
|
1604
2068
|
try {
|
|
1605
2069
|
await ensureOutDirParent(outDir);
|
|
1606
|
-
await promoteStagingToOutDir(stagingDir, outDir
|
|
2070
|
+
await promoteStagingToOutDir(stagingDir, outDir, {
|
|
2071
|
+
outputBaseDir: outputBaseDir ?? ".lxpack/out",
|
|
2072
|
+
projectRoot: writeOpts.projectRoot
|
|
2073
|
+
});
|
|
1607
2074
|
} catch (err) {
|
|
1608
2075
|
await fsp3.rm(stagingDir, { recursive: true, force: true }).catch(
|
|
1609
2076
|
/* v8 ignore next */
|
|
@@ -1726,6 +2193,20 @@ function parseLessonkitManifest(raw, label = "lessonkit.json", projectRoot) {
|
|
|
1726
2193
|
message: `"course.spaDistDir" (${courseSpaDistDir}) differs from "paths.spaDistDir" (${paths.spaDistDir}). Use paths.spaDistDir for CLI build and package.`
|
|
1727
2194
|
});
|
|
1728
2195
|
}
|
|
2196
|
+
for (const key of ["spaDistDir", "lxpackOutDir", "outputBaseDir"]) {
|
|
2197
|
+
const value = paths[key];
|
|
2198
|
+
if (!isSafeRelativeSpaPath(value)) {
|
|
2199
|
+
issues.push({
|
|
2200
|
+
path: `paths.${key}`,
|
|
2201
|
+
message: "path must be relative without '..' segments or absolute prefixes"
|
|
2202
|
+
});
|
|
2203
|
+
} else if (isReservedOutputPath(value)) {
|
|
2204
|
+
issues.push({
|
|
2205
|
+
path: `paths.${key}`,
|
|
2206
|
+
message: "path must not target reserved directories (.git, node_modules, .github)"
|
|
2207
|
+
});
|
|
2208
|
+
}
|
|
2209
|
+
}
|
|
1729
2210
|
if (projectRoot) {
|
|
1730
2211
|
const pathIssues = validateProjectPaths(projectRoot, paths);
|
|
1731
2212
|
for (const pi of pathIssues) {
|
|
@@ -1761,16 +2242,821 @@ import {
|
|
|
1761
2242
|
import {
|
|
1762
2243
|
lessonkitInterchangeSchema,
|
|
1763
2244
|
materializeLessonkitProject as materializeLessonkitProject2,
|
|
1764
|
-
parseLessonkitInterchange
|
|
2245
|
+
parseLessonkitInterchange as parseLessonkitInterchange3
|
|
1765
2246
|
} from "@lxpack/validators";
|
|
2247
|
+
|
|
2248
|
+
// src/lkcourse/zip.ts
|
|
2249
|
+
import { readFileSync as readFileSync2, statSync } from "fs";
|
|
2250
|
+
import { dirname as dirname3, join as join9, normalize } from "path";
|
|
2251
|
+
import { strFromU8, strToU8, unzipSync, zipSync } from "fflate";
|
|
2252
|
+
var MAX_LKCOURSE_UNCOMPRESSED_BYTES = 256 * 1024 * 1024;
|
|
2253
|
+
function canonicalZipEntryPath(entryPath) {
|
|
2254
|
+
const slashNormalized = entryPath.replace(/\\/g, "/");
|
|
2255
|
+
const canonical = normalize(slashNormalized).replace(/\\/g, "/");
|
|
2256
|
+
if (canonical !== slashNormalized) return null;
|
|
2257
|
+
return canonical;
|
|
2258
|
+
}
|
|
2259
|
+
function isSafeZipEntryPath(entryPath) {
|
|
2260
|
+
const canonical = canonicalZipEntryPath(entryPath);
|
|
2261
|
+
if (!canonical?.length || canonical.startsWith("/") || canonical.includes("\0")) {
|
|
2262
|
+
return false;
|
|
2263
|
+
}
|
|
2264
|
+
const segments = canonical.split("/").filter((s) => s.length > 0);
|
|
2265
|
+
if (segments.some((s) => s === "..")) return false;
|
|
2266
|
+
return segments.length > 0;
|
|
2267
|
+
}
|
|
2268
|
+
function createZip(entries) {
|
|
2269
|
+
const zipped = {};
|
|
2270
|
+
for (const [path, data] of entries) {
|
|
2271
|
+
if (!isSafeZipEntryPath(path)) {
|
|
2272
|
+
throw new Error(`unsafe zip entry path: ${path}`);
|
|
2273
|
+
}
|
|
2274
|
+
zipped[path.replace(/\\/g, "/")] = data instanceof Uint8Array ? data : new Uint8Array(data);
|
|
2275
|
+
}
|
|
2276
|
+
return zipSync(zipped, { level: 6 });
|
|
2277
|
+
}
|
|
2278
|
+
function readZip(archivePath) {
|
|
2279
|
+
const issues = [];
|
|
2280
|
+
let raw;
|
|
2281
|
+
try {
|
|
2282
|
+
raw = readFileSync2(archivePath);
|
|
2283
|
+
} catch {
|
|
2284
|
+
return { ok: false, issues: [{ path: archivePath, message: "failed to read archive" }] };
|
|
2285
|
+
}
|
|
2286
|
+
if (!raw.length) {
|
|
2287
|
+
return { ok: false, issues: [{ path: archivePath, message: "archive is empty" }] };
|
|
2288
|
+
}
|
|
2289
|
+
let unzipped;
|
|
2290
|
+
try {
|
|
2291
|
+
unzipped = unzipSync(raw);
|
|
2292
|
+
} catch {
|
|
2293
|
+
return { ok: false, issues: [{ path: archivePath, message: "invalid zip archive" }] };
|
|
2294
|
+
}
|
|
2295
|
+
const entries = /* @__PURE__ */ new Map();
|
|
2296
|
+
let totalUncompressed = 0;
|
|
2297
|
+
for (const [path, data] of Object.entries(unzipped)) {
|
|
2298
|
+
const canonical = canonicalZipEntryPath(path);
|
|
2299
|
+
if (!canonical || !isSafeZipEntryPath(canonical)) {
|
|
2300
|
+
issues.push({ path, message: "unsafe zip entry path" });
|
|
2301
|
+
continue;
|
|
2302
|
+
}
|
|
2303
|
+
if (entries.has(canonical)) {
|
|
2304
|
+
issues.push({ path: canonical, message: "duplicate zip entry path" });
|
|
2305
|
+
continue;
|
|
2306
|
+
}
|
|
2307
|
+
totalUncompressed += data.byteLength;
|
|
2308
|
+
if (totalUncompressed > MAX_LKCOURSE_UNCOMPRESSED_BYTES) {
|
|
2309
|
+
return {
|
|
2310
|
+
ok: false,
|
|
2311
|
+
issues: [
|
|
2312
|
+
{
|
|
2313
|
+
path: archivePath,
|
|
2314
|
+
message: `archive exceeds max uncompressed size (${MAX_LKCOURSE_UNCOMPRESSED_BYTES} bytes)`
|
|
2315
|
+
}
|
|
2316
|
+
]
|
|
2317
|
+
};
|
|
2318
|
+
}
|
|
2319
|
+
entries.set(canonical, data);
|
|
2320
|
+
}
|
|
2321
|
+
if (issues.length) return { ok: false, issues };
|
|
2322
|
+
return { ok: true, entries };
|
|
2323
|
+
}
|
|
2324
|
+
async function collectDistEntries(distDir, spaDistRelative) {
|
|
2325
|
+
const { lstat: lstat2, readdir: readdir4, readFile: readFile2 } = await import("fs/promises");
|
|
2326
|
+
const entries = /* @__PURE__ */ new Map();
|
|
2327
|
+
const walk = async (absDir, relPrefix) => {
|
|
2328
|
+
const dirEntries = await readdir4(absDir, { withFileTypes: true });
|
|
2329
|
+
for (const entry of dirEntries) {
|
|
2330
|
+
const abs = join9(absDir, entry.name);
|
|
2331
|
+
const rel = relPrefix ? `${relPrefix}/${entry.name}` : entry.name;
|
|
2332
|
+
const zipPath = `${spaDistRelative}/${rel}`.replace(/\\/g, "/");
|
|
2333
|
+
if (!isSafeRelativeSpaPath(zipPath)) {
|
|
2334
|
+
throw new Error(`unsafe dist path: ${zipPath}`);
|
|
2335
|
+
}
|
|
2336
|
+
const stat2 = await lstat2(abs);
|
|
2337
|
+
if (stat2.isSymbolicLink()) {
|
|
2338
|
+
throw new Error(`dist contains symlink: ${abs}`);
|
|
2339
|
+
}
|
|
2340
|
+
if (stat2.isDirectory()) {
|
|
2341
|
+
await walk(abs, rel);
|
|
2342
|
+
} else if (stat2.isFile()) {
|
|
2343
|
+
entries.set(zipPath.replace(/\\/g, "/"), await readFile2(abs));
|
|
2344
|
+
}
|
|
2345
|
+
}
|
|
2346
|
+
};
|
|
2347
|
+
await walk(distDir, "");
|
|
2348
|
+
return entries;
|
|
2349
|
+
}
|
|
2350
|
+
function entryToUtf8(data) {
|
|
2351
|
+
return strFromU8(data);
|
|
2352
|
+
}
|
|
2353
|
+
function utf8ToEntry(text) {
|
|
2354
|
+
return strToU8(text);
|
|
2355
|
+
}
|
|
2356
|
+
|
|
2357
|
+
// src/lkcourse/parseEnvelope.ts
|
|
2358
|
+
function parseLkcourseEnvelope(raw, label = "manifest.json") {
|
|
2359
|
+
const issues = [];
|
|
2360
|
+
if (!raw || typeof raw !== "object") {
|
|
2361
|
+
return { ok: false, issues: [{ path: label, message: "must be a JSON object" }] };
|
|
2362
|
+
}
|
|
2363
|
+
const obj = raw;
|
|
2364
|
+
if (obj.format !== "lkcourse") {
|
|
2365
|
+
issues.push({
|
|
2366
|
+
path: "format",
|
|
2367
|
+
message: `must be "lkcourse" (got ${String(obj.format)})`
|
|
2368
|
+
});
|
|
2369
|
+
}
|
|
2370
|
+
let schemaVersion = obj.schemaVersion;
|
|
2371
|
+
if (schemaVersion === "1") schemaVersion = 1;
|
|
2372
|
+
if (schemaVersion !== 1) {
|
|
2373
|
+
issues.push({
|
|
2374
|
+
path: "schemaVersion",
|
|
2375
|
+
message: `must be 1 (got ${String(obj.schemaVersion)})`
|
|
2376
|
+
});
|
|
2377
|
+
}
|
|
2378
|
+
const lessonkitVersion = typeof obj.lessonkitVersion === "string" ? obj.lessonkitVersion.trim() : "";
|
|
2379
|
+
if (!lessonkitVersion) {
|
|
2380
|
+
issues.push({ path: "lessonkitVersion", message: "must be a non-empty string" });
|
|
2381
|
+
}
|
|
2382
|
+
const exportedAt = typeof obj.exportedAt === "string" ? obj.exportedAt.trim() : "";
|
|
2383
|
+
if (!exportedAt) {
|
|
2384
|
+
issues.push({ path: "exportedAt", message: "must be a non-empty string" });
|
|
2385
|
+
}
|
|
2386
|
+
const entriesRaw = obj.entries;
|
|
2387
|
+
const entries = [];
|
|
2388
|
+
if (!Array.isArray(entriesRaw) || entriesRaw.length === 0) {
|
|
2389
|
+
issues.push({ path: "entries", message: "must be a non-empty array of relative paths" });
|
|
2390
|
+
} else {
|
|
2391
|
+
for (let i = 0; i < entriesRaw.length; i++) {
|
|
2392
|
+
const entry = entriesRaw[i];
|
|
2393
|
+
if (typeof entry !== "string" || !entry.trim()) {
|
|
2394
|
+
issues.push({ path: `entries[${i}]`, message: "must be a non-empty string" });
|
|
2395
|
+
} else {
|
|
2396
|
+
const trimmed = entry.trim();
|
|
2397
|
+
if (!isSafeZipEntryPath(trimmed)) {
|
|
2398
|
+
issues.push({ path: `entries[${i}]`, message: "must be a safe relative path" });
|
|
2399
|
+
} else {
|
|
2400
|
+
entries.push(trimmed);
|
|
2401
|
+
}
|
|
2402
|
+
}
|
|
2403
|
+
}
|
|
2404
|
+
}
|
|
2405
|
+
if (issues.length) return { ok: false, issues };
|
|
2406
|
+
const manifestParsed = parseLessonkitManifest(obj.sourceManifest, `${label}.sourceManifest`);
|
|
2407
|
+
if (!manifestParsed.ok) {
|
|
2408
|
+
return {
|
|
2409
|
+
ok: false,
|
|
2410
|
+
issues: manifestParsed.issues.map((issue) => ({
|
|
2411
|
+
path: `sourceManifest.${issue.path}`,
|
|
2412
|
+
message: issue.message
|
|
2413
|
+
}))
|
|
2414
|
+
};
|
|
2415
|
+
}
|
|
2416
|
+
return {
|
|
2417
|
+
ok: true,
|
|
2418
|
+
envelope: {
|
|
2419
|
+
format: "lkcourse",
|
|
2420
|
+
schemaVersion: 1,
|
|
2421
|
+
lessonkitVersion,
|
|
2422
|
+
exportedAt,
|
|
2423
|
+
sourceManifest: manifestParsed.manifest,
|
|
2424
|
+
entries
|
|
2425
|
+
}
|
|
2426
|
+
};
|
|
2427
|
+
}
|
|
2428
|
+
|
|
2429
|
+
// src/lkcourse/blockTree.ts
|
|
2430
|
+
import { existsSync as existsSync4, lstatSync as lstatSync2, readdirSync as readdirSync2, readFileSync as readFileSync3 } from "fs";
|
|
2431
|
+
import { createRequire } from "module";
|
|
2432
|
+
import { join as join10, relative as relative3 } from "path";
|
|
2433
|
+
import { validateId as validateId4 } from "@lessonkit/core";
|
|
2434
|
+
var SCANNABLE_EXTENSIONS2 = [".tsx", ".ts", ".jsx", ".js"];
|
|
2435
|
+
var ID_PROPS = ["courseId", "lessonId", "checkId", "blockId", "nodeId"];
|
|
2436
|
+
function stripComments2(source) {
|
|
2437
|
+
return source.replace(/\/\*[\s\S]*?\*\//g, " ").replace(/\/\/[^\n]*/g, " ");
|
|
2438
|
+
}
|
|
2439
|
+
function collectSourceUnderSrc2(projectRoot) {
|
|
2440
|
+
const srcDir = join10(projectRoot, "src");
|
|
2441
|
+
if (!existsSync4(srcDir)) return [];
|
|
2442
|
+
const results = [];
|
|
2443
|
+
const walk = (dir) => {
|
|
2444
|
+
for (const entry of readdirSync2(dir)) {
|
|
2445
|
+
const abs = join10(dir, entry);
|
|
2446
|
+
try {
|
|
2447
|
+
assertRealPathUnderRoot(projectRoot, abs);
|
|
2448
|
+
} catch {
|
|
2449
|
+
continue;
|
|
2450
|
+
}
|
|
2451
|
+
const stat2 = lstatSync2(abs);
|
|
2452
|
+
if (stat2.isSymbolicLink()) continue;
|
|
2453
|
+
if (stat2.isDirectory()) {
|
|
2454
|
+
walk(abs);
|
|
2455
|
+
} else if (SCANNABLE_EXTENSIONS2.some((ext) => entry.endsWith(ext))) {
|
|
2456
|
+
results.push(relative3(projectRoot, abs));
|
|
2457
|
+
}
|
|
2458
|
+
}
|
|
2459
|
+
};
|
|
2460
|
+
walk(srcDir);
|
|
2461
|
+
return results;
|
|
2462
|
+
}
|
|
2463
|
+
function loadCatalogBlockTypes(blockTypes) {
|
|
2464
|
+
if (blockTypes?.length) return blockTypes;
|
|
2465
|
+
try {
|
|
2466
|
+
const require2 = createRequire(import.meta.url);
|
|
2467
|
+
const catalogPath = require2.resolve("@lessonkit/react/block-catalog.v3.json");
|
|
2468
|
+
const catalog = JSON.parse(readFileSync3(catalogPath, "utf8"));
|
|
2469
|
+
return (catalog.entries ?? []).map((e) => e.type).filter((t) => typeof t === "string" && t.length > 0);
|
|
2470
|
+
} catch {
|
|
2471
|
+
return [
|
|
2472
|
+
"Course",
|
|
2473
|
+
"Lesson",
|
|
2474
|
+
"Scenario",
|
|
2475
|
+
"Quiz",
|
|
2476
|
+
"KnowledgeCheck",
|
|
2477
|
+
"ProgressTracker",
|
|
2478
|
+
"Reflection",
|
|
2479
|
+
"TrueFalse",
|
|
2480
|
+
"MarkTheWords",
|
|
2481
|
+
"FillInTheBlanks",
|
|
2482
|
+
"DragTheWords",
|
|
2483
|
+
"DragAndDrop",
|
|
2484
|
+
"AssessmentSequence",
|
|
2485
|
+
"Text",
|
|
2486
|
+
"Heading",
|
|
2487
|
+
"Image",
|
|
2488
|
+
"Video",
|
|
2489
|
+
"Page",
|
|
2490
|
+
"InteractiveBook",
|
|
2491
|
+
"Slide",
|
|
2492
|
+
"SlideDeck",
|
|
2493
|
+
"TimedCue",
|
|
2494
|
+
"InteractiveVideo",
|
|
2495
|
+
"Summary",
|
|
2496
|
+
"BranchingScenario",
|
|
2497
|
+
"BranchNode",
|
|
2498
|
+
"BranchChoice",
|
|
2499
|
+
"Embed",
|
|
2500
|
+
"Chart"
|
|
2501
|
+
];
|
|
2502
|
+
}
|
|
2503
|
+
}
|
|
2504
|
+
function extractIdProp(tagSource, prop) {
|
|
2505
|
+
const re = new RegExp(
|
|
2506
|
+
`\\b${prop}\\s*=\\s*(?:"([^"]*)"|'([^']*)'|\\{\\s*["'\`]([^"'\`]+)["'\`]\\s*\\})`
|
|
2507
|
+
);
|
|
2508
|
+
const match = tagSource.match(re);
|
|
2509
|
+
if (!match) return void 0;
|
|
2510
|
+
return match[1] ?? match[2] ?? match[3];
|
|
2511
|
+
}
|
|
2512
|
+
function parseJsxBlocks(source, blockTypes) {
|
|
2513
|
+
const stripped = stripComments2(source);
|
|
2514
|
+
const tagRe = /<([A-Z][A-Za-z0-9]*)\b([^>]*?)(\/?)>/g;
|
|
2515
|
+
const stack = [];
|
|
2516
|
+
const roots = [];
|
|
2517
|
+
for (const match of stripped.matchAll(tagRe)) {
|
|
2518
|
+
const rawTag = match[1];
|
|
2519
|
+
const attrs = match[2] ?? "";
|
|
2520
|
+
const selfClosing = match[3] === "/";
|
|
2521
|
+
if (rawTag === "Fragment" || rawTag.endsWith("Provider")) continue;
|
|
2522
|
+
const known = blockTypes.has(rawTag);
|
|
2523
|
+
const node = known ? { type: rawTag } : { type: "Unknown", rawTag };
|
|
2524
|
+
for (const prop of ID_PROPS) {
|
|
2525
|
+
const value = extractIdProp(attrs, prop);
|
|
2526
|
+
if (value) node[prop] = value;
|
|
2527
|
+
}
|
|
2528
|
+
if (selfClosing) {
|
|
2529
|
+
if (stack.length) {
|
|
2530
|
+
const parent = stack[stack.length - 1];
|
|
2531
|
+
parent.children = parent.children ?? [];
|
|
2532
|
+
parent.children.push(node);
|
|
2533
|
+
} else {
|
|
2534
|
+
roots.push(node);
|
|
2535
|
+
}
|
|
2536
|
+
continue;
|
|
2537
|
+
}
|
|
2538
|
+
const closeRe = new RegExp(`</${rawTag}>`);
|
|
2539
|
+
const closeMatch = closeRe.exec(stripped.slice((match.index ?? 0) + match[0].length));
|
|
2540
|
+
if (!closeMatch) {
|
|
2541
|
+
if (stack.length) {
|
|
2542
|
+
const parent = stack[stack.length - 1];
|
|
2543
|
+
parent.children = parent.children ?? [];
|
|
2544
|
+
parent.children.push(node);
|
|
2545
|
+
} else {
|
|
2546
|
+
roots.push(node);
|
|
2547
|
+
}
|
|
2548
|
+
continue;
|
|
2549
|
+
}
|
|
2550
|
+
stack.push(node);
|
|
2551
|
+
const nextClose = stripped.indexOf(`</${rawTag}>`, (match.index ?? 0) + match[0].length);
|
|
2552
|
+
const inner = stripped.slice((match.index ?? 0) + match[0].length, nextClose);
|
|
2553
|
+
if (!inner.includes("<")) {
|
|
2554
|
+
stack.pop();
|
|
2555
|
+
if (stack.length) {
|
|
2556
|
+
const parent = stack[stack.length - 1];
|
|
2557
|
+
parent.children = parent.children ?? [];
|
|
2558
|
+
parent.children.push(node);
|
|
2559
|
+
} else {
|
|
2560
|
+
roots.push(node);
|
|
2561
|
+
}
|
|
2562
|
+
}
|
|
2563
|
+
}
|
|
2564
|
+
return roots.length ? roots : stack;
|
|
2565
|
+
}
|
|
2566
|
+
function validateNodeIds(node, pathPrefix, issues) {
|
|
2567
|
+
for (const prop of ID_PROPS) {
|
|
2568
|
+
const value = node[prop];
|
|
2569
|
+
if (value === void 0) continue;
|
|
2570
|
+
const validated = validateId4(value, prop);
|
|
2571
|
+
if (!validated.ok) {
|
|
2572
|
+
issues.push({
|
|
2573
|
+
path: `${pathPrefix}.${prop}`,
|
|
2574
|
+
message: validated.issues[0]?.message ?? `invalid ${prop}`
|
|
2575
|
+
});
|
|
2576
|
+
}
|
|
2577
|
+
}
|
|
2578
|
+
node.children?.forEach((child, index) => {
|
|
2579
|
+
validateNodeIds(child, `${pathPrefix}.children[${index}]`, issues);
|
|
2580
|
+
});
|
|
2581
|
+
}
|
|
2582
|
+
function validateBlockTreeIds(tree) {
|
|
2583
|
+
const issues = [];
|
|
2584
|
+
tree.blocks.forEach((block, index) => {
|
|
2585
|
+
validateNodeIds(block, `blocks[${index}]`, issues);
|
|
2586
|
+
});
|
|
2587
|
+
return issues;
|
|
2588
|
+
}
|
|
2589
|
+
function extractBlockTree(options) {
|
|
2590
|
+
const blockTypes = new Set(loadCatalogBlockTypes(options.blockTypes));
|
|
2591
|
+
const sources = options.appSources ?? collectSourceUnderSrc2(options.projectRoot);
|
|
2592
|
+
const blocks = [];
|
|
2593
|
+
for (const rel of sources) {
|
|
2594
|
+
const abs = join10(options.projectRoot, rel);
|
|
2595
|
+
if (!existsSync4(abs)) continue;
|
|
2596
|
+
const source = readFileSync3(abs, "utf8");
|
|
2597
|
+
const parsed = parseJsxBlocks(source, blockTypes);
|
|
2598
|
+
blocks.push(...parsed);
|
|
2599
|
+
}
|
|
2600
|
+
return {
|
|
2601
|
+
schemaVersion: 1,
|
|
2602
|
+
sources,
|
|
2603
|
+
blocks
|
|
2604
|
+
};
|
|
2605
|
+
}
|
|
2606
|
+
|
|
2607
|
+
// src/lkcourse/export.ts
|
|
2608
|
+
import { mkdir as mkdir3, writeFile } from "fs/promises";
|
|
2609
|
+
import { createRequire as createRequire2 } from "module";
|
|
2610
|
+
import { dirname as dirname4, join as join11, resolve as resolve8 } from "path";
|
|
2611
|
+
import { parseLessonkitInterchange } from "@lxpack/validators";
|
|
2612
|
+
function resolveLessonkitVersion(explicit) {
|
|
2613
|
+
if (explicit?.trim()) return explicit.trim();
|
|
2614
|
+
try {
|
|
2615
|
+
const require2 = createRequire2(import.meta.url);
|
|
2616
|
+
const pkg = require2("../../package.json");
|
|
2617
|
+
return pkg.version ?? "0.0.0";
|
|
2618
|
+
} catch {
|
|
2619
|
+
return "0.0.0";
|
|
2620
|
+
}
|
|
2621
|
+
}
|
|
2622
|
+
async function exportLkcourse(options) {
|
|
2623
|
+
const projectRoot = resolve8(options.projectRoot);
|
|
2624
|
+
const manifest = options.manifest;
|
|
2625
|
+
const spaDistDir = join11(projectRoot, manifest.paths.spaDistDir);
|
|
2626
|
+
try {
|
|
2627
|
+
assertRealPathUnderRoot(projectRoot, spaDistDir);
|
|
2628
|
+
await assertSpaDistContentsSafe({ main: spaDistDir }, projectRoot);
|
|
2629
|
+
} catch (err) {
|
|
2630
|
+
return {
|
|
2631
|
+
ok: false,
|
|
2632
|
+
issues: [
|
|
2633
|
+
{
|
|
2634
|
+
path: manifest.paths.spaDistDir,
|
|
2635
|
+
message: err instanceof Error ? err.message : String(err)
|
|
2636
|
+
}
|
|
2637
|
+
]
|
|
2638
|
+
};
|
|
2639
|
+
}
|
|
2640
|
+
const interchange = descriptorToInterchange(manifest.course);
|
|
2641
|
+
const interchangeParsed = parseLessonkitInterchange(interchange);
|
|
2642
|
+
if (!interchangeParsed.ok) {
|
|
2643
|
+
return {
|
|
2644
|
+
ok: false,
|
|
2645
|
+
issues: interchangeParsed.issues.map((i) => ({
|
|
2646
|
+
path: `interchange.${i.path ?? ""}`.replace(/\.$/, ""),
|
|
2647
|
+
message: i.message
|
|
2648
|
+
}))
|
|
2649
|
+
};
|
|
2650
|
+
}
|
|
2651
|
+
const validatedInterchange = interchangeParsed.data;
|
|
2652
|
+
const interchangeCourseId = validatedInterchange.course?.id;
|
|
2653
|
+
if (!interchangeCourseId) {
|
|
2654
|
+
return {
|
|
2655
|
+
ok: false,
|
|
2656
|
+
issues: [{ path: "interchange.course.id", message: "missing course id in interchange" }]
|
|
2657
|
+
};
|
|
2658
|
+
}
|
|
2659
|
+
if (manifest.course.courseId !== interchangeCourseId) {
|
|
2660
|
+
return {
|
|
2661
|
+
ok: false,
|
|
2662
|
+
issues: [
|
|
2663
|
+
{
|
|
2664
|
+
path: "course.courseId",
|
|
2665
|
+
message: `descriptor courseId "${manifest.course.courseId}" does not match interchange course.id "${interchangeCourseId}"`
|
|
2666
|
+
}
|
|
2667
|
+
]
|
|
2668
|
+
};
|
|
2669
|
+
}
|
|
2670
|
+
const zipEntries = /* @__PURE__ */ new Map();
|
|
2671
|
+
const interchangeJson = JSON.stringify(interchange, null, 2);
|
|
2672
|
+
zipEntries.set("interchange.json", utf8ToEntry(interchangeJson));
|
|
2673
|
+
let blockTreeJson;
|
|
2674
|
+
if (options.includeBlockTree) {
|
|
2675
|
+
const blockTree = extractBlockTree({ projectRoot });
|
|
2676
|
+
const blockTreeIssues = validateBlockTreeIds(blockTree);
|
|
2677
|
+
if (blockTreeIssues.length) {
|
|
2678
|
+
return {
|
|
2679
|
+
ok: false,
|
|
2680
|
+
issues: blockTreeIssues.map((issue) => ({
|
|
2681
|
+
path: `block-tree.${issue.path}`,
|
|
2682
|
+
message: issue.message
|
|
2683
|
+
}))
|
|
2684
|
+
};
|
|
2685
|
+
}
|
|
2686
|
+
blockTreeJson = JSON.stringify(blockTree, null, 2);
|
|
2687
|
+
zipEntries.set("block-tree.json", utf8ToEntry(blockTreeJson));
|
|
2688
|
+
}
|
|
2689
|
+
let distEntries;
|
|
2690
|
+
try {
|
|
2691
|
+
distEntries = await collectDistEntries(spaDistDir, manifest.paths.spaDistDir);
|
|
2692
|
+
} catch (err) {
|
|
2693
|
+
return {
|
|
2694
|
+
ok: false,
|
|
2695
|
+
issues: [
|
|
2696
|
+
{
|
|
2697
|
+
path: manifest.paths.spaDistDir,
|
|
2698
|
+
message: err instanceof Error ? err.message : String(err)
|
|
2699
|
+
}
|
|
2700
|
+
]
|
|
2701
|
+
};
|
|
2702
|
+
}
|
|
2703
|
+
if (!distEntries.has(`${manifest.paths.spaDistDir}/index.html`.replace(/\\/g, "/"))) {
|
|
2704
|
+
return {
|
|
2705
|
+
ok: false,
|
|
2706
|
+
issues: [
|
|
2707
|
+
{
|
|
2708
|
+
path: `${manifest.paths.spaDistDir}/index.html`,
|
|
2709
|
+
message: "dist must contain index.html before export"
|
|
2710
|
+
}
|
|
2711
|
+
]
|
|
2712
|
+
};
|
|
2713
|
+
}
|
|
2714
|
+
for (const [path, data] of distEntries) {
|
|
2715
|
+
zipEntries.set(path, data);
|
|
2716
|
+
}
|
|
2717
|
+
const entryPaths = [...zipEntries.keys()].sort();
|
|
2718
|
+
const envelope = {
|
|
2719
|
+
format: "lkcourse",
|
|
2720
|
+
schemaVersion: 1,
|
|
2721
|
+
lessonkitVersion: resolveLessonkitVersion(options.lessonkitVersion),
|
|
2722
|
+
exportedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
2723
|
+
sourceManifest: manifest,
|
|
2724
|
+
entries: entryPaths
|
|
2725
|
+
};
|
|
2726
|
+
const envelopeCheck = parseLkcourseEnvelope(envelope);
|
|
2727
|
+
if (!envelopeCheck.ok) {
|
|
2728
|
+
return { ok: false, issues: envelopeCheck.issues };
|
|
2729
|
+
}
|
|
2730
|
+
zipEntries.set("manifest.json", utf8ToEntry(JSON.stringify(envelope, null, 2)));
|
|
2731
|
+
const archivePath = resolve8(
|
|
2732
|
+
projectRoot,
|
|
2733
|
+
options.outPath ?? `${manifest.name}.lkcourse`
|
|
2734
|
+
);
|
|
2735
|
+
try {
|
|
2736
|
+
assertRealPathUnderRoot(projectRoot, archivePath);
|
|
2737
|
+
} catch (err) {
|
|
2738
|
+
return {
|
|
2739
|
+
ok: false,
|
|
2740
|
+
issues: [
|
|
2741
|
+
{
|
|
2742
|
+
path: options.outPath ?? `${manifest.name}.lkcourse`,
|
|
2743
|
+
message: err instanceof Error ? err.message : String(err)
|
|
2744
|
+
}
|
|
2745
|
+
]
|
|
2746
|
+
};
|
|
2747
|
+
}
|
|
2748
|
+
if (!isSafeZipEntryPath(options.outPath ?? `${manifest.name}.lkcourse`)) {
|
|
2749
|
+
return {
|
|
2750
|
+
ok: false,
|
|
2751
|
+
issues: [{ path: "outPath", message: "output path must be a safe relative path" }]
|
|
2752
|
+
};
|
|
2753
|
+
}
|
|
2754
|
+
try {
|
|
2755
|
+
await mkdir3(dirname4(archivePath), { recursive: true });
|
|
2756
|
+
const zipped = createZip(zipEntries);
|
|
2757
|
+
await writeFile(archivePath, zipped);
|
|
2758
|
+
} catch (err) {
|
|
2759
|
+
return {
|
|
2760
|
+
ok: false,
|
|
2761
|
+
issues: [
|
|
2762
|
+
{
|
|
2763
|
+
path: archivePath,
|
|
2764
|
+
message: err instanceof Error ? err.message : String(err)
|
|
2765
|
+
}
|
|
2766
|
+
]
|
|
2767
|
+
};
|
|
2768
|
+
}
|
|
2769
|
+
return {
|
|
2770
|
+
ok: true,
|
|
2771
|
+
archivePath,
|
|
2772
|
+
fileCount: zipEntries.size,
|
|
2773
|
+
includeBlockTree: Boolean(options.includeBlockTree)
|
|
2774
|
+
};
|
|
2775
|
+
}
|
|
2776
|
+
|
|
2777
|
+
// src/lkcourse/validate.ts
|
|
2778
|
+
import { parseLessonkitInterchange as parseLessonkitInterchange2 } from "@lxpack/validators";
|
|
2779
|
+
function validateLkcourseArchiveEntries(entries, _archiveLabel) {
|
|
2780
|
+
const issues = [];
|
|
2781
|
+
const manifestData = entries.get("manifest.json");
|
|
2782
|
+
if (!manifestData) {
|
|
2783
|
+
return {
|
|
2784
|
+
ok: false,
|
|
2785
|
+
issues: [{ path: "manifest.json", message: "required file missing from archive" }]
|
|
2786
|
+
};
|
|
2787
|
+
}
|
|
2788
|
+
let envelopeRaw;
|
|
2789
|
+
try {
|
|
2790
|
+
envelopeRaw = JSON.parse(entryToUtf8(manifestData));
|
|
2791
|
+
} catch {
|
|
2792
|
+
return {
|
|
2793
|
+
ok: false,
|
|
2794
|
+
issues: [{ path: "manifest.json", message: "invalid JSON" }]
|
|
2795
|
+
};
|
|
2796
|
+
}
|
|
2797
|
+
const envelopeParsed = parseLkcourseEnvelope(envelopeRaw, "manifest.json");
|
|
2798
|
+
if (!envelopeParsed.ok) {
|
|
2799
|
+
return { ok: false, issues: envelopeParsed.issues };
|
|
2800
|
+
}
|
|
2801
|
+
const envelope = envelopeParsed.envelope;
|
|
2802
|
+
const interchangeData = entries.get("interchange.json");
|
|
2803
|
+
if (!interchangeData) {
|
|
2804
|
+
issues.push({ path: "interchange.json", message: "required file missing from archive" });
|
|
2805
|
+
}
|
|
2806
|
+
const spaDistDir = envelope.sourceManifest.paths.spaDistDir.replace(/\\/g, "/");
|
|
2807
|
+
const spaIndexPath = `${spaDistDir}/index.html`;
|
|
2808
|
+
if (!entries.has(spaIndexPath)) {
|
|
2809
|
+
issues.push({ path: spaIndexPath, message: "required file missing from archive" });
|
|
2810
|
+
}
|
|
2811
|
+
for (const entryPath of envelope.entries) {
|
|
2812
|
+
if (!entries.has(entryPath)) {
|
|
2813
|
+
issues.push({
|
|
2814
|
+
path: entryPath,
|
|
2815
|
+
message: "listed in manifest.entries but missing from archive"
|
|
2816
|
+
});
|
|
2817
|
+
}
|
|
2818
|
+
}
|
|
2819
|
+
if (issues.length) return { ok: false, issues };
|
|
2820
|
+
let interchangeRaw;
|
|
2821
|
+
try {
|
|
2822
|
+
interchangeRaw = JSON.parse(entryToUtf8(interchangeData));
|
|
2823
|
+
} catch {
|
|
2824
|
+
return {
|
|
2825
|
+
ok: false,
|
|
2826
|
+
issues: [{ path: "interchange.json", message: "invalid JSON" }]
|
|
2827
|
+
};
|
|
2828
|
+
}
|
|
2829
|
+
const interchangeParsed = parseLessonkitInterchange2(interchangeRaw);
|
|
2830
|
+
if (!interchangeParsed.ok) {
|
|
2831
|
+
return {
|
|
2832
|
+
ok: false,
|
|
2833
|
+
issues: interchangeParsed.issues.map((i) => ({
|
|
2834
|
+
path: `interchange.${i.path ?? ""}`.replace(/\.$/, ""),
|
|
2835
|
+
message: i.message
|
|
2836
|
+
}))
|
|
2837
|
+
};
|
|
2838
|
+
}
|
|
2839
|
+
const interchange = interchangeParsed.data;
|
|
2840
|
+
const interchangeCourseId = interchange.course?.id;
|
|
2841
|
+
if (!interchangeCourseId) {
|
|
2842
|
+
issues.push({
|
|
2843
|
+
path: "interchange.course.id",
|
|
2844
|
+
message: "missing course id in interchange"
|
|
2845
|
+
});
|
|
2846
|
+
} else if (envelope.sourceManifest.course.courseId !== interchangeCourseId) {
|
|
2847
|
+
issues.push({
|
|
2848
|
+
path: "sourceManifest.course.courseId",
|
|
2849
|
+
message: `does not match interchange.course.id (${interchangeCourseId})`
|
|
2850
|
+
});
|
|
2851
|
+
}
|
|
2852
|
+
if (issues.length) return { ok: false, issues };
|
|
2853
|
+
const blockTreeData = entries.get("block-tree.json");
|
|
2854
|
+
if (blockTreeData) {
|
|
2855
|
+
let blockTreeRaw;
|
|
2856
|
+
try {
|
|
2857
|
+
blockTreeRaw = JSON.parse(entryToUtf8(blockTreeData));
|
|
2858
|
+
} catch {
|
|
2859
|
+
return {
|
|
2860
|
+
ok: false,
|
|
2861
|
+
issues: [{ path: "block-tree.json", message: "invalid JSON" }]
|
|
2862
|
+
};
|
|
2863
|
+
}
|
|
2864
|
+
const blockTree = blockTreeRaw;
|
|
2865
|
+
if (Array.isArray(blockTree?.blocks)) {
|
|
2866
|
+
const blockTreeIssues = validateBlockTreeIds(blockTree);
|
|
2867
|
+
if (blockTreeIssues.length) {
|
|
2868
|
+
return {
|
|
2869
|
+
ok: false,
|
|
2870
|
+
issues: blockTreeIssues.map((issue) => ({
|
|
2871
|
+
path: `block-tree.${issue.path}`,
|
|
2872
|
+
message: issue.message
|
|
2873
|
+
}))
|
|
2874
|
+
};
|
|
2875
|
+
}
|
|
2876
|
+
}
|
|
2877
|
+
}
|
|
2878
|
+
return {
|
|
2879
|
+
ok: true,
|
|
2880
|
+
envelope,
|
|
2881
|
+
interchange
|
|
2882
|
+
};
|
|
2883
|
+
}
|
|
2884
|
+
function validateLkcourse(archivePath) {
|
|
2885
|
+
const read = readZip(archivePath);
|
|
2886
|
+
if (!read.ok) return read;
|
|
2887
|
+
return validateLkcourseArchiveEntries(read.entries, archivePath);
|
|
2888
|
+
}
|
|
2889
|
+
|
|
2890
|
+
// src/lkcourse/import.ts
|
|
2891
|
+
import { access as access3, cp as cp2, mkdir as mkdir4, mkdtemp as mkdtemp3, readdir as readdir3, rename as rename2, rm as rm4, writeFile as writeFile2 } from "fs/promises";
|
|
2892
|
+
import { dirname as dirname5, join as join12, resolve as resolve9 } from "path";
|
|
2893
|
+
var IMPORT_ARTIFACTS = ["lessonkit.json", "dist"];
|
|
2894
|
+
async function pathExists2(path) {
|
|
2895
|
+
try {
|
|
2896
|
+
await access3(path);
|
|
2897
|
+
return true;
|
|
2898
|
+
} catch {
|
|
2899
|
+
return false;
|
|
2900
|
+
}
|
|
2901
|
+
}
|
|
2902
|
+
async function renameOrCopy2(from, to, opts) {
|
|
2903
|
+
const renameFn = opts?.renameFn ?? rename2;
|
|
2904
|
+
try {
|
|
2905
|
+
await renameFn(from, to);
|
|
2906
|
+
} catch (err) {
|
|
2907
|
+
const code = err && typeof err === "object" && "code" in err ? String(err.code) : "";
|
|
2908
|
+
if (code !== "EXDEV") throw err;
|
|
2909
|
+
await cp2(from, to, { recursive: true });
|
|
2910
|
+
await rm4(from, { recursive: true, force: true });
|
|
2911
|
+
}
|
|
2912
|
+
}
|
|
2913
|
+
async function writeImportTree(stagingDir, manifest, entries, spaDistDir) {
|
|
2914
|
+
let fileCount = 0;
|
|
2915
|
+
await writeFile2(
|
|
2916
|
+
join12(stagingDir, "lessonkit.json"),
|
|
2917
|
+
`${JSON.stringify(manifest, null, 2)}
|
|
2918
|
+
`,
|
|
2919
|
+
"utf8"
|
|
2920
|
+
);
|
|
2921
|
+
fileCount += 1;
|
|
2922
|
+
for (const [entryPath, data] of entries) {
|
|
2923
|
+
const normalized = entryPath.replace(/\\/g, "/");
|
|
2924
|
+
if (!normalized.startsWith(`${spaDistDir}/`)) continue;
|
|
2925
|
+
const relativeUnderSpa = normalized.slice(spaDistDir.length + 1);
|
|
2926
|
+
const outPath = join12(stagingDir, spaDistDir, relativeUnderSpa);
|
|
2927
|
+
const resolvedOut = resolve9(outPath);
|
|
2928
|
+
assertRealPathUnderRoot(stagingDir, resolvedOut);
|
|
2929
|
+
if (!isSafeZipEntryPath(join12(spaDistDir, relativeUnderSpa))) {
|
|
2930
|
+
throw new Error(`unsafe extraction path: ${entryPath}`);
|
|
2931
|
+
}
|
|
2932
|
+
await mkdir4(dirname5(resolvedOut), { recursive: true });
|
|
2933
|
+
await writeFile2(resolvedOut, data);
|
|
2934
|
+
fileCount += 1;
|
|
2935
|
+
}
|
|
2936
|
+
return fileCount;
|
|
2937
|
+
}
|
|
2938
|
+
async function backupImportArtifacts(targetDir) {
|
|
2939
|
+
const existing = [];
|
|
2940
|
+
for (const name of IMPORT_ARTIFACTS) {
|
|
2941
|
+
if (await pathExists2(join12(targetDir, name))) {
|
|
2942
|
+
existing.push(name);
|
|
2943
|
+
}
|
|
2944
|
+
}
|
|
2945
|
+
if (!existing.length) return void 0;
|
|
2946
|
+
const backupDir = await mkdtemp3(join12(targetDir, ".lkcourse-backup-"));
|
|
2947
|
+
for (const name of existing) {
|
|
2948
|
+
await renameOrCopy2(join12(targetDir, name), join12(backupDir, name));
|
|
2949
|
+
}
|
|
2950
|
+
return backupDir;
|
|
2951
|
+
}
|
|
2952
|
+
async function restoreImportBackup(targetDir, backupDir) {
|
|
2953
|
+
for (const name of IMPORT_ARTIFACTS) {
|
|
2954
|
+
const backupPath = join12(backupDir, name);
|
|
2955
|
+
if (!await pathExists2(backupPath)) continue;
|
|
2956
|
+
const destPath = join12(targetDir, name);
|
|
2957
|
+
if (await pathExists2(destPath)) {
|
|
2958
|
+
await rm4(destPath, { recursive: true, force: true });
|
|
2959
|
+
}
|
|
2960
|
+
await renameOrCopy2(backupPath, destPath);
|
|
2961
|
+
}
|
|
2962
|
+
}
|
|
2963
|
+
async function promoteImportStaging(stagingDir, targetDir) {
|
|
2964
|
+
const entries = await readdir3(stagingDir, { withFileTypes: true });
|
|
2965
|
+
for (const entry of entries) {
|
|
2966
|
+
const srcPath = join12(stagingDir, entry.name);
|
|
2967
|
+
const destPath = join12(targetDir, entry.name);
|
|
2968
|
+
if (entry.isDirectory()) {
|
|
2969
|
+
await cp2(srcPath, destPath, { recursive: true, force: true });
|
|
2970
|
+
} else if (entry.isFile()) {
|
|
2971
|
+
await mkdir4(dirname5(destPath), { recursive: true });
|
|
2972
|
+
await cp2(srcPath, destPath);
|
|
2973
|
+
}
|
|
2974
|
+
}
|
|
2975
|
+
}
|
|
2976
|
+
var promoteImportStagingImpl = promoteImportStaging;
|
|
2977
|
+
async function importLkcourse(options) {
|
|
2978
|
+
const archivePath = resolve9(options.archivePath);
|
|
2979
|
+
const targetDir = resolve9(options.targetDir);
|
|
2980
|
+
const validated = validateLkcourse(archivePath);
|
|
2981
|
+
if (!validated.ok) return validated;
|
|
2982
|
+
const { envelope, interchange } = validated;
|
|
2983
|
+
const manifest = envelope.sourceManifest;
|
|
2984
|
+
const spaDistDir = manifest.paths.spaDistDir.replace(/\\/g, "/");
|
|
2985
|
+
try {
|
|
2986
|
+
await mkdir4(targetDir, { recursive: true });
|
|
2987
|
+
assertRealPathUnderRoot(targetDir, targetDir);
|
|
2988
|
+
} catch (err) {
|
|
2989
|
+
return {
|
|
2990
|
+
ok: false,
|
|
2991
|
+
issues: [
|
|
2992
|
+
{
|
|
2993
|
+
path: targetDir,
|
|
2994
|
+
message: err instanceof Error ? err.message : String(err)
|
|
2995
|
+
}
|
|
2996
|
+
]
|
|
2997
|
+
};
|
|
2998
|
+
}
|
|
2999
|
+
const read = readZip(archivePath);
|
|
3000
|
+
if (!read.ok) return read;
|
|
3001
|
+
let stagingDir;
|
|
3002
|
+
let backupDir;
|
|
3003
|
+
try {
|
|
3004
|
+
stagingDir = await mkdtemp3(join12(targetDir, ".lkcourse-import-"));
|
|
3005
|
+
const fileCount = await writeImportTree(stagingDir, manifest, read.entries, spaDistDir);
|
|
3006
|
+
backupDir = await backupImportArtifacts(targetDir);
|
|
3007
|
+
try {
|
|
3008
|
+
await promoteImportStagingImpl(stagingDir, targetDir);
|
|
3009
|
+
} catch (promoteError) {
|
|
3010
|
+
if (backupDir) {
|
|
3011
|
+
await restoreImportBackup(targetDir, backupDir);
|
|
3012
|
+
}
|
|
3013
|
+
throw promoteError;
|
|
3014
|
+
}
|
|
3015
|
+
if (backupDir) {
|
|
3016
|
+
await rm4(backupDir, { recursive: true, force: true }).catch(() => void 0);
|
|
3017
|
+
backupDir = void 0;
|
|
3018
|
+
}
|
|
3019
|
+
await rm4(stagingDir, { recursive: true, force: true });
|
|
3020
|
+
stagingDir = void 0;
|
|
3021
|
+
return {
|
|
3022
|
+
ok: true,
|
|
3023
|
+
targetDir,
|
|
3024
|
+
manifest,
|
|
3025
|
+
interchange,
|
|
3026
|
+
fileCount
|
|
3027
|
+
};
|
|
3028
|
+
} catch (err) {
|
|
3029
|
+
if (backupDir) {
|
|
3030
|
+
await restoreImportBackup(targetDir, backupDir).catch(() => void 0);
|
|
3031
|
+
await rm4(backupDir, { recursive: true, force: true }).catch(() => void 0);
|
|
3032
|
+
}
|
|
3033
|
+
if (stagingDir) {
|
|
3034
|
+
await rm4(stagingDir, { recursive: true, force: true }).catch(() => void 0);
|
|
3035
|
+
}
|
|
3036
|
+
return {
|
|
3037
|
+
ok: false,
|
|
3038
|
+
issues: [
|
|
3039
|
+
{
|
|
3040
|
+
path: targetDir,
|
|
3041
|
+
message: err instanceof Error ? err.message : String(err)
|
|
3042
|
+
}
|
|
3043
|
+
]
|
|
3044
|
+
};
|
|
3045
|
+
}
|
|
3046
|
+
}
|
|
1766
3047
|
export {
|
|
1767
3048
|
LESSONKIT_TELEMETRY_EVENTS,
|
|
3049
|
+
assertSpaDistContentsSafe,
|
|
1768
3050
|
assessmentDescriptorToLxpack,
|
|
1769
3051
|
buildLessonkitProject,
|
|
1770
3052
|
buildStagingPackage,
|
|
1771
3053
|
descriptorToInterchange,
|
|
1772
3054
|
ensureOutDirParent,
|
|
3055
|
+
escapeShellText,
|
|
3056
|
+
exportLkcourse,
|
|
1773
3057
|
extractAssessments,
|
|
3058
|
+
extractBlockTree,
|
|
3059
|
+
importLkcourse,
|
|
1774
3060
|
lessonkitInterchangeSchema,
|
|
1775
3061
|
loadLessonkitManifestFromFile,
|
|
1776
3062
|
mapLessonkitIds,
|
|
@@ -1778,8 +3064,9 @@ export {
|
|
|
1778
3064
|
mapLessonkitTelemetryToLxpack,
|
|
1779
3065
|
materializeLessonkitProject2 as materializeLessonkitProject,
|
|
1780
3066
|
packageLessonkitCourse,
|
|
1781
|
-
parseLessonkitInterchange,
|
|
3067
|
+
parseLessonkitInterchange3 as parseLessonkitInterchange,
|
|
1782
3068
|
parseLessonkitManifest,
|
|
3069
|
+
parseLkcourseEnvelope,
|
|
1783
3070
|
promoteStagingToOutDir,
|
|
1784
3071
|
remapArtifactPaths,
|
|
1785
3072
|
resolveSafePackageOutputOverride,
|
|
@@ -1789,6 +3076,8 @@ export {
|
|
|
1789
3076
|
validateDescriptor,
|
|
1790
3077
|
validateDescriptorForTarget,
|
|
1791
3078
|
validateLessonkitProject,
|
|
3079
|
+
validateLkcourse,
|
|
3080
|
+
validateLkcourseArchiveEntries,
|
|
1792
3081
|
validatePackageInputs,
|
|
1793
3082
|
validateProjectPaths,
|
|
1794
3083
|
validateReactManifestParity,
|