@lessonkit/lxpack 1.3.1 → 1.5.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 +36 -8
- package/dist/bridge.cjs +160 -41
- package/dist/bridge.d.cts +30 -11
- package/dist/bridge.d.ts +30 -11
- package/dist/bridge.js +96 -27
- package/dist/chunk-HTZR4CF3.js +94 -0
- package/dist/index.cjs +564 -162
- package/dist/index.d.cts +29 -14
- package/dist/index.d.ts +29 -14
- package/dist/index.js +514 -132
- package/dist/telemetry-0fIWoomS.d.cts +17 -0
- package/dist/telemetry-0fIWoomS.d.ts +17 -0
- package/package.json +10 -10
- 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.cjs
CHANGED
|
@@ -36,6 +36,7 @@ __export(index_exports, {
|
|
|
36
36
|
buildStagingPackage: () => buildStagingPackage,
|
|
37
37
|
descriptorToInterchange: () => descriptorToInterchange,
|
|
38
38
|
ensureOutDirParent: () => ensureOutDirParent,
|
|
39
|
+
escapeShellText: () => escapeShellText,
|
|
39
40
|
extractAssessments: () => extractAssessments,
|
|
40
41
|
lessonkitInterchangeSchema: () => import_validators2.lessonkitInterchangeSchema,
|
|
41
42
|
loadLessonkitManifestFromFile: () => loadLessonkitManifestFromFile,
|
|
@@ -375,6 +376,10 @@ var validateMcqLike = (assessment, path, issues) => {
|
|
|
375
376
|
} else if (trimmedChoices.length && !trimmedChoices.includes(assessment.answer.trim())) {
|
|
376
377
|
issues.push({ path: `${path}.answer`, message: "answer must match a choice" });
|
|
377
378
|
}
|
|
379
|
+
const uniqueChoices = new Set(trimmedChoices);
|
|
380
|
+
if (trimmedChoices.length !== uniqueChoices.size) {
|
|
381
|
+
issues.push({ path: `${path}.choices`, message: "choices must be unique" });
|
|
382
|
+
}
|
|
378
383
|
};
|
|
379
384
|
function countStarDelimitedBlanks(template) {
|
|
380
385
|
const matches = template.match(/\*[^*]+\*/g);
|
|
@@ -400,8 +405,30 @@ var ASSESSMENT_VALIDATORS = {
|
|
|
400
405
|
}
|
|
401
406
|
},
|
|
402
407
|
fillInBlanks: (assessment, path, issues) => {
|
|
403
|
-
if (assessment.kind
|
|
408
|
+
if (assessment.kind !== "fillInBlanks") return;
|
|
409
|
+
if (!assessment.template?.trim()) {
|
|
404
410
|
issues.push({ path: `${path}.template`, message: "template is required for fillInBlanks" });
|
|
411
|
+
return;
|
|
412
|
+
}
|
|
413
|
+
const templateBlankCount = countStarDelimitedBlanks(assessment.template);
|
|
414
|
+
if (templateBlankCount === 0) {
|
|
415
|
+
issues.push({
|
|
416
|
+
path: `${path}.template`,
|
|
417
|
+
message: "template must include at least one blank wrapped in asterisks for fillInBlanks"
|
|
418
|
+
});
|
|
419
|
+
}
|
|
420
|
+
const explicitBlanks = assessment.blanks?.map((b) => ({ id: b.id?.trim() ?? "", answer: b.answer?.trim() ?? "" })).filter((b) => b.id.length > 0 && b.answer.length > 0) ?? [];
|
|
421
|
+
if (assessment.blanks !== void 0 && explicitBlanks.length === 0) {
|
|
422
|
+
issues.push({
|
|
423
|
+
path: `${path}.blanks`,
|
|
424
|
+
message: "blanks must include at least one entry with non-empty id and answer"
|
|
425
|
+
});
|
|
426
|
+
}
|
|
427
|
+
if (explicitBlanks.length > 0 && explicitBlanks.length !== templateBlankCount) {
|
|
428
|
+
issues.push({
|
|
429
|
+
path: `${path}.blanks`,
|
|
430
|
+
message: `blanks length (${explicitBlanks.length}) must match template blank count (${templateBlankCount})`
|
|
431
|
+
});
|
|
405
432
|
}
|
|
406
433
|
},
|
|
407
434
|
findHotspot: (assessment, path, issues) => {
|
|
@@ -599,27 +626,47 @@ function validateCourseDescriptor(input) {
|
|
|
599
626
|
}
|
|
600
627
|
|
|
601
628
|
// src/assessments.ts
|
|
629
|
+
function escapeShellText(text) {
|
|
630
|
+
return text.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """).replace(/'/g, "'");
|
|
631
|
+
}
|
|
632
|
+
function decodeShellEntities(text) {
|
|
633
|
+
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)));
|
|
634
|
+
}
|
|
635
|
+
function containsUnsafeShellMarkup(text) {
|
|
636
|
+
const decoded = decodeShellEntities(text);
|
|
637
|
+
return /<\/script/i.test(decoded) || /<!--/.test(decoded) || /</.test(decoded);
|
|
638
|
+
}
|
|
639
|
+
function sanitizeShellField(text) {
|
|
640
|
+
if (containsUnsafeShellMarkup(text)) return null;
|
|
641
|
+
return escapeShellText(text);
|
|
642
|
+
}
|
|
602
643
|
function slugChoiceId(text, index) {
|
|
603
644
|
const base = text.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "").slice(0, 32);
|
|
604
645
|
const stem = base.length ? base : "choice";
|
|
605
646
|
return `${stem}-${index + 1}`;
|
|
606
647
|
}
|
|
607
648
|
function mcqToLxpack(assessment) {
|
|
649
|
+
const checkId = sanitizeShellField(assessment.checkId);
|
|
650
|
+
const prompt = sanitizeShellField(assessment.question);
|
|
651
|
+
if (!checkId || !prompt) return null;
|
|
608
652
|
const choices = assessment.choices.map((text, index) => {
|
|
653
|
+
const sanitizedText = sanitizeShellField(text);
|
|
654
|
+
if (!sanitizedText) return null;
|
|
609
655
|
const id = slugChoiceId(text, index);
|
|
610
656
|
return {
|
|
611
657
|
id,
|
|
612
|
-
text,
|
|
658
|
+
text: sanitizedText,
|
|
613
659
|
correct: text === assessment.answer
|
|
614
660
|
};
|
|
615
661
|
});
|
|
662
|
+
if (choices.some((choice) => choice === null)) return null;
|
|
616
663
|
return {
|
|
617
|
-
id:
|
|
664
|
+
id: checkId,
|
|
618
665
|
passingScore: assessment.passingScore ?? 1,
|
|
619
666
|
questions: [
|
|
620
667
|
{
|
|
621
668
|
id: "q1",
|
|
622
|
-
prompt
|
|
669
|
+
prompt,
|
|
623
670
|
choices
|
|
624
671
|
}
|
|
625
672
|
]
|
|
@@ -642,15 +689,8 @@ function assessmentDescriptorToLxpack(assessment) {
|
|
|
642
689
|
if (kind === "fillInBlanks") {
|
|
643
690
|
return null;
|
|
644
691
|
}
|
|
645
|
-
if (kind === "findHotspot"
|
|
646
|
-
return
|
|
647
|
-
kind: "mcq",
|
|
648
|
-
checkId: assessment.checkId,
|
|
649
|
-
question: assessment.question,
|
|
650
|
-
choices: [assessment.correctTargetId, "other"],
|
|
651
|
-
answer: assessment.correctTargetId,
|
|
652
|
-
passingScore: assessment.passingScore
|
|
653
|
-
});
|
|
692
|
+
if (kind === "findHotspot") {
|
|
693
|
+
return null;
|
|
654
694
|
}
|
|
655
695
|
if (kind === "findMultipleHotspots") {
|
|
656
696
|
return null;
|
|
@@ -664,6 +704,20 @@ function extractAssessments(descriptor) {
|
|
|
664
704
|
return (descriptor.assessments ?? []).map(assessmentDescriptorToLxpack).filter((a) => a !== null);
|
|
665
705
|
}
|
|
666
706
|
|
|
707
|
+
// src/descriptor/validateInjectableAssessments.ts
|
|
708
|
+
function validateInjectableAssessments(descriptor) {
|
|
709
|
+
const issues = [];
|
|
710
|
+
(descriptor.assessments ?? []).forEach((assessment, index) => {
|
|
711
|
+
if (assessmentDescriptorToLxpack(assessment) === null) {
|
|
712
|
+
issues.push({
|
|
713
|
+
path: `assessments[${index}]`,
|
|
714
|
+
message: `assessment kind "${assessment.kind ?? "mcq"}" (checkId "${assessment.checkId}") is not injected into LMS shell quizzes`
|
|
715
|
+
});
|
|
716
|
+
}
|
|
717
|
+
});
|
|
718
|
+
return issues;
|
|
719
|
+
}
|
|
720
|
+
|
|
667
721
|
// src/descriptor/validateForTarget.ts
|
|
668
722
|
var LMS_SHELL_TARGETS = /* @__PURE__ */ new Set([
|
|
669
723
|
"scorm12",
|
|
@@ -672,26 +726,34 @@ var LMS_SHELL_TARGETS = /* @__PURE__ */ new Set([
|
|
|
672
726
|
"xapi",
|
|
673
727
|
"cmi5"
|
|
674
728
|
]);
|
|
729
|
+
function appendActivityIriIssues(issues, descriptor, target) {
|
|
730
|
+
const hasXapiTracking = Boolean(descriptor.tracking?.xapi);
|
|
731
|
+
const requiresForTarget = target === "xapi" || target === "cmi5";
|
|
732
|
+
if (!hasXapiTracking && !requiresForTarget) return;
|
|
733
|
+
const activityIri = descriptor.tracking?.xapi?.activityIri?.trim();
|
|
734
|
+
const targetSuffix = target === "xapi" || target === "cmi5" ? ` for ${target} export targets` : " when tracking.xapi is configured";
|
|
735
|
+
if (!activityIri) {
|
|
736
|
+
issues.push({
|
|
737
|
+
path: "tracking.xapi.activityIri",
|
|
738
|
+
message: `tracking.xapi.activityIri is required${targetSuffix}`
|
|
739
|
+
});
|
|
740
|
+
return;
|
|
741
|
+
}
|
|
742
|
+
if (!/^https:\/\/.+/i.test(activityIri)) {
|
|
743
|
+
issues.push({
|
|
744
|
+
path: "tracking.xapi.activityIri",
|
|
745
|
+
message: `tracking.xapi.activityIri must be an HTTPS URL${targetSuffix}`
|
|
746
|
+
});
|
|
747
|
+
}
|
|
748
|
+
}
|
|
675
749
|
function validateDescriptorForExportTarget(descriptor, target) {
|
|
676
750
|
const issues = [];
|
|
677
|
-
|
|
678
|
-
const activityIri = descriptor.tracking?.xapi?.activityIri?.trim();
|
|
679
|
-
if (!activityIri) {
|
|
680
|
-
issues.push({
|
|
681
|
-
path: "course.tracking.xapi.activityIri",
|
|
682
|
-
message: "tracking.xapi.activityIri is required for xapi and cmi5 export targets"
|
|
683
|
-
});
|
|
684
|
-
}
|
|
685
|
-
}
|
|
751
|
+
appendActivityIriIssues(issues, descriptor, target);
|
|
686
752
|
if (LMS_SHELL_TARGETS.has(target)) {
|
|
687
|
-
(descriptor
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
message: `assessment kind "${assessment.kind ?? "mcq"}" (checkId "${assessment.checkId}") is not injected into LMS shell quizzes for target "${target}"`
|
|
692
|
-
});
|
|
693
|
-
}
|
|
694
|
-
});
|
|
753
|
+
issues.push(...validateInjectableAssessments(descriptor).map((issue) => ({
|
|
754
|
+
...issue,
|
|
755
|
+
message: `${issue.message} for target "${target}"`
|
|
756
|
+
})));
|
|
695
757
|
}
|
|
696
758
|
return issues;
|
|
697
759
|
}
|
|
@@ -723,16 +785,50 @@ function validateDescriptorForTarget(input, target) {
|
|
|
723
785
|
var import_node_fs2 = require("fs");
|
|
724
786
|
var import_node_path2 = require("path");
|
|
725
787
|
var SCANNABLE_EXTENSIONS = [".tsx", ".ts", ".jsx", ".js"];
|
|
726
|
-
function collectSourceUnderSrc(projectRoot) {
|
|
788
|
+
function collectSourceUnderSrc(projectRoot, issues) {
|
|
727
789
|
const srcDir = (0, import_node_path2.join)(projectRoot, "src");
|
|
728
790
|
if (!(0, import_node_fs2.existsSync)(srcDir)) return [];
|
|
729
791
|
const results = [];
|
|
730
792
|
const walk = (dir) => {
|
|
731
793
|
for (const entry of (0, import_node_fs2.readdirSync)(dir)) {
|
|
732
794
|
const abs = (0, import_node_path2.join)(dir, entry);
|
|
733
|
-
|
|
795
|
+
let stat2;
|
|
796
|
+
try {
|
|
797
|
+
stat2 = (0, import_node_fs2.lstatSync)(abs);
|
|
798
|
+
} catch {
|
|
799
|
+
continue;
|
|
800
|
+
}
|
|
801
|
+
if (stat2.isSymbolicLink()) {
|
|
802
|
+
issues.push({
|
|
803
|
+
path: (0, import_node_path2.relative)(projectRoot, abs),
|
|
804
|
+
message: `Source tree contains symlink (rejected for parity scan): ${(0, import_node_path2.relative)(projectRoot, abs)}`,
|
|
805
|
+
severity: "error"
|
|
806
|
+
});
|
|
807
|
+
continue;
|
|
808
|
+
}
|
|
809
|
+
if (stat2.isDirectory()) {
|
|
810
|
+
try {
|
|
811
|
+
assertRealPathUnderRoot(projectRoot, abs);
|
|
812
|
+
} catch {
|
|
813
|
+
issues.push({
|
|
814
|
+
path: (0, import_node_path2.relative)(projectRoot, abs),
|
|
815
|
+
message: `Source directory escapes project root: ${(0, import_node_path2.relative)(projectRoot, abs)}`,
|
|
816
|
+
severity: "error"
|
|
817
|
+
});
|
|
818
|
+
continue;
|
|
819
|
+
}
|
|
734
820
|
walk(abs);
|
|
735
821
|
} else if (SCANNABLE_EXTENSIONS.some((ext) => entry.endsWith(ext))) {
|
|
822
|
+
try {
|
|
823
|
+
assertRealPathUnderRoot(projectRoot, abs);
|
|
824
|
+
} catch {
|
|
825
|
+
issues.push({
|
|
826
|
+
path: (0, import_node_path2.relative)(projectRoot, abs),
|
|
827
|
+
message: `Source file escapes project root: ${(0, import_node_path2.relative)(projectRoot, abs)}`,
|
|
828
|
+
severity: "error"
|
|
829
|
+
});
|
|
830
|
+
continue;
|
|
831
|
+
}
|
|
736
832
|
results.push((0, import_node_path2.relative)(projectRoot, abs));
|
|
737
833
|
}
|
|
738
834
|
}
|
|
@@ -740,20 +836,69 @@ function collectSourceUnderSrc(projectRoot) {
|
|
|
740
836
|
walk(srcDir);
|
|
741
837
|
return results;
|
|
742
838
|
}
|
|
743
|
-
function readAppSources(projectRoot, appSources) {
|
|
744
|
-
return appSources.map((rel) =>
|
|
839
|
+
function readAppSources(projectRoot, appSources, issues, customSourcesProvided) {
|
|
840
|
+
return appSources.map((rel) => {
|
|
841
|
+
if (!isSafeRelativeSpaPath(rel)) {
|
|
842
|
+
if (customSourcesProvided) {
|
|
843
|
+
issues.push({
|
|
844
|
+
path: rel,
|
|
845
|
+
message: `Unsafe appSources path skipped: ${rel}`,
|
|
846
|
+
severity: "warning"
|
|
847
|
+
});
|
|
848
|
+
}
|
|
849
|
+
return null;
|
|
850
|
+
}
|
|
851
|
+
const abs = (0, import_node_path2.join)(projectRoot, rel);
|
|
852
|
+
try {
|
|
853
|
+
assertRealPathUnderRoot(projectRoot, abs);
|
|
854
|
+
if ((0, import_node_fs2.existsSync)(abs) && (0, import_node_fs2.lstatSync)(abs).isSymbolicLink()) {
|
|
855
|
+
issues.push({
|
|
856
|
+
path: rel,
|
|
857
|
+
message: `appSources path is a symlink: ${rel}`,
|
|
858
|
+
severity: "error"
|
|
859
|
+
});
|
|
860
|
+
return null;
|
|
861
|
+
}
|
|
862
|
+
} catch {
|
|
863
|
+
issues.push({
|
|
864
|
+
path: rel,
|
|
865
|
+
message: `appSources path escapes project root: ${rel}`,
|
|
866
|
+
severity: "error"
|
|
867
|
+
});
|
|
868
|
+
return null;
|
|
869
|
+
}
|
|
870
|
+
if (!(0, import_node_fs2.existsSync)(abs)) return null;
|
|
871
|
+
return (0, import_node_fs2.readFileSync)(abs, "utf8");
|
|
872
|
+
}).filter((content) => content != null).join("\n");
|
|
745
873
|
}
|
|
746
874
|
function stripComments(source) {
|
|
747
875
|
return source.replace(/\/\*[\s\S]*?\*\//g, " ").replace(/\/\/[^\n]*/g, " ");
|
|
748
876
|
}
|
|
749
|
-
function
|
|
750
|
-
return [
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
877
|
+
function maskUnrelatedStringLiterals(source) {
|
|
878
|
+
return source.replace(/(["'`])(?:\\.|(?!\1).)*\1/g, (match, _quote, offset, full) => {
|
|
879
|
+
const before = full.slice(Math.max(0, offset - 24), offset);
|
|
880
|
+
if (/\b(?:courseId|checkId|lessonId)\s*=\s*$/.test(before)) {
|
|
881
|
+
return match;
|
|
882
|
+
}
|
|
883
|
+
return '""';
|
|
884
|
+
});
|
|
885
|
+
}
|
|
886
|
+
function idPropPresent(source, prop, id) {
|
|
887
|
+
const stripped = stripComments(source);
|
|
888
|
+
const masked = maskUnrelatedStringLiterals(stripped);
|
|
889
|
+
return jsxPropRegex(prop, id).test(masked);
|
|
890
|
+
}
|
|
891
|
+
function escapeRegExp(value) {
|
|
892
|
+
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
893
|
+
}
|
|
894
|
+
function jsxPropRegex(prop, id) {
|
|
895
|
+
const escapedId = escapeRegExp(id);
|
|
896
|
+
return new RegExp(
|
|
897
|
+
`(?<![A-Za-z0-9_$])${prop}\\s*=\\s*(?:"${escapedId}"|'${escapedId}'|\\{\\s*["'\`]${escapedId}["'\`]\\s*\\}|\\{\\s*\`${escapedId}\`\\s*\\})`
|
|
898
|
+
);
|
|
899
|
+
}
|
|
900
|
+
function maskStringLiterals(source) {
|
|
901
|
+
return source.replace(/(["'`])(?:\\.|(?!\1).)*\1/g, '""');
|
|
757
902
|
}
|
|
758
903
|
function extractStringConstants(source) {
|
|
759
904
|
const stripped = stripComments(source);
|
|
@@ -764,7 +909,9 @@ function extractStringConstants(source) {
|
|
|
764
909
|
}
|
|
765
910
|
return map;
|
|
766
911
|
}
|
|
767
|
-
function idUsedViaConstant(
|
|
912
|
+
function idUsedViaConstant(source, prop, id, constants) {
|
|
913
|
+
const stripped = stripComments(source);
|
|
914
|
+
const masked = maskStringLiterals(stripped);
|
|
768
915
|
for (const [name, value] of constants) {
|
|
769
916
|
if (value !== id) continue;
|
|
770
917
|
const jsxPatterns = [
|
|
@@ -773,51 +920,93 @@ function idUsedViaConstant(stripped, prop, id, constants) {
|
|
|
773
920
|
`${prop}={${name} }`,
|
|
774
921
|
`${prop}={ ${name}}`
|
|
775
922
|
];
|
|
776
|
-
if (jsxPatterns.some((p) =>
|
|
777
|
-
const objPatterns = [`${prop}: ${name}`, `${prop}:${name}`];
|
|
778
|
-
if (objPatterns.some((p) => stripped.includes(p))) return true;
|
|
923
|
+
if (jsxPatterns.some((p) => masked.includes(p))) return true;
|
|
779
924
|
}
|
|
780
925
|
return false;
|
|
781
926
|
}
|
|
782
|
-
function
|
|
927
|
+
function lessonIdInDataLiteral(source, lessonId) {
|
|
783
928
|
const stripped = stripComments(source);
|
|
784
|
-
|
|
785
|
-
return
|
|
929
|
+
const escaped = escapeRegExp(lessonId);
|
|
930
|
+
return new RegExp(`\\bid\\s*:\\s*["'\`]${escaped}["'\`]`).test(stripped);
|
|
786
931
|
}
|
|
787
|
-
function
|
|
932
|
+
function lessonIdPresent(source, lessonId) {
|
|
933
|
+
if (idPropPresent(source, "lessonId", lessonId)) return true;
|
|
934
|
+
if (idUsedViaConstant(source, "lessonId", lessonId, extractStringConstants(source))) return true;
|
|
935
|
+
return lessonIdInDataLiteral(source, lessonId);
|
|
936
|
+
}
|
|
937
|
+
function courseConfigCourseIdPresent(source, courseId) {
|
|
788
938
|
const stripped = stripComments(source);
|
|
789
|
-
|
|
790
|
-
|
|
939
|
+
const escaped = escapeRegExp(courseId);
|
|
940
|
+
const literalPattern = new RegExp(
|
|
941
|
+
`(?<![A-Za-z0-9_$])courseId\\s*:\\s*(?:"${escaped}"|'${escaped}')`
|
|
942
|
+
);
|
|
943
|
+
if (literalPattern.test(stripped)) return true;
|
|
944
|
+
return idUsedViaConstant(source, "courseId", courseId, extractStringConstants(source));
|
|
945
|
+
}
|
|
946
|
+
function courseIdPresent(source, courseId) {
|
|
947
|
+
if (idPropPresent(source, "courseId", courseId)) return true;
|
|
948
|
+
if (idUsedViaConstant(source, "courseId", courseId, extractStringConstants(source))) return true;
|
|
949
|
+
return courseConfigCourseIdPresent(source, courseId);
|
|
950
|
+
}
|
|
951
|
+
function checkIdPresent(source, checkId) {
|
|
952
|
+
if (idPropPresent(source, "checkId", checkId)) return true;
|
|
953
|
+
return idUsedViaConstant(source, "checkId", checkId, extractStringConstants(source));
|
|
954
|
+
}
|
|
955
|
+
var ID_SYNC_DOC = "https://lessonkit.readthedocs.io/en/latest/guides/react-developers/quickstart.html#keep-react-ids-in-sync-with-lessonkitjson";
|
|
956
|
+
function parityHint(message) {
|
|
957
|
+
return `${message} See ${ID_SYNC_DOC}`;
|
|
791
958
|
}
|
|
792
959
|
function validateReactManifestParity(opts) {
|
|
793
|
-
const
|
|
794
|
-
const
|
|
960
|
+
const issues = [];
|
|
961
|
+
const customSourcesProvided = opts.appSources !== void 0;
|
|
962
|
+
const appSources = opts.appSources ?? collectSourceUnderSrc(opts.projectRoot, issues);
|
|
963
|
+
const source = readAppSources(
|
|
964
|
+
opts.projectRoot,
|
|
965
|
+
appSources,
|
|
966
|
+
issues,
|
|
967
|
+
customSourcesProvided
|
|
968
|
+
);
|
|
795
969
|
const hasDescriptorIds = Boolean(opts.descriptor.courseId) || (opts.descriptor.assessments?.length ?? 0) > 0;
|
|
796
970
|
if (!source.trim()) {
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
];
|
|
971
|
+
issues.push({
|
|
972
|
+
path: appSources.length > 0 ? appSources.join(", ") : "src/",
|
|
973
|
+
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",
|
|
974
|
+
severity: hasDescriptorIds ? "error" : "warning"
|
|
975
|
+
});
|
|
976
|
+
return issues;
|
|
804
977
|
}
|
|
805
|
-
const issues = [];
|
|
806
978
|
const courseId = opts.descriptor.courseId;
|
|
807
979
|
if (!courseIdPresent(source, courseId)) {
|
|
808
980
|
issues.push({
|
|
809
981
|
path: "course.courseId",
|
|
810
|
-
message:
|
|
982
|
+
message: parityHint(
|
|
983
|
+
`React app source does not reference courseId="${courseId}" from lessonkit.json.`
|
|
984
|
+
),
|
|
811
985
|
severity: "error"
|
|
812
986
|
});
|
|
813
987
|
}
|
|
988
|
+
for (const lesson of opts.descriptor.lessons ?? []) {
|
|
989
|
+
const lessonId = lesson.id;
|
|
990
|
+
if (!lessonId) continue;
|
|
991
|
+
if (!lessonIdPresent(source, lessonId)) {
|
|
992
|
+
issues.push({
|
|
993
|
+
path: `lessons.id:${lessonId}`,
|
|
994
|
+
message: parityHint(
|
|
995
|
+
`React app source missing lessonId="${lessonId}" declared in lessonkit.json.`
|
|
996
|
+
),
|
|
997
|
+
severity: "error"
|
|
998
|
+
});
|
|
999
|
+
}
|
|
1000
|
+
}
|
|
814
1001
|
for (const assessment of opts.descriptor.assessments ?? []) {
|
|
815
1002
|
const checkId = assessment.checkId;
|
|
816
1003
|
if (!checkId) continue;
|
|
817
1004
|
if (!checkIdPresent(source, checkId)) {
|
|
818
1005
|
issues.push({
|
|
819
1006
|
path: `assessments.checkId:${checkId}`,
|
|
820
|
-
message:
|
|
1007
|
+
message: parityHint(
|
|
1008
|
+
`React app source missing checkId="${checkId}" declared in lessonkit.json.`
|
|
1009
|
+
),
|
|
821
1010
|
severity: "error"
|
|
822
1011
|
});
|
|
823
1012
|
}
|
|
@@ -827,7 +1016,13 @@ function validateReactManifestParity(opts) {
|
|
|
827
1016
|
|
|
828
1017
|
// src/validateProjectPaths.ts
|
|
829
1018
|
var import_node_path3 = require("path");
|
|
830
|
-
|
|
1019
|
+
var RESERVED_OUTPUT_SEGMENTS = /* @__PURE__ */ new Set([".git", "node_modules", ".github"]);
|
|
1020
|
+
function isReservedOutputPath(value) {
|
|
1021
|
+
const normalized = value.replace(/\\/g, "/").replace(/^\/+|\/+$/g, "");
|
|
1022
|
+
const segments = normalized.split("/").filter(Boolean);
|
|
1023
|
+
return segments.some((segment) => RESERVED_OUTPUT_SEGMENTS.has(segment));
|
|
1024
|
+
}
|
|
1025
|
+
function validatePathField(value, fieldPath, projectRoot, issues, options) {
|
|
831
1026
|
if (!isSafeRelativeSpaPath(value)) {
|
|
832
1027
|
issues.push({
|
|
833
1028
|
path: fieldPath,
|
|
@@ -835,6 +1030,13 @@ function validatePathField(value, fieldPath, projectRoot, issues) {
|
|
|
835
1030
|
});
|
|
836
1031
|
return;
|
|
837
1032
|
}
|
|
1033
|
+
if (options?.rejectReserved && isReservedOutputPath(value)) {
|
|
1034
|
+
issues.push({
|
|
1035
|
+
path: fieldPath,
|
|
1036
|
+
message: "path must not target reserved directories (.git, node_modules, .github)"
|
|
1037
|
+
});
|
|
1038
|
+
return;
|
|
1039
|
+
}
|
|
838
1040
|
try {
|
|
839
1041
|
assertRealPathUnderRoot(projectRoot, (0, import_node_path3.resolve)(projectRoot, value));
|
|
840
1042
|
} catch {
|
|
@@ -851,10 +1053,14 @@ function validateProjectPaths(projectRoot, paths) {
|
|
|
851
1053
|
validatePathField(paths.spaDistDir.trim(), "paths.spaDistDir", root, issues);
|
|
852
1054
|
}
|
|
853
1055
|
if (paths.lxpackOutDir?.trim()) {
|
|
854
|
-
validatePathField(paths.lxpackOutDir.trim(), "paths.lxpackOutDir", root, issues
|
|
1056
|
+
validatePathField(paths.lxpackOutDir.trim(), "paths.lxpackOutDir", root, issues, {
|
|
1057
|
+
rejectReserved: true
|
|
1058
|
+
});
|
|
855
1059
|
}
|
|
856
1060
|
if (paths.outputBaseDir?.trim()) {
|
|
857
|
-
validatePathField(paths.outputBaseDir.trim(), "paths.outputBaseDir", root, issues
|
|
1061
|
+
validatePathField(paths.outputBaseDir.trim(), "paths.outputBaseDir", root, issues, {
|
|
1062
|
+
rejectReserved: true
|
|
1063
|
+
});
|
|
858
1064
|
}
|
|
859
1065
|
return issues;
|
|
860
1066
|
}
|
|
@@ -867,11 +1073,17 @@ function resolveSafePackageOutputOverride(projectRoot, override) {
|
|
|
867
1073
|
if ((0, import_node_path3.isAbsolute)(trimmed)) {
|
|
868
1074
|
const resolved2 = (0, import_node_path3.resolve)(trimmed);
|
|
869
1075
|
assertRealPathUnderRoot(root, resolved2);
|
|
1076
|
+
if (isReservedOutputPath(trimmed)) {
|
|
1077
|
+
throw new Error(`unsafe output path: ${override} targets a reserved directory`);
|
|
1078
|
+
}
|
|
870
1079
|
return resolved2;
|
|
871
1080
|
}
|
|
872
1081
|
if (!isSafeRelativeSpaPath(trimmed)) {
|
|
873
1082
|
throw new Error(`unsafe output path: ${override}`);
|
|
874
1083
|
}
|
|
1084
|
+
if (isReservedOutputPath(trimmed)) {
|
|
1085
|
+
throw new Error(`unsafe output path: ${override} targets a reserved directory`);
|
|
1086
|
+
}
|
|
875
1087
|
const resolved = (0, import_node_path3.resolve)(root, trimmed);
|
|
876
1088
|
assertRealPathUnderRoot(root, resolved);
|
|
877
1089
|
return resolved;
|
|
@@ -948,7 +1160,7 @@ function descriptorToInterchange(descriptor) {
|
|
|
948
1160
|
}
|
|
949
1161
|
|
|
950
1162
|
// src/writeProject.ts
|
|
951
|
-
var
|
|
1163
|
+
var import_node_path6 = require("path");
|
|
952
1164
|
var import_validators = require("@lxpack/validators");
|
|
953
1165
|
|
|
954
1166
|
// src/spaDirs.ts
|
|
@@ -1006,6 +1218,62 @@ async function resolveSpaDirs(options) {
|
|
|
1006
1218
|
return dirs;
|
|
1007
1219
|
}
|
|
1008
1220
|
|
|
1221
|
+
// src/spaDistValidation.ts
|
|
1222
|
+
var import_promises2 = require("fs/promises");
|
|
1223
|
+
var import_node_fs3 = require("fs");
|
|
1224
|
+
var import_node_path5 = require("path");
|
|
1225
|
+
async function assertSpaDistContentsSafe(spaDirs, projectRoot) {
|
|
1226
|
+
for (const [label, dir] of Object.entries(spaDirs)) {
|
|
1227
|
+
const dirResolved = resolveComparablePath(dir);
|
|
1228
|
+
const dirStat = await (0, import_promises2.lstat)(dirResolved);
|
|
1229
|
+
if (dirStat.isSymbolicLink()) {
|
|
1230
|
+
throw new Error(`spa dist for "${label}" cannot be a symlink: ${dir}`);
|
|
1231
|
+
}
|
|
1232
|
+
let rootReal;
|
|
1233
|
+
try {
|
|
1234
|
+
rootReal = (0, import_node_fs3.realpathSync)(dirResolved);
|
|
1235
|
+
} catch {
|
|
1236
|
+
throw new Error(`spa dist for "${label}" is not readable: ${dir}`);
|
|
1237
|
+
}
|
|
1238
|
+
if (projectRoot) {
|
|
1239
|
+
assertRealPathUnderRoot(projectRoot, dir);
|
|
1240
|
+
}
|
|
1241
|
+
assertResolvedPathUnderRoot(rootReal, rootReal);
|
|
1242
|
+
await walkDistDir(rootReal, rootReal, label);
|
|
1243
|
+
}
|
|
1244
|
+
}
|
|
1245
|
+
async function walkDistDir(rootReal, current, label) {
|
|
1246
|
+
let entries;
|
|
1247
|
+
try {
|
|
1248
|
+
entries = await (0, import_promises2.readdir)(current, { withFileTypes: true });
|
|
1249
|
+
} catch (err) {
|
|
1250
|
+
throw new Error(
|
|
1251
|
+
`spa dist for "${label}" is not readable: ${err instanceof Error ? err.message : String(err)}`,
|
|
1252
|
+
{ cause: err }
|
|
1253
|
+
);
|
|
1254
|
+
}
|
|
1255
|
+
for (const entry of entries) {
|
|
1256
|
+
const entryPath = (0, import_node_path5.join)(current, entry.name);
|
|
1257
|
+
const stat2 = await (0, import_promises2.lstat)(entryPath);
|
|
1258
|
+
if (stat2.isSymbolicLink()) {
|
|
1259
|
+
throw new Error(`spa dist for "${label}" contains symlink: ${entryPath}`);
|
|
1260
|
+
}
|
|
1261
|
+
let entryReal;
|
|
1262
|
+
try {
|
|
1263
|
+
entryReal = (0, import_node_fs3.realpathSync)(entryPath);
|
|
1264
|
+
} catch (err) {
|
|
1265
|
+
throw new Error(
|
|
1266
|
+
`spa dist for "${label}" could not resolve path: ${entryPath}`,
|
|
1267
|
+
{ cause: err }
|
|
1268
|
+
);
|
|
1269
|
+
}
|
|
1270
|
+
assertResolvedPathUnderRoot(rootReal, entryReal);
|
|
1271
|
+
if (stat2.isDirectory()) {
|
|
1272
|
+
await walkDistDir(rootReal, entryPath, label);
|
|
1273
|
+
}
|
|
1274
|
+
}
|
|
1275
|
+
}
|
|
1276
|
+
|
|
1009
1277
|
// src/writeProject.ts
|
|
1010
1278
|
async function writeLxpackProject(options) {
|
|
1011
1279
|
const validation = validateDescriptor(options.descriptor);
|
|
@@ -1015,11 +1283,14 @@ async function writeLxpackProject(options) {
|
|
|
1015
1283
|
);
|
|
1016
1284
|
}
|
|
1017
1285
|
const descriptor = validation.descriptor;
|
|
1018
|
-
const
|
|
1019
|
-
if (
|
|
1020
|
-
|
|
1286
|
+
const injectableIssues = validateInjectableAssessments(descriptor);
|
|
1287
|
+
if (injectableIssues.length > 0) {
|
|
1288
|
+
throw new Error(injectableIssues.map((i) => `${i.path}: ${i.message}`).join("; "));
|
|
1021
1289
|
}
|
|
1290
|
+
const outDir = (0, import_node_path6.resolve)(options.outDir);
|
|
1291
|
+
assertRealPathUnderRoot((0, import_node_path6.resolve)(options.projectRoot), outDir);
|
|
1022
1292
|
const spaDirs = await resolveSpaDirs({ ...options, descriptor });
|
|
1293
|
+
await assertSpaDistContentsSafe(spaDirs, options.projectRoot);
|
|
1023
1294
|
const interchange = descriptorToInterchange(descriptor);
|
|
1024
1295
|
const materialized = await (0, import_validators.materializeLessonkitProject)({
|
|
1025
1296
|
interchange,
|
|
@@ -1035,21 +1306,21 @@ async function writeLxpackProject(options) {
|
|
|
1035
1306
|
const courseDir = materialized.courseDir;
|
|
1036
1307
|
return {
|
|
1037
1308
|
outDir: courseDir,
|
|
1038
|
-
courseYamlPath: (0,
|
|
1039
|
-
lessonkitJsonPath: (0,
|
|
1309
|
+
courseYamlPath: (0, import_node_path6.join)(courseDir, "course.yaml"),
|
|
1310
|
+
lessonkitJsonPath: (0, import_node_path6.join)(courseDir, "lessonkit.json")
|
|
1040
1311
|
};
|
|
1041
1312
|
}
|
|
1042
1313
|
|
|
1043
1314
|
// src/packageCourse.ts
|
|
1044
|
-
var
|
|
1315
|
+
var import_node_path10 = require("path");
|
|
1045
1316
|
var fsp3 = __toESM(require("fs/promises"), 1);
|
|
1046
1317
|
var import_api2 = require("@lxpack/api");
|
|
1047
1318
|
|
|
1048
1319
|
// src/packaging/validateInputs.ts
|
|
1049
|
-
var
|
|
1320
|
+
var import_node_path7 = require("path");
|
|
1050
1321
|
function validatePackageInputs(options) {
|
|
1051
1322
|
const { target, output, outputBaseDir } = options;
|
|
1052
|
-
const outDir = (0,
|
|
1323
|
+
const outDir = (0, import_node_path7.resolve)(options.outDir);
|
|
1053
1324
|
if (!options.projectRoot) {
|
|
1054
1325
|
return {
|
|
1055
1326
|
ok: false,
|
|
@@ -1058,7 +1329,7 @@ function validatePackageInputs(options) {
|
|
|
1058
1329
|
issues: [{ path: "projectRoot", message: "projectRoot is required for packageLessonkitCourse" }]
|
|
1059
1330
|
};
|
|
1060
1331
|
}
|
|
1061
|
-
const projectRoot = (0,
|
|
1332
|
+
const projectRoot = (0, import_node_path7.resolve)(options.projectRoot);
|
|
1062
1333
|
try {
|
|
1063
1334
|
assertRealPathUnderRoot(projectRoot, outDir);
|
|
1064
1335
|
} catch (err) {
|
|
@@ -1086,9 +1357,9 @@ function validatePackageInputs(options) {
|
|
|
1086
1357
|
};
|
|
1087
1358
|
}
|
|
1088
1359
|
if (output && !isSafeRelativeSpaPath(output)) {
|
|
1089
|
-
if ((0,
|
|
1360
|
+
if ((0, import_node_path7.isAbsolute)(output)) {
|
|
1090
1361
|
try {
|
|
1091
|
-
assertRealPathUnderRoot(projectRoot, (0,
|
|
1362
|
+
assertRealPathUnderRoot(projectRoot, (0, import_node_path7.resolve)(output));
|
|
1092
1363
|
} catch (err) {
|
|
1093
1364
|
return {
|
|
1094
1365
|
ok: false,
|
|
@@ -1115,7 +1386,7 @@ function validatePackageInputs(options) {
|
|
|
1115
1386
|
}
|
|
1116
1387
|
}
|
|
1117
1388
|
if (outputBaseDir) {
|
|
1118
|
-
const resolvedOutputBase = (0,
|
|
1389
|
+
const resolvedOutputBase = (0, import_node_path7.resolve)(projectRoot, outputBaseDir);
|
|
1119
1390
|
try {
|
|
1120
1391
|
assertRealPathUnderRoot(projectRoot, resolvedOutputBase);
|
|
1121
1392
|
} catch (err) {
|
|
@@ -1136,7 +1407,7 @@ function validatePackageInputs(options) {
|
|
|
1136
1407
|
}
|
|
1137
1408
|
}
|
|
1138
1409
|
if (output) {
|
|
1139
|
-
const resolvedOutput = (0,
|
|
1410
|
+
const resolvedOutput = (0, import_node_path7.isAbsolute)(output) ? (0, import_node_path7.resolve)(output) : (0, import_node_path7.resolve)(projectRoot, output);
|
|
1140
1411
|
try {
|
|
1141
1412
|
assertRealPathUnderRoot(projectRoot, resolvedOutput);
|
|
1142
1413
|
} catch (err) {
|
|
@@ -1176,20 +1447,20 @@ function remapArtifactPaths(stagingRoot, outDir, artifactPath) {
|
|
|
1176
1447
|
throw new Error(`${artifactPath} is outside the staging directory`);
|
|
1177
1448
|
}
|
|
1178
1449
|
const rel = relativePathUnderRoot(stagingRoot, resolved);
|
|
1179
|
-
if (rel.startsWith("..") || (0,
|
|
1450
|
+
if (rel.startsWith("..") || (0, import_node_path7.isAbsolute)(rel)) {
|
|
1180
1451
|
throw new Error(`${artifactPath} is outside the staging directory`);
|
|
1181
1452
|
}
|
|
1182
1453
|
if (!rel) return outDir;
|
|
1183
1454
|
if (/^[a-zA-Z]:[/\\]/.test(outDir)) {
|
|
1184
|
-
return
|
|
1455
|
+
return import_node_path7.win32.join(outDir, rel.replace(/\//g, import_node_path7.win32.sep));
|
|
1185
1456
|
}
|
|
1186
|
-
return (0,
|
|
1457
|
+
return (0, import_node_path7.join)(outDir, rel);
|
|
1187
1458
|
}
|
|
1188
1459
|
|
|
1189
1460
|
// src/packaging/promote.ts
|
|
1190
1461
|
var fsp = __toESM(require("fs/promises"), 1);
|
|
1191
1462
|
var import_node_crypto = require("crypto");
|
|
1192
|
-
var
|
|
1463
|
+
var import_node_path8 = require("path");
|
|
1193
1464
|
async function pathExists(path) {
|
|
1194
1465
|
try {
|
|
1195
1466
|
await fsp.access(path);
|
|
@@ -1209,37 +1480,54 @@ async function renameOrCopy(from, to) {
|
|
|
1209
1480
|
}
|
|
1210
1481
|
}
|
|
1211
1482
|
function promoteLockPath(outDir) {
|
|
1212
|
-
const parent = (0,
|
|
1213
|
-
const hash = (0, import_node_crypto.createHash)("sha256").update((0,
|
|
1214
|
-
return (0,
|
|
1483
|
+
const parent = (0, import_node_path8.dirname)(outDir);
|
|
1484
|
+
const hash = (0, import_node_crypto.createHash)("sha256").update((0, import_node_path8.resolve)(outDir)).digest("hex").slice(0, 16);
|
|
1485
|
+
return (0, import_node_path8.join)(parent, `.lk-promote-lock-${hash}`);
|
|
1215
1486
|
}
|
|
1216
|
-
var
|
|
1487
|
+
var STALE_ARTIFACT_TTL_MS = 5 * 60 * 1e3;
|
|
1488
|
+
var MAX_LOCK_AGE_MS = 30 * 60 * 1e3;
|
|
1489
|
+
var LOCK_TOKEN_RE = /^(\d+)\n([0-9a-f-]{36})(?:\n(\d+))?\n?$/i;
|
|
1217
1490
|
async function isStalePromoteLock(lockPath) {
|
|
1218
1491
|
try {
|
|
1492
|
+
const stat2 = await fsp.stat(lockPath);
|
|
1219
1493
|
const content = await fsp.readFile(lockPath, "utf8");
|
|
1220
|
-
const
|
|
1221
|
-
|
|
1222
|
-
|
|
1223
|
-
|
|
1224
|
-
|
|
1225
|
-
|
|
1226
|
-
return true;
|
|
1494
|
+
const match = content.match(LOCK_TOKEN_RE);
|
|
1495
|
+
let lockAgeMs = Date.now() - stat2.mtimeMs;
|
|
1496
|
+
if (match?.[3]) {
|
|
1497
|
+
const startedAt = Number.parseInt(match[3], 10);
|
|
1498
|
+
if (Number.isFinite(startedAt) && startedAt > 0) {
|
|
1499
|
+
lockAgeMs = Date.now() - startedAt;
|
|
1227
1500
|
}
|
|
1228
1501
|
}
|
|
1229
|
-
|
|
1230
|
-
|
|
1502
|
+
if (lockAgeMs > MAX_LOCK_AGE_MS) {
|
|
1503
|
+
return true;
|
|
1504
|
+
}
|
|
1505
|
+
if (match) {
|
|
1506
|
+
const pid = Number.parseInt(match[1], 10);
|
|
1507
|
+
if (Number.isFinite(pid) && pid > 0) {
|
|
1508
|
+
try {
|
|
1509
|
+
process.kill(pid, 0);
|
|
1510
|
+
return false;
|
|
1511
|
+
} catch {
|
|
1512
|
+
return true;
|
|
1513
|
+
}
|
|
1514
|
+
}
|
|
1515
|
+
}
|
|
1516
|
+
return lockAgeMs > STALE_ARTIFACT_TTL_MS;
|
|
1231
1517
|
} catch {
|
|
1232
1518
|
return true;
|
|
1233
1519
|
}
|
|
1234
1520
|
}
|
|
1235
1521
|
async function withPromoteLock(outDir, fn) {
|
|
1236
1522
|
const lockPath = promoteLockPath(outDir);
|
|
1237
|
-
await fsp.mkdir((0,
|
|
1523
|
+
await fsp.mkdir((0, import_node_path8.dirname)(outDir), { recursive: true });
|
|
1238
1524
|
let lockHandle;
|
|
1239
1525
|
for (let attempt = 0; attempt < 200; attempt++) {
|
|
1240
1526
|
try {
|
|
1241
1527
|
lockHandle = await fsp.open(lockPath, "wx");
|
|
1242
1528
|
await lockHandle.writeFile(`${process.pid}
|
|
1529
|
+
${(0, import_node_crypto.randomUUID)()}
|
|
1530
|
+
${Date.now()}
|
|
1243
1531
|
`, "utf8");
|
|
1244
1532
|
break;
|
|
1245
1533
|
} catch (err) {
|
|
@@ -1271,26 +1559,81 @@ async function withPromoteLock(outDir, fn) {
|
|
|
1271
1559
|
);
|
|
1272
1560
|
}
|
|
1273
1561
|
}
|
|
1274
|
-
async function
|
|
1562
|
+
async function removeStaleLegacyPromoteArtifacts(outDir) {
|
|
1275
1563
|
const legacyTmp = `${outDir}.tmp-promote`;
|
|
1276
1564
|
const legacyBak = `${outDir}.bak`;
|
|
1277
|
-
const
|
|
1278
|
-
|
|
1279
|
-
|
|
1280
|
-
|
|
1565
|
+
const blocked = [];
|
|
1566
|
+
for (const legacyPath of [legacyTmp, legacyBak]) {
|
|
1567
|
+
if (!await pathExists(legacyPath)) continue;
|
|
1568
|
+
try {
|
|
1569
|
+
const stat2 = await fsp.stat(legacyPath);
|
|
1570
|
+
if (Date.now() - stat2.mtimeMs > STALE_ARTIFACT_TTL_MS) {
|
|
1571
|
+
await fsp.rm(legacyPath, { recursive: true, force: true }).catch(
|
|
1572
|
+
/* v8 ignore next */
|
|
1573
|
+
() => void 0
|
|
1574
|
+
);
|
|
1575
|
+
continue;
|
|
1576
|
+
}
|
|
1577
|
+
} catch {
|
|
1578
|
+
}
|
|
1579
|
+
blocked.push(legacyPath);
|
|
1580
|
+
}
|
|
1581
|
+
if (blocked.length) {
|
|
1582
|
+
const rmHint = blocked.map((p) => `rm -rf ${JSON.stringify(p)}`).join("; ");
|
|
1281
1583
|
throw new Error(
|
|
1282
|
-
`[lessonkit/lxpack] cannot promote: remove stale packaging artifacts from a previous failed run: ${
|
|
1584
|
+
`[lessonkit/lxpack] cannot promote: remove stale packaging artifacts from a previous failed run: ${blocked.join(", ")}. Try: ${rmHint}`
|
|
1283
1585
|
);
|
|
1284
1586
|
}
|
|
1285
1587
|
}
|
|
1286
|
-
async function
|
|
1588
|
+
async function listRelativePaths(root, dir = root) {
|
|
1589
|
+
const entries = await fsp.readdir(dir, { withFileTypes: true });
|
|
1590
|
+
const paths = [];
|
|
1591
|
+
for (const entry of entries) {
|
|
1592
|
+
const full = (0, import_node_path8.join)(dir, entry.name);
|
|
1593
|
+
if (entry.isDirectory()) {
|
|
1594
|
+
paths.push(...await listRelativePaths(root, full));
|
|
1595
|
+
} else if (entry.isFile()) {
|
|
1596
|
+
paths.push(full.slice(root.length + 1));
|
|
1597
|
+
} else {
|
|
1598
|
+
}
|
|
1599
|
+
}
|
|
1600
|
+
return paths;
|
|
1601
|
+
}
|
|
1602
|
+
async function mergePreservedOutArtifacts(priorArtifactsDir, destArtifactsDir, newArtifactPaths) {
|
|
1603
|
+
if (!await pathExists(priorArtifactsDir)) return;
|
|
1604
|
+
for (const rel of await listRelativePaths(priorArtifactsDir)) {
|
|
1605
|
+
if (newArtifactPaths.has(rel)) continue;
|
|
1606
|
+
const src = (0, import_node_path8.join)(priorArtifactsDir, rel);
|
|
1607
|
+
const dest = (0, import_node_path8.join)(destArtifactsDir, rel);
|
|
1608
|
+
await fsp.mkdir((0, import_node_path8.dirname)(dest), { recursive: true });
|
|
1609
|
+
await fsp.cp(src, dest, { force: true });
|
|
1610
|
+
}
|
|
1611
|
+
}
|
|
1612
|
+
async function promoteStagingToOutDir(stagingDir, outDir, options) {
|
|
1613
|
+
const outputBaseDir = options?.outputBaseDir ?? ".lxpack/out";
|
|
1614
|
+
if (options?.projectRoot) {
|
|
1615
|
+
assertRealPathUnderRoot((0, import_node_path8.resolve)(options.projectRoot), (0, import_node_path8.resolve)(outDir));
|
|
1616
|
+
}
|
|
1287
1617
|
return withPromoteLock(outDir, async () => {
|
|
1288
|
-
await
|
|
1289
|
-
const
|
|
1290
|
-
const
|
|
1618
|
+
await removeStaleLegacyPromoteArtifacts(outDir);
|
|
1619
|
+
const stagingArtifactsDir = (0, import_node_path8.join)(stagingDir, outputBaseDir);
|
|
1620
|
+
const newArtifactPaths = /* @__PURE__ */ new Set();
|
|
1621
|
+
if (await pathExists(stagingArtifactsDir)) {
|
|
1622
|
+
for (const rel of await listRelativePaths(stagingArtifactsDir)) {
|
|
1623
|
+
newArtifactPaths.add(rel);
|
|
1624
|
+
}
|
|
1625
|
+
}
|
|
1626
|
+
const parent = (0, import_node_path8.dirname)(outDir);
|
|
1627
|
+
let priorArtifactsBackup;
|
|
1628
|
+
const existingArtifactsDir = (0, import_node_path8.join)(outDir, outputBaseDir);
|
|
1629
|
+
if (await pathExists(outDir) && await pathExists(existingArtifactsDir)) {
|
|
1630
|
+
priorArtifactsBackup = await fsp.mkdtemp((0, import_node_path8.join)(parent, ".lk-prior-out-"));
|
|
1631
|
+
await fsp.cp(existingArtifactsDir, (0, import_node_path8.join)(priorArtifactsBackup, outputBaseDir), { recursive: true });
|
|
1632
|
+
}
|
|
1633
|
+
const tmpPromote = await fsp.mkdtemp((0, import_node_path8.join)(parent, ".lk-promote-"));
|
|
1291
1634
|
await renameOrCopy(stagingDir, tmpPromote);
|
|
1292
1635
|
const hadOutDir = await pathExists(outDir);
|
|
1293
|
-
const backup = hadOutDir ? await fsp.mkdtemp((0,
|
|
1636
|
+
const backup = hadOutDir ? await fsp.mkdtemp((0, import_node_path8.join)(parent, ".lk-backup-")) : void 0;
|
|
1294
1637
|
if (hadOutDir && backup) {
|
|
1295
1638
|
await renameOrCopy(outDir, backup);
|
|
1296
1639
|
}
|
|
@@ -1301,7 +1644,7 @@ async function promoteStagingToOutDir(stagingDir, outDir) {
|
|
|
1301
1644
|
try {
|
|
1302
1645
|
await renameOrCopy(backup, outDir);
|
|
1303
1646
|
} catch (restoreError) {
|
|
1304
|
-
const failedPromote2 = (0,
|
|
1647
|
+
const failedPromote2 = (0, import_node_path8.join)(parent, `.lk-failed-promote-${(0, import_node_crypto.randomUUID)()}`);
|
|
1305
1648
|
try {
|
|
1306
1649
|
await renameOrCopy(tmpPromote, failedPromote2);
|
|
1307
1650
|
} catch {
|
|
@@ -1313,7 +1656,8 @@ async function promoteStagingToOutDir(stagingDir, outDir) {
|
|
|
1313
1656
|
const promoteMsg = promoteError instanceof Error ? promoteError.message : String(promoteError);
|
|
1314
1657
|
const restoreMsg = restoreError instanceof Error ? restoreError.message : String(restoreError);
|
|
1315
1658
|
throw new Error(
|
|
1316
|
-
`[lessonkit/lxpack] promote failed (${promoteMsg}) and could not restore ${outDir} (${restoreMsg}). Recovery: previous output may be in ${backup}; staged package may be in ${failedPromote2}
|
|
1659
|
+
`[lessonkit/lxpack] promote failed (${promoteMsg}) and could not restore ${outDir} (${restoreMsg}). Recovery: previous output may be in ${backup}; staged package may be in ${failedPromote2}.`,
|
|
1660
|
+
{ cause: restoreError }
|
|
1317
1661
|
);
|
|
1318
1662
|
}
|
|
1319
1663
|
} else {
|
|
@@ -1331,7 +1675,7 @@ async function promoteStagingToOutDir(stagingDir, outDir) {
|
|
|
1331
1675
|
}
|
|
1332
1676
|
throw promoteError;
|
|
1333
1677
|
}
|
|
1334
|
-
const failedPromote = (0,
|
|
1678
|
+
const failedPromote = (0, import_node_path8.join)(parent, `.lk-failed-promote-${(0, import_node_crypto.randomUUID)()}`);
|
|
1335
1679
|
try {
|
|
1336
1680
|
await renameOrCopy(tmpPromote, failedPromote);
|
|
1337
1681
|
} catch {
|
|
@@ -1342,6 +1686,20 @@ async function promoteStagingToOutDir(stagingDir, outDir) {
|
|
|
1342
1686
|
}
|
|
1343
1687
|
throw promoteError;
|
|
1344
1688
|
}
|
|
1689
|
+
if (priorArtifactsBackup) {
|
|
1690
|
+
try {
|
|
1691
|
+
await mergePreservedOutArtifacts(
|
|
1692
|
+
(0, import_node_path8.join)(priorArtifactsBackup, outputBaseDir),
|
|
1693
|
+
(0, import_node_path8.join)(outDir, outputBaseDir),
|
|
1694
|
+
newArtifactPaths
|
|
1695
|
+
);
|
|
1696
|
+
} finally {
|
|
1697
|
+
await fsp.rm(priorArtifactsBackup, { recursive: true, force: true }).catch(
|
|
1698
|
+
/* v8 ignore next */
|
|
1699
|
+
() => void 0
|
|
1700
|
+
);
|
|
1701
|
+
}
|
|
1702
|
+
}
|
|
1345
1703
|
if (backup) {
|
|
1346
1704
|
await fsp.rm(backup, { recursive: true, force: true }).catch(
|
|
1347
1705
|
/* v8 ignore next */
|
|
@@ -1353,16 +1711,18 @@ async function promoteStagingToOutDir(stagingDir, outDir) {
|
|
|
1353
1711
|
|
|
1354
1712
|
// src/packaging/staging.ts
|
|
1355
1713
|
var fsp2 = __toESM(require("fs/promises"), 1);
|
|
1356
|
-
var
|
|
1714
|
+
var import_node_path9 = require("path");
|
|
1357
1715
|
var import_node_os = require("os");
|
|
1358
1716
|
var import_api = require("@lxpack/api");
|
|
1359
1717
|
async function buildStagingPackage(options) {
|
|
1360
1718
|
const { target, output, dir, outputBaseDir, descriptor, ...writeOpts } = options;
|
|
1361
|
-
const stagingDir = await fsp2.mkdtemp((0,
|
|
1719
|
+
const stagingDir = await fsp2.mkdtemp((0, import_node_path9.join)((0, import_node_os.tmpdir)(), "lessonkit-lxpack-"));
|
|
1720
|
+
let succeeded = false;
|
|
1362
1721
|
try {
|
|
1363
1722
|
let spaDirs;
|
|
1364
1723
|
try {
|
|
1365
1724
|
spaDirs = await resolveSpaDirs({ ...writeOpts, descriptor });
|
|
1725
|
+
await assertSpaDistContentsSafe(spaDirs, writeOpts.projectRoot);
|
|
1366
1726
|
} catch (err) {
|
|
1367
1727
|
return {
|
|
1368
1728
|
ok: false,
|
|
@@ -1375,10 +1735,21 @@ async function buildStagingPackage(options) {
|
|
|
1375
1735
|
]
|
|
1376
1736
|
};
|
|
1377
1737
|
}
|
|
1738
|
+
const injectableIssues = validateInjectableAssessments(descriptor);
|
|
1739
|
+
if (injectableIssues.length > 0) {
|
|
1740
|
+
return {
|
|
1741
|
+
ok: false,
|
|
1742
|
+
stagingDir,
|
|
1743
|
+
issues: injectableIssues.map((i) => ({
|
|
1744
|
+
path: i.path,
|
|
1745
|
+
message: i.message
|
|
1746
|
+
}))
|
|
1747
|
+
};
|
|
1748
|
+
}
|
|
1378
1749
|
const interchange = descriptorToInterchange(descriptor);
|
|
1379
1750
|
const outputBase = outputBaseDir ?? ".lxpack/out";
|
|
1380
|
-
await fsp2.mkdir((0,
|
|
1381
|
-
const defaultOutput = output ?? (dir ? (0,
|
|
1751
|
+
await fsp2.mkdir((0, import_node_path9.join)(stagingDir, outputBase), { recursive: true });
|
|
1752
|
+
const defaultOutput = output ?? (dir ? (0, import_node_path9.join)(outputBase, target) : (0, import_node_path9.join)(outputBase, `course-${target}.zip`));
|
|
1382
1753
|
const build = await (0, import_api.packageLessonkit)({
|
|
1383
1754
|
interchange,
|
|
1384
1755
|
spaDirs,
|
|
@@ -1402,6 +1773,7 @@ async function buildStagingPackage(options) {
|
|
|
1402
1773
|
}))
|
|
1403
1774
|
};
|
|
1404
1775
|
}
|
|
1776
|
+
succeeded = true;
|
|
1405
1777
|
return {
|
|
1406
1778
|
ok: true,
|
|
1407
1779
|
stagingDir,
|
|
@@ -1415,10 +1787,17 @@ async function buildStagingPackage(options) {
|
|
|
1415
1787
|
() => void 0
|
|
1416
1788
|
);
|
|
1417
1789
|
throw err;
|
|
1790
|
+
} finally {
|
|
1791
|
+
if (!succeeded) {
|
|
1792
|
+
await fsp2.rm(stagingDir, { recursive: true, force: true }).catch(
|
|
1793
|
+
/* v8 ignore next */
|
|
1794
|
+
() => void 0
|
|
1795
|
+
);
|
|
1796
|
+
}
|
|
1418
1797
|
}
|
|
1419
1798
|
}
|
|
1420
1799
|
async function ensureOutDirParent(outDir) {
|
|
1421
|
-
await fsp2.mkdir((0,
|
|
1800
|
+
await fsp2.mkdir((0, import_node_path9.dirname)(outDir), { recursive: true });
|
|
1422
1801
|
}
|
|
1423
1802
|
|
|
1424
1803
|
// src/packaging/issueSeverity.ts
|
|
@@ -1433,13 +1812,13 @@ function findPackagingErrorIssues(issues) {
|
|
|
1433
1812
|
// src/packageCourse.ts
|
|
1434
1813
|
async function validateLessonkitProject(options) {
|
|
1435
1814
|
return (0, import_api2.validateCourse)({
|
|
1436
|
-
courseDir: (0,
|
|
1815
|
+
courseDir: (0, import_node_path10.resolve)(options.courseDir),
|
|
1437
1816
|
target: options.target
|
|
1438
1817
|
});
|
|
1439
1818
|
}
|
|
1440
1819
|
async function buildLessonkitProject(options) {
|
|
1441
1820
|
const buildOptions = {
|
|
1442
|
-
courseDir: (0,
|
|
1821
|
+
courseDir: (0, import_node_path10.resolve)(options.courseDir),
|
|
1443
1822
|
target: options.target,
|
|
1444
1823
|
output: options.output,
|
|
1445
1824
|
dir: options.dir,
|
|
@@ -1470,7 +1849,7 @@ async function packageLessonkitCourse(options) {
|
|
|
1470
1849
|
if (!descriptorValidation.ok) {
|
|
1471
1850
|
return {
|
|
1472
1851
|
ok: false,
|
|
1473
|
-
courseDir: (0,
|
|
1852
|
+
courseDir: (0, import_node_path10.resolve)(writeOpts.outDir),
|
|
1474
1853
|
target,
|
|
1475
1854
|
issues: descriptorValidation.issues.map((i) => ({
|
|
1476
1855
|
path: i.path,
|
|
@@ -1479,34 +1858,20 @@ async function packageLessonkitCourse(options) {
|
|
|
1479
1858
|
};
|
|
1480
1859
|
}
|
|
1481
1860
|
const descriptor = descriptorValidation.descriptor;
|
|
1482
|
-
|
|
1483
|
-
|
|
1484
|
-
|
|
1485
|
-
|
|
1486
|
-
|
|
1487
|
-
|
|
1488
|
-
if (parityErrors.length > 0) {
|
|
1489
|
-
return {
|
|
1490
|
-
ok: false,
|
|
1491
|
-
courseDir: outDir,
|
|
1492
|
-
target,
|
|
1493
|
-
issues: parityErrors.map((i) => ({
|
|
1494
|
-
path: i.path,
|
|
1495
|
-
message: i.message,
|
|
1496
|
-
severity: i.severity
|
|
1497
|
-
}))
|
|
1498
|
-
};
|
|
1499
|
-
}
|
|
1500
|
-
}
|
|
1501
|
-
const nonInjectableAssessments = (descriptor.assessments ?? []).map((assessment, index) => ({ assessment, index })).filter(({ assessment }) => assessmentDescriptorToLxpack(assessment) === null);
|
|
1502
|
-
if (nonInjectableAssessments.length > 0) {
|
|
1861
|
+
const parityIssues = validateReactManifestParity({
|
|
1862
|
+
projectRoot: writeOpts.projectRoot,
|
|
1863
|
+
descriptor
|
|
1864
|
+
});
|
|
1865
|
+
const parityFailures = writeOpts.strictParity ? parityIssues : parityIssues.filter((i) => i.severity === "error");
|
|
1866
|
+
if (parityFailures.length > 0) {
|
|
1503
1867
|
return {
|
|
1504
1868
|
ok: false,
|
|
1505
1869
|
courseDir: outDir,
|
|
1506
1870
|
target,
|
|
1507
|
-
issues:
|
|
1508
|
-
path:
|
|
1509
|
-
message:
|
|
1871
|
+
issues: parityFailures.map((i) => ({
|
|
1872
|
+
path: i.path,
|
|
1873
|
+
message: i.message,
|
|
1874
|
+
severity: i.severity
|
|
1510
1875
|
}))
|
|
1511
1876
|
};
|
|
1512
1877
|
}
|
|
@@ -1567,7 +1932,7 @@ async function packageLessonkitCourse(options) {
|
|
|
1567
1932
|
ok: false,
|
|
1568
1933
|
courseDir: outDir,
|
|
1569
1934
|
target,
|
|
1570
|
-
validation: { ok:
|
|
1935
|
+
validation: { ok: false, manifest: build.manifest, issues: build.issues },
|
|
1571
1936
|
build,
|
|
1572
1937
|
issues: artifactIssues
|
|
1573
1938
|
};
|
|
@@ -1581,7 +1946,10 @@ async function packageLessonkitCourse(options) {
|
|
|
1581
1946
|
};
|
|
1582
1947
|
try {
|
|
1583
1948
|
await ensureOutDirParent(outDir);
|
|
1584
|
-
await promoteStagingToOutDir(stagingDir, outDir
|
|
1949
|
+
await promoteStagingToOutDir(stagingDir, outDir, {
|
|
1950
|
+
outputBaseDir: outputBaseDir ?? ".lxpack/out",
|
|
1951
|
+
projectRoot: writeOpts.projectRoot
|
|
1952
|
+
});
|
|
1585
1953
|
} catch (err) {
|
|
1586
1954
|
await fsp3.rm(stagingDir, { recursive: true, force: true }).catch(
|
|
1587
1955
|
/* v8 ignore next */
|
|
@@ -1704,6 +2072,20 @@ function parseLessonkitManifest(raw, label = "lessonkit.json", projectRoot) {
|
|
|
1704
2072
|
message: `"course.spaDistDir" (${courseSpaDistDir}) differs from "paths.spaDistDir" (${paths.spaDistDir}). Use paths.spaDistDir for CLI build and package.`
|
|
1705
2073
|
});
|
|
1706
2074
|
}
|
|
2075
|
+
for (const key of ["spaDistDir", "lxpackOutDir", "outputBaseDir"]) {
|
|
2076
|
+
const value = paths[key];
|
|
2077
|
+
if (!isSafeRelativeSpaPath(value)) {
|
|
2078
|
+
issues.push({
|
|
2079
|
+
path: `paths.${key}`,
|
|
2080
|
+
message: "path must be relative without '..' segments or absolute prefixes"
|
|
2081
|
+
});
|
|
2082
|
+
} else if ((key === "lxpackOutDir" || key === "outputBaseDir") && isReservedOutputPath(value)) {
|
|
2083
|
+
issues.push({
|
|
2084
|
+
path: `paths.${key}`,
|
|
2085
|
+
message: "path must not target reserved directories (.git, node_modules, .github)"
|
|
2086
|
+
});
|
|
2087
|
+
}
|
|
2088
|
+
}
|
|
1707
2089
|
if (projectRoot) {
|
|
1708
2090
|
const pathIssues = validateProjectPaths(projectRoot, paths);
|
|
1709
2091
|
for (const pi of pathIssues) {
|
|
@@ -1735,37 +2117,56 @@ var import_tracking_schema2 = require("@lxpack/tracking-schema");
|
|
|
1735
2117
|
|
|
1736
2118
|
// src/telemetry.ts
|
|
1737
2119
|
var import_tracking_schema = require("@lxpack/tracking-schema");
|
|
1738
|
-
var
|
|
2120
|
+
var BRANCH_TELEMETRY_EVENTS = ["branch_node_viewed", "branch_selected"];
|
|
2121
|
+
var ASSESSMENT_TELEMETRY_EVENTS = ["assessment_answered"];
|
|
2122
|
+
var SUPPORTED = /* @__PURE__ */ new Set([
|
|
2123
|
+
...import_tracking_schema.LESSONKIT_TELEMETRY_EVENTS,
|
|
2124
|
+
...BRANCH_TELEMETRY_EVENTS,
|
|
2125
|
+
...ASSESSMENT_TELEMETRY_EVENTS
|
|
2126
|
+
]);
|
|
1739
2127
|
function isQuizAnsweredData(data) {
|
|
1740
|
-
return typeof data === "object" && data !== null && typeof data.checkId === "string";
|
|
2128
|
+
return typeof data === "object" && data !== null && typeof data.checkId === "string" && data.checkId.length > 0;
|
|
1741
2129
|
}
|
|
1742
2130
|
function isQuizCompletedData(data) {
|
|
1743
|
-
return typeof data === "object" && data !== null && typeof data.checkId === "string";
|
|
2131
|
+
return typeof data === "object" && data !== null && typeof data.checkId === "string" && data.checkId.length > 0;
|
|
2132
|
+
}
|
|
2133
|
+
function isAssessmentAnsweredData(data) {
|
|
2134
|
+
return typeof data === "object" && data !== null && typeof data.checkId === "string" && data.checkId.length > 0;
|
|
1744
2135
|
}
|
|
1745
2136
|
function isInteractionData(data) {
|
|
1746
2137
|
return typeof data === "object" && data !== null;
|
|
1747
2138
|
}
|
|
2139
|
+
function isBranchNodeViewedData(data) {
|
|
2140
|
+
return typeof data === "object" && data !== null && typeof data.blockId === "string" && typeof data.nodeId === "string";
|
|
2141
|
+
}
|
|
2142
|
+
function isBranchSelectedData(data) {
|
|
2143
|
+
return typeof data === "object" && data !== null && typeof data.blockId === "string" && typeof data.fromNodeId === "string" && typeof data.toNodeId === "string";
|
|
2144
|
+
}
|
|
1748
2145
|
function telemetryEventToLessonkit(event) {
|
|
1749
2146
|
if (!SUPPORTED.has(event.name)) {
|
|
1750
2147
|
return null;
|
|
1751
2148
|
}
|
|
1752
|
-
const name = event.name;
|
|
1753
2149
|
const mapped = {
|
|
1754
|
-
name,
|
|
2150
|
+
name: event.name,
|
|
1755
2151
|
lessonId: event.lessonId
|
|
1756
2152
|
};
|
|
1757
|
-
if (name === "quiz_completed" || name === "quiz_answered") {
|
|
2153
|
+
if (event.name === "quiz_completed" || event.name === "quiz_answered" || event.name === "assessment_answered") {
|
|
1758
2154
|
const data = event.data;
|
|
1759
|
-
if (isQuizAnsweredData(data)
|
|
1760
|
-
|
|
1761
|
-
if ("score" in data) {
|
|
1762
|
-
mapped.score = data.score;
|
|
1763
|
-
mapped.maxScore = data.maxScore;
|
|
1764
|
-
mapped.passingScore = data.passingScore;
|
|
1765
|
-
}
|
|
1766
|
-
mapped.data = data;
|
|
2155
|
+
if (!isQuizAnsweredData(data) && !isQuizCompletedData(data) && !isAssessmentAnsweredData(data)) {
|
|
2156
|
+
return null;
|
|
1767
2157
|
}
|
|
1768
|
-
|
|
2158
|
+
mapped.assessmentId = data.checkId;
|
|
2159
|
+
if ("score" in data) {
|
|
2160
|
+
mapped.score = data.score;
|
|
2161
|
+
mapped.maxScore = data.maxScore;
|
|
2162
|
+
mapped.passingScore = data.passingScore;
|
|
2163
|
+
}
|
|
2164
|
+
mapped.data = data;
|
|
2165
|
+
} else if (mapped.name === "interaction" && event.data && isInteractionData(event.data)) {
|
|
2166
|
+
mapped.data = event.data;
|
|
2167
|
+
} else if (event.name === "branch_node_viewed" && isBranchNodeViewedData(event.data)) {
|
|
2168
|
+
mapped.data = event.data;
|
|
2169
|
+
} else if (event.name === "branch_selected" && isBranchSelectedData(event.data)) {
|
|
1769
2170
|
mapped.data = event.data;
|
|
1770
2171
|
}
|
|
1771
2172
|
return mapped;
|
|
@@ -1781,6 +2182,7 @@ var import_validators2 = require("@lxpack/validators");
|
|
|
1781
2182
|
buildStagingPackage,
|
|
1782
2183
|
descriptorToInterchange,
|
|
1783
2184
|
ensureOutDirParent,
|
|
2185
|
+
escapeShellText,
|
|
1784
2186
|
extractAssessments,
|
|
1785
2187
|
lessonkitInterchangeSchema,
|
|
1786
2188
|
loadLessonkitManifestFromFile,
|