@lessonkit/lxpack 1.4.0 → 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 +35 -7
- package/dist/bridge.cjs +160 -41
- package/dist/bridge.d.cts +27 -9
- package/dist/bridge.d.ts +27 -9
- package/dist/bridge.js +96 -27
- package/dist/chunk-HTZR4CF3.js +94 -0
- package/dist/index.cjs +434 -111
- package/dist/index.d.cts +23 -5
- package/dist/index.d.ts +23 -5
- package/dist/index.js +401 -98
- package/dist/telemetry-0fIWoomS.d.cts +17 -0
- package/dist/telemetry-0fIWoomS.d.ts +17 -0
- package/package.json +5 -5
- 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";
|
|
@@ -344,8 +344,30 @@ var ASSESSMENT_VALIDATORS = {
|
|
|
344
344
|
}
|
|
345
345
|
},
|
|
346
346
|
fillInBlanks: (assessment, path, issues) => {
|
|
347
|
-
if (assessment.kind
|
|
347
|
+
if (assessment.kind !== "fillInBlanks") return;
|
|
348
|
+
if (!assessment.template?.trim()) {
|
|
348
349
|
issues.push({ path: `${path}.template`, message: "template is required for fillInBlanks" });
|
|
350
|
+
return;
|
|
351
|
+
}
|
|
352
|
+
const templateBlankCount = countStarDelimitedBlanks(assessment.template);
|
|
353
|
+
if (templateBlankCount === 0) {
|
|
354
|
+
issues.push({
|
|
355
|
+
path: `${path}.template`,
|
|
356
|
+
message: "template must include at least one blank wrapped in asterisks for fillInBlanks"
|
|
357
|
+
});
|
|
358
|
+
}
|
|
359
|
+
const explicitBlanks = assessment.blanks?.map((b) => ({ id: b.id?.trim() ?? "", answer: b.answer?.trim() ?? "" })).filter((b) => b.id.length > 0 && b.answer.length > 0) ?? [];
|
|
360
|
+
if (assessment.blanks !== void 0 && explicitBlanks.length === 0) {
|
|
361
|
+
issues.push({
|
|
362
|
+
path: `${path}.blanks`,
|
|
363
|
+
message: "blanks must include at least one entry with non-empty id and answer"
|
|
364
|
+
});
|
|
365
|
+
}
|
|
366
|
+
if (explicitBlanks.length > 0 && explicitBlanks.length !== templateBlankCount) {
|
|
367
|
+
issues.push({
|
|
368
|
+
path: `${path}.blanks`,
|
|
369
|
+
message: `blanks length (${explicitBlanks.length}) must match template blank count (${templateBlankCount})`
|
|
370
|
+
});
|
|
349
371
|
}
|
|
350
372
|
},
|
|
351
373
|
findHotspot: (assessment, path, issues) => {
|
|
@@ -543,27 +565,47 @@ function validateCourseDescriptor(input) {
|
|
|
543
565
|
}
|
|
544
566
|
|
|
545
567
|
// src/assessments.ts
|
|
568
|
+
function escapeShellText(text) {
|
|
569
|
+
return text.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """).replace(/'/g, "'");
|
|
570
|
+
}
|
|
571
|
+
function decodeShellEntities(text) {
|
|
572
|
+
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)));
|
|
573
|
+
}
|
|
574
|
+
function containsUnsafeShellMarkup(text) {
|
|
575
|
+
const decoded = decodeShellEntities(text);
|
|
576
|
+
return /<\/script/i.test(decoded) || /<!--/.test(decoded) || /</.test(decoded);
|
|
577
|
+
}
|
|
578
|
+
function sanitizeShellField(text) {
|
|
579
|
+
if (containsUnsafeShellMarkup(text)) return null;
|
|
580
|
+
return escapeShellText(text);
|
|
581
|
+
}
|
|
546
582
|
function slugChoiceId(text, index) {
|
|
547
583
|
const base = text.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "").slice(0, 32);
|
|
548
584
|
const stem = base.length ? base : "choice";
|
|
549
585
|
return `${stem}-${index + 1}`;
|
|
550
586
|
}
|
|
551
587
|
function mcqToLxpack(assessment) {
|
|
588
|
+
const checkId = sanitizeShellField(assessment.checkId);
|
|
589
|
+
const prompt = sanitizeShellField(assessment.question);
|
|
590
|
+
if (!checkId || !prompt) return null;
|
|
552
591
|
const choices = assessment.choices.map((text, index) => {
|
|
592
|
+
const sanitizedText = sanitizeShellField(text);
|
|
593
|
+
if (!sanitizedText) return null;
|
|
553
594
|
const id = slugChoiceId(text, index);
|
|
554
595
|
return {
|
|
555
596
|
id,
|
|
556
|
-
text,
|
|
597
|
+
text: sanitizedText,
|
|
557
598
|
correct: text === assessment.answer
|
|
558
599
|
};
|
|
559
600
|
});
|
|
601
|
+
if (choices.some((choice) => choice === null)) return null;
|
|
560
602
|
return {
|
|
561
|
-
id:
|
|
603
|
+
id: checkId,
|
|
562
604
|
passingScore: assessment.passingScore ?? 1,
|
|
563
605
|
questions: [
|
|
564
606
|
{
|
|
565
607
|
id: "q1",
|
|
566
|
-
prompt
|
|
608
|
+
prompt,
|
|
567
609
|
choices
|
|
568
610
|
}
|
|
569
611
|
]
|
|
@@ -623,22 +665,29 @@ var LMS_SHELL_TARGETS = /* @__PURE__ */ new Set([
|
|
|
623
665
|
"xapi",
|
|
624
666
|
"cmi5"
|
|
625
667
|
]);
|
|
668
|
+
function appendActivityIriIssues(issues, descriptor, target) {
|
|
669
|
+
const hasXapiTracking = Boolean(descriptor.tracking?.xapi);
|
|
670
|
+
const requiresForTarget = target === "xapi" || target === "cmi5";
|
|
671
|
+
if (!hasXapiTracking && !requiresForTarget) return;
|
|
672
|
+
const activityIri = descriptor.tracking?.xapi?.activityIri?.trim();
|
|
673
|
+
const targetSuffix = target === "xapi" || target === "cmi5" ? ` for ${target} export targets` : " when tracking.xapi is configured";
|
|
674
|
+
if (!activityIri) {
|
|
675
|
+
issues.push({
|
|
676
|
+
path: "tracking.xapi.activityIri",
|
|
677
|
+
message: `tracking.xapi.activityIri is required${targetSuffix}`
|
|
678
|
+
});
|
|
679
|
+
return;
|
|
680
|
+
}
|
|
681
|
+
if (!/^https:\/\/.+/i.test(activityIri)) {
|
|
682
|
+
issues.push({
|
|
683
|
+
path: "tracking.xapi.activityIri",
|
|
684
|
+
message: `tracking.xapi.activityIri must be an HTTPS URL${targetSuffix}`
|
|
685
|
+
});
|
|
686
|
+
}
|
|
687
|
+
}
|
|
626
688
|
function validateDescriptorForExportTarget(descriptor, target) {
|
|
627
689
|
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
|
-
}
|
|
690
|
+
appendActivityIriIssues(issues, descriptor, target);
|
|
642
691
|
if (LMS_SHELL_TARGETS.has(target)) {
|
|
643
692
|
issues.push(...validateInjectableAssessments(descriptor).map((issue) => ({
|
|
644
693
|
...issue,
|
|
@@ -672,19 +721,53 @@ function validateDescriptorForTarget(input, target) {
|
|
|
672
721
|
}
|
|
673
722
|
|
|
674
723
|
// src/validateReactParity.ts
|
|
675
|
-
import { readFileSync, existsSync as existsSync2, readdirSync,
|
|
724
|
+
import { readFileSync, existsSync as existsSync2, readdirSync, lstatSync } from "fs";
|
|
676
725
|
import { join as join2, relative as relative2 } from "path";
|
|
677
726
|
var SCANNABLE_EXTENSIONS = [".tsx", ".ts", ".jsx", ".js"];
|
|
678
|
-
function collectSourceUnderSrc(projectRoot) {
|
|
727
|
+
function collectSourceUnderSrc(projectRoot, issues) {
|
|
679
728
|
const srcDir = join2(projectRoot, "src");
|
|
680
729
|
if (!existsSync2(srcDir)) return [];
|
|
681
730
|
const results = [];
|
|
682
731
|
const walk = (dir) => {
|
|
683
732
|
for (const entry of readdirSync(dir)) {
|
|
684
733
|
const abs = join2(dir, entry);
|
|
685
|
-
|
|
734
|
+
let stat2;
|
|
735
|
+
try {
|
|
736
|
+
stat2 = lstatSync(abs);
|
|
737
|
+
} catch {
|
|
738
|
+
continue;
|
|
739
|
+
}
|
|
740
|
+
if (stat2.isSymbolicLink()) {
|
|
741
|
+
issues.push({
|
|
742
|
+
path: relative2(projectRoot, abs),
|
|
743
|
+
message: `Source tree contains symlink (rejected for parity scan): ${relative2(projectRoot, abs)}`,
|
|
744
|
+
severity: "error"
|
|
745
|
+
});
|
|
746
|
+
continue;
|
|
747
|
+
}
|
|
748
|
+
if (stat2.isDirectory()) {
|
|
749
|
+
try {
|
|
750
|
+
assertRealPathUnderRoot(projectRoot, abs);
|
|
751
|
+
} catch {
|
|
752
|
+
issues.push({
|
|
753
|
+
path: relative2(projectRoot, abs),
|
|
754
|
+
message: `Source directory escapes project root: ${relative2(projectRoot, abs)}`,
|
|
755
|
+
severity: "error"
|
|
756
|
+
});
|
|
757
|
+
continue;
|
|
758
|
+
}
|
|
686
759
|
walk(abs);
|
|
687
760
|
} else if (SCANNABLE_EXTENSIONS.some((ext) => entry.endsWith(ext))) {
|
|
761
|
+
try {
|
|
762
|
+
assertRealPathUnderRoot(projectRoot, abs);
|
|
763
|
+
} catch {
|
|
764
|
+
issues.push({
|
|
765
|
+
path: relative2(projectRoot, abs),
|
|
766
|
+
message: `Source file escapes project root: ${relative2(projectRoot, abs)}`,
|
|
767
|
+
severity: "error"
|
|
768
|
+
});
|
|
769
|
+
continue;
|
|
770
|
+
}
|
|
688
771
|
results.push(relative2(projectRoot, abs));
|
|
689
772
|
}
|
|
690
773
|
}
|
|
@@ -692,20 +775,69 @@ function collectSourceUnderSrc(projectRoot) {
|
|
|
692
775
|
walk(srcDir);
|
|
693
776
|
return results;
|
|
694
777
|
}
|
|
695
|
-
function readAppSources(projectRoot, appSources) {
|
|
696
|
-
return appSources.map((rel) =>
|
|
778
|
+
function readAppSources(projectRoot, appSources, issues, customSourcesProvided) {
|
|
779
|
+
return appSources.map((rel) => {
|
|
780
|
+
if (!isSafeRelativeSpaPath(rel)) {
|
|
781
|
+
if (customSourcesProvided) {
|
|
782
|
+
issues.push({
|
|
783
|
+
path: rel,
|
|
784
|
+
message: `Unsafe appSources path skipped: ${rel}`,
|
|
785
|
+
severity: "warning"
|
|
786
|
+
});
|
|
787
|
+
}
|
|
788
|
+
return null;
|
|
789
|
+
}
|
|
790
|
+
const abs = join2(projectRoot, rel);
|
|
791
|
+
try {
|
|
792
|
+
assertRealPathUnderRoot(projectRoot, abs);
|
|
793
|
+
if (existsSync2(abs) && lstatSync(abs).isSymbolicLink()) {
|
|
794
|
+
issues.push({
|
|
795
|
+
path: rel,
|
|
796
|
+
message: `appSources path is a symlink: ${rel}`,
|
|
797
|
+
severity: "error"
|
|
798
|
+
});
|
|
799
|
+
return null;
|
|
800
|
+
}
|
|
801
|
+
} catch {
|
|
802
|
+
issues.push({
|
|
803
|
+
path: rel,
|
|
804
|
+
message: `appSources path escapes project root: ${rel}`,
|
|
805
|
+
severity: "error"
|
|
806
|
+
});
|
|
807
|
+
return null;
|
|
808
|
+
}
|
|
809
|
+
if (!existsSync2(abs)) return null;
|
|
810
|
+
return readFileSync(abs, "utf8");
|
|
811
|
+
}).filter((content) => content != null).join("\n");
|
|
697
812
|
}
|
|
698
813
|
function stripComments(source) {
|
|
699
814
|
return source.replace(/\/\*[\s\S]*?\*\//g, " ").replace(/\/\/[^\n]*/g, " ");
|
|
700
815
|
}
|
|
701
|
-
function
|
|
702
|
-
return [
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
816
|
+
function maskUnrelatedStringLiterals(source) {
|
|
817
|
+
return source.replace(/(["'`])(?:\\.|(?!\1).)*\1/g, (match, _quote, offset, full) => {
|
|
818
|
+
const before = full.slice(Math.max(0, offset - 24), offset);
|
|
819
|
+
if (/\b(?:courseId|checkId|lessonId)\s*=\s*$/.test(before)) {
|
|
820
|
+
return match;
|
|
821
|
+
}
|
|
822
|
+
return '""';
|
|
823
|
+
});
|
|
824
|
+
}
|
|
825
|
+
function idPropPresent(source, prop, id) {
|
|
826
|
+
const stripped = stripComments(source);
|
|
827
|
+
const masked = maskUnrelatedStringLiterals(stripped);
|
|
828
|
+
return jsxPropRegex(prop, id).test(masked);
|
|
829
|
+
}
|
|
830
|
+
function escapeRegExp(value) {
|
|
831
|
+
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
832
|
+
}
|
|
833
|
+
function jsxPropRegex(prop, id) {
|
|
834
|
+
const escapedId = escapeRegExp(id);
|
|
835
|
+
return new RegExp(
|
|
836
|
+
`(?<![A-Za-z0-9_$])${prop}\\s*=\\s*(?:"${escapedId}"|'${escapedId}'|\\{\\s*["'\`]${escapedId}["'\`]\\s*\\}|\\{\\s*\`${escapedId}\`\\s*\\})`
|
|
837
|
+
);
|
|
838
|
+
}
|
|
839
|
+
function maskStringLiterals(source) {
|
|
840
|
+
return source.replace(/(["'`])(?:\\.|(?!\1).)*\1/g, '""');
|
|
709
841
|
}
|
|
710
842
|
function extractStringConstants(source) {
|
|
711
843
|
const stripped = stripComments(source);
|
|
@@ -716,7 +848,9 @@ function extractStringConstants(source) {
|
|
|
716
848
|
}
|
|
717
849
|
return map;
|
|
718
850
|
}
|
|
719
|
-
function idUsedViaConstant(
|
|
851
|
+
function idUsedViaConstant(source, prop, id, constants) {
|
|
852
|
+
const stripped = stripComments(source);
|
|
853
|
+
const masked = maskStringLiterals(stripped);
|
|
720
854
|
for (const [name, value] of constants) {
|
|
721
855
|
if (value !== id) continue;
|
|
722
856
|
const jsxPatterns = [
|
|
@@ -725,40 +859,61 @@ function idUsedViaConstant(stripped, prop, id, constants) {
|
|
|
725
859
|
`${prop}={${name} }`,
|
|
726
860
|
`${prop}={ ${name}}`
|
|
727
861
|
];
|
|
728
|
-
if (jsxPatterns.some((p) =>
|
|
729
|
-
const objPatterns = [`${prop}: ${name}`, `${prop}:${name}`];
|
|
730
|
-
if (objPatterns.some((p) => stripped.includes(p))) return true;
|
|
862
|
+
if (jsxPatterns.some((p) => masked.includes(p))) return true;
|
|
731
863
|
}
|
|
732
864
|
return false;
|
|
733
865
|
}
|
|
734
|
-
function
|
|
866
|
+
function lessonIdInDataLiteral(source, lessonId) {
|
|
735
867
|
const stripped = stripComments(source);
|
|
736
|
-
|
|
737
|
-
return
|
|
868
|
+
const escaped = escapeRegExp(lessonId);
|
|
869
|
+
return new RegExp(`\\bid\\s*:\\s*["'\`]${escaped}["'\`]`).test(stripped);
|
|
738
870
|
}
|
|
739
|
-
function
|
|
871
|
+
function lessonIdPresent(source, lessonId) {
|
|
872
|
+
if (idPropPresent(source, "lessonId", lessonId)) return true;
|
|
873
|
+
if (idUsedViaConstant(source, "lessonId", lessonId, extractStringConstants(source))) return true;
|
|
874
|
+
return lessonIdInDataLiteral(source, lessonId);
|
|
875
|
+
}
|
|
876
|
+
function courseConfigCourseIdPresent(source, courseId) {
|
|
740
877
|
const stripped = stripComments(source);
|
|
741
|
-
|
|
742
|
-
|
|
878
|
+
const escaped = escapeRegExp(courseId);
|
|
879
|
+
const literalPattern = new RegExp(
|
|
880
|
+
`(?<![A-Za-z0-9_$])courseId\\s*:\\s*(?:"${escaped}"|'${escaped}')`
|
|
881
|
+
);
|
|
882
|
+
if (literalPattern.test(stripped)) return true;
|
|
883
|
+
return idUsedViaConstant(source, "courseId", courseId, extractStringConstants(source));
|
|
884
|
+
}
|
|
885
|
+
function courseIdPresent(source, courseId) {
|
|
886
|
+
if (idPropPresent(source, "courseId", courseId)) return true;
|
|
887
|
+
if (idUsedViaConstant(source, "courseId", courseId, extractStringConstants(source))) return true;
|
|
888
|
+
return courseConfigCourseIdPresent(source, courseId);
|
|
889
|
+
}
|
|
890
|
+
function checkIdPresent(source, checkId) {
|
|
891
|
+
if (idPropPresent(source, "checkId", checkId)) return true;
|
|
892
|
+
return idUsedViaConstant(source, "checkId", checkId, extractStringConstants(source));
|
|
743
893
|
}
|
|
744
894
|
var ID_SYNC_DOC = "https://lessonkit.readthedocs.io/en/latest/guides/react-developers/quickstart.html#keep-react-ids-in-sync-with-lessonkitjson";
|
|
745
895
|
function parityHint(message) {
|
|
746
896
|
return `${message} See ${ID_SYNC_DOC}`;
|
|
747
897
|
}
|
|
748
898
|
function validateReactManifestParity(opts) {
|
|
749
|
-
const
|
|
750
|
-
const
|
|
899
|
+
const issues = [];
|
|
900
|
+
const customSourcesProvided = opts.appSources !== void 0;
|
|
901
|
+
const appSources = opts.appSources ?? collectSourceUnderSrc(opts.projectRoot, issues);
|
|
902
|
+
const source = readAppSources(
|
|
903
|
+
opts.projectRoot,
|
|
904
|
+
appSources,
|
|
905
|
+
issues,
|
|
906
|
+
customSourcesProvided
|
|
907
|
+
);
|
|
751
908
|
const hasDescriptorIds = Boolean(opts.descriptor.courseId) || (opts.descriptor.assessments?.length ?? 0) > 0;
|
|
752
909
|
if (!source.trim()) {
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
];
|
|
910
|
+
issues.push({
|
|
911
|
+
path: appSources.length > 0 ? appSources.join(", ") : "src/",
|
|
912
|
+
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",
|
|
913
|
+
severity: hasDescriptorIds ? "error" : "warning"
|
|
914
|
+
});
|
|
915
|
+
return issues;
|
|
760
916
|
}
|
|
761
|
-
const issues = [];
|
|
762
917
|
const courseId = opts.descriptor.courseId;
|
|
763
918
|
if (!courseIdPresent(source, courseId)) {
|
|
764
919
|
issues.push({
|
|
@@ -769,6 +924,19 @@ function validateReactManifestParity(opts) {
|
|
|
769
924
|
severity: "error"
|
|
770
925
|
});
|
|
771
926
|
}
|
|
927
|
+
for (const lesson of opts.descriptor.lessons ?? []) {
|
|
928
|
+
const lessonId = lesson.id;
|
|
929
|
+
if (!lessonId) continue;
|
|
930
|
+
if (!lessonIdPresent(source, lessonId)) {
|
|
931
|
+
issues.push({
|
|
932
|
+
path: `lessons.id:${lessonId}`,
|
|
933
|
+
message: parityHint(
|
|
934
|
+
`React app source missing lessonId="${lessonId}" declared in lessonkit.json.`
|
|
935
|
+
),
|
|
936
|
+
severity: "error"
|
|
937
|
+
});
|
|
938
|
+
}
|
|
939
|
+
}
|
|
772
940
|
for (const assessment of opts.descriptor.assessments ?? []) {
|
|
773
941
|
const checkId = assessment.checkId;
|
|
774
942
|
if (!checkId) continue;
|
|
@@ -787,7 +955,13 @@ function validateReactManifestParity(opts) {
|
|
|
787
955
|
|
|
788
956
|
// src/validateProjectPaths.ts
|
|
789
957
|
import { isAbsolute as isAbsolute2, resolve as resolve2 } from "path";
|
|
790
|
-
|
|
958
|
+
var RESERVED_OUTPUT_SEGMENTS = /* @__PURE__ */ new Set([".git", "node_modules", ".github"]);
|
|
959
|
+
function isReservedOutputPath(value) {
|
|
960
|
+
const normalized = value.replace(/\\/g, "/").replace(/^\/+|\/+$/g, "");
|
|
961
|
+
const segments = normalized.split("/").filter(Boolean);
|
|
962
|
+
return segments.some((segment) => RESERVED_OUTPUT_SEGMENTS.has(segment));
|
|
963
|
+
}
|
|
964
|
+
function validatePathField(value, fieldPath, projectRoot, issues, options) {
|
|
791
965
|
if (!isSafeRelativeSpaPath(value)) {
|
|
792
966
|
issues.push({
|
|
793
967
|
path: fieldPath,
|
|
@@ -795,6 +969,13 @@ function validatePathField(value, fieldPath, projectRoot, issues) {
|
|
|
795
969
|
});
|
|
796
970
|
return;
|
|
797
971
|
}
|
|
972
|
+
if (options?.rejectReserved && isReservedOutputPath(value)) {
|
|
973
|
+
issues.push({
|
|
974
|
+
path: fieldPath,
|
|
975
|
+
message: "path must not target reserved directories (.git, node_modules, .github)"
|
|
976
|
+
});
|
|
977
|
+
return;
|
|
978
|
+
}
|
|
798
979
|
try {
|
|
799
980
|
assertRealPathUnderRoot(projectRoot, resolve2(projectRoot, value));
|
|
800
981
|
} catch {
|
|
@@ -811,10 +992,14 @@ function validateProjectPaths(projectRoot, paths) {
|
|
|
811
992
|
validatePathField(paths.spaDistDir.trim(), "paths.spaDistDir", root, issues);
|
|
812
993
|
}
|
|
813
994
|
if (paths.lxpackOutDir?.trim()) {
|
|
814
|
-
validatePathField(paths.lxpackOutDir.trim(), "paths.lxpackOutDir", root, issues
|
|
995
|
+
validatePathField(paths.lxpackOutDir.trim(), "paths.lxpackOutDir", root, issues, {
|
|
996
|
+
rejectReserved: true
|
|
997
|
+
});
|
|
815
998
|
}
|
|
816
999
|
if (paths.outputBaseDir?.trim()) {
|
|
817
|
-
validatePathField(paths.outputBaseDir.trim(), "paths.outputBaseDir", root, issues
|
|
1000
|
+
validatePathField(paths.outputBaseDir.trim(), "paths.outputBaseDir", root, issues, {
|
|
1001
|
+
rejectReserved: true
|
|
1002
|
+
});
|
|
818
1003
|
}
|
|
819
1004
|
return issues;
|
|
820
1005
|
}
|
|
@@ -827,11 +1012,17 @@ function resolveSafePackageOutputOverride(projectRoot, override) {
|
|
|
827
1012
|
if (isAbsolute2(trimmed)) {
|
|
828
1013
|
const resolved2 = resolve2(trimmed);
|
|
829
1014
|
assertRealPathUnderRoot(root, resolved2);
|
|
1015
|
+
if (isReservedOutputPath(trimmed)) {
|
|
1016
|
+
throw new Error(`unsafe output path: ${override} targets a reserved directory`);
|
|
1017
|
+
}
|
|
830
1018
|
return resolved2;
|
|
831
1019
|
}
|
|
832
1020
|
if (!isSafeRelativeSpaPath(trimmed)) {
|
|
833
1021
|
throw new Error(`unsafe output path: ${override}`);
|
|
834
1022
|
}
|
|
1023
|
+
if (isReservedOutputPath(trimmed)) {
|
|
1024
|
+
throw new Error(`unsafe output path: ${override} targets a reserved directory`);
|
|
1025
|
+
}
|
|
835
1026
|
const resolved = resolve2(root, trimmed);
|
|
836
1027
|
assertRealPathUnderRoot(root, resolved);
|
|
837
1028
|
return resolved;
|
|
@@ -1009,8 +1200,11 @@ async function walkDistDir(rootReal, current, label) {
|
|
|
1009
1200
|
let entryReal;
|
|
1010
1201
|
try {
|
|
1011
1202
|
entryReal = realpathSync2(entryPath);
|
|
1012
|
-
} catch {
|
|
1013
|
-
|
|
1203
|
+
} catch (err) {
|
|
1204
|
+
throw new Error(
|
|
1205
|
+
`spa dist for "${label}" could not resolve path: ${entryPath}`,
|
|
1206
|
+
{ cause: err }
|
|
1207
|
+
);
|
|
1014
1208
|
}
|
|
1015
1209
|
assertResolvedPathUnderRoot(rootReal, entryReal);
|
|
1016
1210
|
if (stat2.isDirectory()) {
|
|
@@ -1033,9 +1227,7 @@ async function writeLxpackProject(options) {
|
|
|
1033
1227
|
throw new Error(injectableIssues.map((i) => `${i.path}: ${i.message}`).join("; "));
|
|
1034
1228
|
}
|
|
1035
1229
|
const outDir = resolve4(options.outDir);
|
|
1036
|
-
|
|
1037
|
-
assertRealPathUnderRoot(resolve4(options.projectRoot), outDir);
|
|
1038
|
-
}
|
|
1230
|
+
assertRealPathUnderRoot(resolve4(options.projectRoot), outDir);
|
|
1039
1231
|
const spaDirs = await resolveSpaDirs({ ...options, descriptor });
|
|
1040
1232
|
await assertSpaDistContentsSafe(spaDirs, options.projectRoot);
|
|
1041
1233
|
const interchange = descriptorToInterchange(descriptor);
|
|
@@ -1234,21 +1426,36 @@ function promoteLockPath(outDir) {
|
|
|
1234
1426
|
const hash = createHash("sha256").update(resolve6(outDir)).digest("hex").slice(0, 16);
|
|
1235
1427
|
return join7(parent, `.lk-promote-lock-${hash}`);
|
|
1236
1428
|
}
|
|
1237
|
-
var
|
|
1429
|
+
var STALE_ARTIFACT_TTL_MS = 5 * 60 * 1e3;
|
|
1430
|
+
var MAX_LOCK_AGE_MS = 30 * 60 * 1e3;
|
|
1431
|
+
var LOCK_TOKEN_RE = /^(\d+)\n([0-9a-f-]{36})(?:\n(\d+))?\n?$/i;
|
|
1238
1432
|
async function isStalePromoteLock(lockPath) {
|
|
1239
1433
|
try {
|
|
1434
|
+
const stat2 = await fsp.stat(lockPath);
|
|
1240
1435
|
const content = await fsp.readFile(lockPath, "utf8");
|
|
1241
|
-
const
|
|
1242
|
-
|
|
1243
|
-
|
|
1244
|
-
|
|
1245
|
-
|
|
1246
|
-
|
|
1247
|
-
return true;
|
|
1436
|
+
const match = content.match(LOCK_TOKEN_RE);
|
|
1437
|
+
let lockAgeMs = Date.now() - stat2.mtimeMs;
|
|
1438
|
+
if (match?.[3]) {
|
|
1439
|
+
const startedAt = Number.parseInt(match[3], 10);
|
|
1440
|
+
if (Number.isFinite(startedAt) && startedAt > 0) {
|
|
1441
|
+
lockAgeMs = Date.now() - startedAt;
|
|
1248
1442
|
}
|
|
1249
1443
|
}
|
|
1250
|
-
|
|
1251
|
-
|
|
1444
|
+
if (lockAgeMs > MAX_LOCK_AGE_MS) {
|
|
1445
|
+
return true;
|
|
1446
|
+
}
|
|
1447
|
+
if (match) {
|
|
1448
|
+
const pid = Number.parseInt(match[1], 10);
|
|
1449
|
+
if (Number.isFinite(pid) && pid > 0) {
|
|
1450
|
+
try {
|
|
1451
|
+
process.kill(pid, 0);
|
|
1452
|
+
return false;
|
|
1453
|
+
} catch {
|
|
1454
|
+
return true;
|
|
1455
|
+
}
|
|
1456
|
+
}
|
|
1457
|
+
}
|
|
1458
|
+
return lockAgeMs > STALE_ARTIFACT_TTL_MS;
|
|
1252
1459
|
} catch {
|
|
1253
1460
|
return true;
|
|
1254
1461
|
}
|
|
@@ -1261,6 +1468,8 @@ async function withPromoteLock(outDir, fn) {
|
|
|
1261
1468
|
try {
|
|
1262
1469
|
lockHandle = await fsp.open(lockPath, "wx");
|
|
1263
1470
|
await lockHandle.writeFile(`${process.pid}
|
|
1471
|
+
${randomUUID()}
|
|
1472
|
+
${Date.now()}
|
|
1264
1473
|
`, "utf8");
|
|
1265
1474
|
break;
|
|
1266
1475
|
} catch (err) {
|
|
@@ -1292,22 +1501,77 @@ async function withPromoteLock(outDir, fn) {
|
|
|
1292
1501
|
);
|
|
1293
1502
|
}
|
|
1294
1503
|
}
|
|
1295
|
-
async function
|
|
1504
|
+
async function removeStaleLegacyPromoteArtifacts(outDir) {
|
|
1296
1505
|
const legacyTmp = `${outDir}.tmp-promote`;
|
|
1297
1506
|
const legacyBak = `${outDir}.bak`;
|
|
1298
|
-
const
|
|
1299
|
-
|
|
1300
|
-
|
|
1301
|
-
|
|
1507
|
+
const blocked = [];
|
|
1508
|
+
for (const legacyPath of [legacyTmp, legacyBak]) {
|
|
1509
|
+
if (!await pathExists(legacyPath)) continue;
|
|
1510
|
+
try {
|
|
1511
|
+
const stat2 = await fsp.stat(legacyPath);
|
|
1512
|
+
if (Date.now() - stat2.mtimeMs > STALE_ARTIFACT_TTL_MS) {
|
|
1513
|
+
await fsp.rm(legacyPath, { recursive: true, force: true }).catch(
|
|
1514
|
+
/* v8 ignore next */
|
|
1515
|
+
() => void 0
|
|
1516
|
+
);
|
|
1517
|
+
continue;
|
|
1518
|
+
}
|
|
1519
|
+
} catch {
|
|
1520
|
+
}
|
|
1521
|
+
blocked.push(legacyPath);
|
|
1522
|
+
}
|
|
1523
|
+
if (blocked.length) {
|
|
1524
|
+
const rmHint = blocked.map((p) => `rm -rf ${JSON.stringify(p)}`).join("; ");
|
|
1302
1525
|
throw new Error(
|
|
1303
|
-
`[lessonkit/lxpack] cannot promote: remove stale packaging artifacts from a previous failed run: ${
|
|
1526
|
+
`[lessonkit/lxpack] cannot promote: remove stale packaging artifacts from a previous failed run: ${blocked.join(", ")}. Try: ${rmHint}`
|
|
1304
1527
|
);
|
|
1305
1528
|
}
|
|
1306
1529
|
}
|
|
1307
|
-
async function
|
|
1530
|
+
async function listRelativePaths(root, dir = root) {
|
|
1531
|
+
const entries = await fsp.readdir(dir, { withFileTypes: true });
|
|
1532
|
+
const paths = [];
|
|
1533
|
+
for (const entry of entries) {
|
|
1534
|
+
const full = join7(dir, entry.name);
|
|
1535
|
+
if (entry.isDirectory()) {
|
|
1536
|
+
paths.push(...await listRelativePaths(root, full));
|
|
1537
|
+
} else if (entry.isFile()) {
|
|
1538
|
+
paths.push(full.slice(root.length + 1));
|
|
1539
|
+
} else {
|
|
1540
|
+
}
|
|
1541
|
+
}
|
|
1542
|
+
return paths;
|
|
1543
|
+
}
|
|
1544
|
+
async function mergePreservedOutArtifacts(priorArtifactsDir, destArtifactsDir, newArtifactPaths) {
|
|
1545
|
+
if (!await pathExists(priorArtifactsDir)) return;
|
|
1546
|
+
for (const rel of await listRelativePaths(priorArtifactsDir)) {
|
|
1547
|
+
if (newArtifactPaths.has(rel)) continue;
|
|
1548
|
+
const src = join7(priorArtifactsDir, rel);
|
|
1549
|
+
const dest = join7(destArtifactsDir, rel);
|
|
1550
|
+
await fsp.mkdir(dirname(dest), { recursive: true });
|
|
1551
|
+
await fsp.cp(src, dest, { force: true });
|
|
1552
|
+
}
|
|
1553
|
+
}
|
|
1554
|
+
async function promoteStagingToOutDir(stagingDir, outDir, options) {
|
|
1555
|
+
const outputBaseDir = options?.outputBaseDir ?? ".lxpack/out";
|
|
1556
|
+
if (options?.projectRoot) {
|
|
1557
|
+
assertRealPathUnderRoot(resolve6(options.projectRoot), resolve6(outDir));
|
|
1558
|
+
}
|
|
1308
1559
|
return withPromoteLock(outDir, async () => {
|
|
1309
|
-
await
|
|
1560
|
+
await removeStaleLegacyPromoteArtifacts(outDir);
|
|
1561
|
+
const stagingArtifactsDir = join7(stagingDir, outputBaseDir);
|
|
1562
|
+
const newArtifactPaths = /* @__PURE__ */ new Set();
|
|
1563
|
+
if (await pathExists(stagingArtifactsDir)) {
|
|
1564
|
+
for (const rel of await listRelativePaths(stagingArtifactsDir)) {
|
|
1565
|
+
newArtifactPaths.add(rel);
|
|
1566
|
+
}
|
|
1567
|
+
}
|
|
1310
1568
|
const parent = dirname(outDir);
|
|
1569
|
+
let priorArtifactsBackup;
|
|
1570
|
+
const existingArtifactsDir = join7(outDir, outputBaseDir);
|
|
1571
|
+
if (await pathExists(outDir) && await pathExists(existingArtifactsDir)) {
|
|
1572
|
+
priorArtifactsBackup = await fsp.mkdtemp(join7(parent, ".lk-prior-out-"));
|
|
1573
|
+
await fsp.cp(existingArtifactsDir, join7(priorArtifactsBackup, outputBaseDir), { recursive: true });
|
|
1574
|
+
}
|
|
1311
1575
|
const tmpPromote = await fsp.mkdtemp(join7(parent, ".lk-promote-"));
|
|
1312
1576
|
await renameOrCopy(stagingDir, tmpPromote);
|
|
1313
1577
|
const hadOutDir = await pathExists(outDir);
|
|
@@ -1364,6 +1628,20 @@ async function promoteStagingToOutDir(stagingDir, outDir) {
|
|
|
1364
1628
|
}
|
|
1365
1629
|
throw promoteError;
|
|
1366
1630
|
}
|
|
1631
|
+
if (priorArtifactsBackup) {
|
|
1632
|
+
try {
|
|
1633
|
+
await mergePreservedOutArtifacts(
|
|
1634
|
+
join7(priorArtifactsBackup, outputBaseDir),
|
|
1635
|
+
join7(outDir, outputBaseDir),
|
|
1636
|
+
newArtifactPaths
|
|
1637
|
+
);
|
|
1638
|
+
} finally {
|
|
1639
|
+
await fsp.rm(priorArtifactsBackup, { recursive: true, force: true }).catch(
|
|
1640
|
+
/* v8 ignore next */
|
|
1641
|
+
() => void 0
|
|
1642
|
+
);
|
|
1643
|
+
}
|
|
1644
|
+
}
|
|
1367
1645
|
if (backup) {
|
|
1368
1646
|
await fsp.rm(backup, { recursive: true, force: true }).catch(
|
|
1369
1647
|
/* v8 ignore next */
|
|
@@ -1381,6 +1659,7 @@ import { packageLessonkit } from "@lxpack/api";
|
|
|
1381
1659
|
async function buildStagingPackage(options) {
|
|
1382
1660
|
const { target, output, dir, outputBaseDir, descriptor, ...writeOpts } = options;
|
|
1383
1661
|
const stagingDir = await fsp2.mkdtemp(join8(tmpdir(), "lessonkit-lxpack-"));
|
|
1662
|
+
let succeeded = false;
|
|
1384
1663
|
try {
|
|
1385
1664
|
let spaDirs;
|
|
1386
1665
|
try {
|
|
@@ -1436,6 +1715,7 @@ async function buildStagingPackage(options) {
|
|
|
1436
1715
|
}))
|
|
1437
1716
|
};
|
|
1438
1717
|
}
|
|
1718
|
+
succeeded = true;
|
|
1439
1719
|
return {
|
|
1440
1720
|
ok: true,
|
|
1441
1721
|
stagingDir,
|
|
@@ -1449,6 +1729,13 @@ async function buildStagingPackage(options) {
|
|
|
1449
1729
|
() => void 0
|
|
1450
1730
|
);
|
|
1451
1731
|
throw err;
|
|
1732
|
+
} finally {
|
|
1733
|
+
if (!succeeded) {
|
|
1734
|
+
await fsp2.rm(stagingDir, { recursive: true, force: true }).catch(
|
|
1735
|
+
/* v8 ignore next */
|
|
1736
|
+
() => void 0
|
|
1737
|
+
);
|
|
1738
|
+
}
|
|
1452
1739
|
}
|
|
1453
1740
|
}
|
|
1454
1741
|
async function ensureOutDirParent(outDir) {
|
|
@@ -1513,24 +1800,22 @@ async function packageLessonkitCourse(options) {
|
|
|
1513
1800
|
};
|
|
1514
1801
|
}
|
|
1515
1802
|
const descriptor = descriptorValidation.descriptor;
|
|
1516
|
-
|
|
1517
|
-
|
|
1518
|
-
|
|
1519
|
-
|
|
1520
|
-
|
|
1521
|
-
|
|
1522
|
-
|
|
1523
|
-
|
|
1524
|
-
|
|
1525
|
-
|
|
1526
|
-
|
|
1527
|
-
|
|
1528
|
-
|
|
1529
|
-
|
|
1530
|
-
|
|
1531
|
-
|
|
1532
|
-
};
|
|
1533
|
-
}
|
|
1803
|
+
const parityIssues = validateReactManifestParity({
|
|
1804
|
+
projectRoot: writeOpts.projectRoot,
|
|
1805
|
+
descriptor
|
|
1806
|
+
});
|
|
1807
|
+
const parityFailures = writeOpts.strictParity ? parityIssues : parityIssues.filter((i) => i.severity === "error");
|
|
1808
|
+
if (parityFailures.length > 0) {
|
|
1809
|
+
return {
|
|
1810
|
+
ok: false,
|
|
1811
|
+
courseDir: outDir,
|
|
1812
|
+
target,
|
|
1813
|
+
issues: parityFailures.map((i) => ({
|
|
1814
|
+
path: i.path,
|
|
1815
|
+
message: i.message,
|
|
1816
|
+
severity: i.severity
|
|
1817
|
+
}))
|
|
1818
|
+
};
|
|
1534
1819
|
}
|
|
1535
1820
|
const staged = await buildStagingPackage({
|
|
1536
1821
|
...writeOpts,
|
|
@@ -1589,7 +1874,7 @@ async function packageLessonkitCourse(options) {
|
|
|
1589
1874
|
ok: false,
|
|
1590
1875
|
courseDir: outDir,
|
|
1591
1876
|
target,
|
|
1592
|
-
validation: { ok:
|
|
1877
|
+
validation: { ok: false, manifest: build.manifest, issues: build.issues },
|
|
1593
1878
|
build,
|
|
1594
1879
|
issues: artifactIssues
|
|
1595
1880
|
};
|
|
@@ -1603,7 +1888,10 @@ async function packageLessonkitCourse(options) {
|
|
|
1603
1888
|
};
|
|
1604
1889
|
try {
|
|
1605
1890
|
await ensureOutDirParent(outDir);
|
|
1606
|
-
await promoteStagingToOutDir(stagingDir, outDir
|
|
1891
|
+
await promoteStagingToOutDir(stagingDir, outDir, {
|
|
1892
|
+
outputBaseDir: outputBaseDir ?? ".lxpack/out",
|
|
1893
|
+
projectRoot: writeOpts.projectRoot
|
|
1894
|
+
});
|
|
1607
1895
|
} catch (err) {
|
|
1608
1896
|
await fsp3.rm(stagingDir, { recursive: true, force: true }).catch(
|
|
1609
1897
|
/* v8 ignore next */
|
|
@@ -1726,6 +2014,20 @@ function parseLessonkitManifest(raw, label = "lessonkit.json", projectRoot) {
|
|
|
1726
2014
|
message: `"course.spaDistDir" (${courseSpaDistDir}) differs from "paths.spaDistDir" (${paths.spaDistDir}). Use paths.spaDistDir for CLI build and package.`
|
|
1727
2015
|
});
|
|
1728
2016
|
}
|
|
2017
|
+
for (const key of ["spaDistDir", "lxpackOutDir", "outputBaseDir"]) {
|
|
2018
|
+
const value = paths[key];
|
|
2019
|
+
if (!isSafeRelativeSpaPath(value)) {
|
|
2020
|
+
issues.push({
|
|
2021
|
+
path: `paths.${key}`,
|
|
2022
|
+
message: "path must be relative without '..' segments or absolute prefixes"
|
|
2023
|
+
});
|
|
2024
|
+
} else if ((key === "lxpackOutDir" || key === "outputBaseDir") && isReservedOutputPath(value)) {
|
|
2025
|
+
issues.push({
|
|
2026
|
+
path: `paths.${key}`,
|
|
2027
|
+
message: "path must not target reserved directories (.git, node_modules, .github)"
|
|
2028
|
+
});
|
|
2029
|
+
}
|
|
2030
|
+
}
|
|
1729
2031
|
if (projectRoot) {
|
|
1730
2032
|
const pathIssues = validateProjectPaths(projectRoot, paths);
|
|
1731
2033
|
for (const pi of pathIssues) {
|
|
@@ -1770,6 +2072,7 @@ export {
|
|
|
1770
2072
|
buildStagingPackage,
|
|
1771
2073
|
descriptorToInterchange,
|
|
1772
2074
|
ensureOutDirParent,
|
|
2075
|
+
escapeShellText,
|
|
1773
2076
|
extractAssessments,
|
|
1774
2077
|
lessonkitInterchangeSchema,
|
|
1775
2078
|
loadLessonkitManifestFromFile,
|