@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.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";
|
|
@@ -315,6 +315,10 @@ var validateMcqLike = (assessment, path, issues) => {
|
|
|
315
315
|
} else if (trimmedChoices.length && !trimmedChoices.includes(assessment.answer.trim())) {
|
|
316
316
|
issues.push({ path: `${path}.answer`, message: "answer must match a choice" });
|
|
317
317
|
}
|
|
318
|
+
const uniqueChoices = new Set(trimmedChoices);
|
|
319
|
+
if (trimmedChoices.length !== uniqueChoices.size) {
|
|
320
|
+
issues.push({ path: `${path}.choices`, message: "choices must be unique" });
|
|
321
|
+
}
|
|
318
322
|
};
|
|
319
323
|
function countStarDelimitedBlanks(template) {
|
|
320
324
|
const matches = template.match(/\*[^*]+\*/g);
|
|
@@ -340,8 +344,30 @@ var ASSESSMENT_VALIDATORS = {
|
|
|
340
344
|
}
|
|
341
345
|
},
|
|
342
346
|
fillInBlanks: (assessment, path, issues) => {
|
|
343
|
-
if (assessment.kind
|
|
347
|
+
if (assessment.kind !== "fillInBlanks") return;
|
|
348
|
+
if (!assessment.template?.trim()) {
|
|
344
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
|
+
});
|
|
345
371
|
}
|
|
346
372
|
},
|
|
347
373
|
findHotspot: (assessment, path, issues) => {
|
|
@@ -539,27 +565,47 @@ function validateCourseDescriptor(input) {
|
|
|
539
565
|
}
|
|
540
566
|
|
|
541
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
|
+
}
|
|
542
582
|
function slugChoiceId(text, index) {
|
|
543
583
|
const base = text.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "").slice(0, 32);
|
|
544
584
|
const stem = base.length ? base : "choice";
|
|
545
585
|
return `${stem}-${index + 1}`;
|
|
546
586
|
}
|
|
547
587
|
function mcqToLxpack(assessment) {
|
|
588
|
+
const checkId = sanitizeShellField(assessment.checkId);
|
|
589
|
+
const prompt = sanitizeShellField(assessment.question);
|
|
590
|
+
if (!checkId || !prompt) return null;
|
|
548
591
|
const choices = assessment.choices.map((text, index) => {
|
|
592
|
+
const sanitizedText = sanitizeShellField(text);
|
|
593
|
+
if (!sanitizedText) return null;
|
|
549
594
|
const id = slugChoiceId(text, index);
|
|
550
595
|
return {
|
|
551
596
|
id,
|
|
552
|
-
text,
|
|
597
|
+
text: sanitizedText,
|
|
553
598
|
correct: text === assessment.answer
|
|
554
599
|
};
|
|
555
600
|
});
|
|
601
|
+
if (choices.some((choice) => choice === null)) return null;
|
|
556
602
|
return {
|
|
557
|
-
id:
|
|
603
|
+
id: checkId,
|
|
558
604
|
passingScore: assessment.passingScore ?? 1,
|
|
559
605
|
questions: [
|
|
560
606
|
{
|
|
561
607
|
id: "q1",
|
|
562
|
-
prompt
|
|
608
|
+
prompt,
|
|
563
609
|
choices
|
|
564
610
|
}
|
|
565
611
|
]
|
|
@@ -582,15 +628,8 @@ function assessmentDescriptorToLxpack(assessment) {
|
|
|
582
628
|
if (kind === "fillInBlanks") {
|
|
583
629
|
return null;
|
|
584
630
|
}
|
|
585
|
-
if (kind === "findHotspot"
|
|
586
|
-
return
|
|
587
|
-
kind: "mcq",
|
|
588
|
-
checkId: assessment.checkId,
|
|
589
|
-
question: assessment.question,
|
|
590
|
-
choices: [assessment.correctTargetId, "other"],
|
|
591
|
-
answer: assessment.correctTargetId,
|
|
592
|
-
passingScore: assessment.passingScore
|
|
593
|
-
});
|
|
631
|
+
if (kind === "findHotspot") {
|
|
632
|
+
return null;
|
|
594
633
|
}
|
|
595
634
|
if (kind === "findMultipleHotspots") {
|
|
596
635
|
return null;
|
|
@@ -604,6 +643,20 @@ function extractAssessments(descriptor) {
|
|
|
604
643
|
return (descriptor.assessments ?? []).map(assessmentDescriptorToLxpack).filter((a) => a !== null);
|
|
605
644
|
}
|
|
606
645
|
|
|
646
|
+
// src/descriptor/validateInjectableAssessments.ts
|
|
647
|
+
function validateInjectableAssessments(descriptor) {
|
|
648
|
+
const issues = [];
|
|
649
|
+
(descriptor.assessments ?? []).forEach((assessment, index) => {
|
|
650
|
+
if (assessmentDescriptorToLxpack(assessment) === null) {
|
|
651
|
+
issues.push({
|
|
652
|
+
path: `assessments[${index}]`,
|
|
653
|
+
message: `assessment kind "${assessment.kind ?? "mcq"}" (checkId "${assessment.checkId}") is not injected into LMS shell quizzes`
|
|
654
|
+
});
|
|
655
|
+
}
|
|
656
|
+
});
|
|
657
|
+
return issues;
|
|
658
|
+
}
|
|
659
|
+
|
|
607
660
|
// src/descriptor/validateForTarget.ts
|
|
608
661
|
var LMS_SHELL_TARGETS = /* @__PURE__ */ new Set([
|
|
609
662
|
"scorm12",
|
|
@@ -612,26 +665,34 @@ var LMS_SHELL_TARGETS = /* @__PURE__ */ new Set([
|
|
|
612
665
|
"xapi",
|
|
613
666
|
"cmi5"
|
|
614
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
|
+
}
|
|
615
688
|
function validateDescriptorForExportTarget(descriptor, target) {
|
|
616
689
|
const issues = [];
|
|
617
|
-
|
|
618
|
-
const activityIri = descriptor.tracking?.xapi?.activityIri?.trim();
|
|
619
|
-
if (!activityIri) {
|
|
620
|
-
issues.push({
|
|
621
|
-
path: "course.tracking.xapi.activityIri",
|
|
622
|
-
message: "tracking.xapi.activityIri is required for xapi and cmi5 export targets"
|
|
623
|
-
});
|
|
624
|
-
}
|
|
625
|
-
}
|
|
690
|
+
appendActivityIriIssues(issues, descriptor, target);
|
|
626
691
|
if (LMS_SHELL_TARGETS.has(target)) {
|
|
627
|
-
(descriptor
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
message: `assessment kind "${assessment.kind ?? "mcq"}" (checkId "${assessment.checkId}") is not injected into LMS shell quizzes for target "${target}"`
|
|
632
|
-
});
|
|
633
|
-
}
|
|
634
|
-
});
|
|
692
|
+
issues.push(...validateInjectableAssessments(descriptor).map((issue) => ({
|
|
693
|
+
...issue,
|
|
694
|
+
message: `${issue.message} for target "${target}"`
|
|
695
|
+
})));
|
|
635
696
|
}
|
|
636
697
|
return issues;
|
|
637
698
|
}
|
|
@@ -660,19 +721,53 @@ function validateDescriptorForTarget(input, target) {
|
|
|
660
721
|
}
|
|
661
722
|
|
|
662
723
|
// src/validateReactParity.ts
|
|
663
|
-
import { readFileSync, existsSync as existsSync2, readdirSync,
|
|
724
|
+
import { readFileSync, existsSync as existsSync2, readdirSync, lstatSync } from "fs";
|
|
664
725
|
import { join as join2, relative as relative2 } from "path";
|
|
665
726
|
var SCANNABLE_EXTENSIONS = [".tsx", ".ts", ".jsx", ".js"];
|
|
666
|
-
function collectSourceUnderSrc(projectRoot) {
|
|
727
|
+
function collectSourceUnderSrc(projectRoot, issues) {
|
|
667
728
|
const srcDir = join2(projectRoot, "src");
|
|
668
729
|
if (!existsSync2(srcDir)) return [];
|
|
669
730
|
const results = [];
|
|
670
731
|
const walk = (dir) => {
|
|
671
732
|
for (const entry of readdirSync(dir)) {
|
|
672
733
|
const abs = join2(dir, entry);
|
|
673
|
-
|
|
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
|
+
}
|
|
674
759
|
walk(abs);
|
|
675
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
|
+
}
|
|
676
771
|
results.push(relative2(projectRoot, abs));
|
|
677
772
|
}
|
|
678
773
|
}
|
|
@@ -680,20 +775,69 @@ function collectSourceUnderSrc(projectRoot) {
|
|
|
680
775
|
walk(srcDir);
|
|
681
776
|
return results;
|
|
682
777
|
}
|
|
683
|
-
function readAppSources(projectRoot, appSources) {
|
|
684
|
-
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");
|
|
685
812
|
}
|
|
686
813
|
function stripComments(source) {
|
|
687
814
|
return source.replace(/\/\*[\s\S]*?\*\//g, " ").replace(/\/\/[^\n]*/g, " ");
|
|
688
815
|
}
|
|
689
|
-
function
|
|
690
|
-
return [
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
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, '""');
|
|
697
841
|
}
|
|
698
842
|
function extractStringConstants(source) {
|
|
699
843
|
const stripped = stripComments(source);
|
|
@@ -704,7 +848,9 @@ function extractStringConstants(source) {
|
|
|
704
848
|
}
|
|
705
849
|
return map;
|
|
706
850
|
}
|
|
707
|
-
function idUsedViaConstant(
|
|
851
|
+
function idUsedViaConstant(source, prop, id, constants) {
|
|
852
|
+
const stripped = stripComments(source);
|
|
853
|
+
const masked = maskStringLiterals(stripped);
|
|
708
854
|
for (const [name, value] of constants) {
|
|
709
855
|
if (value !== id) continue;
|
|
710
856
|
const jsxPatterns = [
|
|
@@ -713,51 +859,93 @@ function idUsedViaConstant(stripped, prop, id, constants) {
|
|
|
713
859
|
`${prop}={${name} }`,
|
|
714
860
|
`${prop}={ ${name}}`
|
|
715
861
|
];
|
|
716
|
-
if (jsxPatterns.some((p) =>
|
|
717
|
-
const objPatterns = [`${prop}: ${name}`, `${prop}:${name}`];
|
|
718
|
-
if (objPatterns.some((p) => stripped.includes(p))) return true;
|
|
862
|
+
if (jsxPatterns.some((p) => masked.includes(p))) return true;
|
|
719
863
|
}
|
|
720
864
|
return false;
|
|
721
865
|
}
|
|
722
|
-
function
|
|
866
|
+
function lessonIdInDataLiteral(source, lessonId) {
|
|
723
867
|
const stripped = stripComments(source);
|
|
724
|
-
|
|
725
|
-
return
|
|
868
|
+
const escaped = escapeRegExp(lessonId);
|
|
869
|
+
return new RegExp(`\\bid\\s*:\\s*["'\`]${escaped}["'\`]`).test(stripped);
|
|
726
870
|
}
|
|
727
|
-
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) {
|
|
728
877
|
const stripped = stripComments(source);
|
|
729
|
-
|
|
730
|
-
|
|
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));
|
|
893
|
+
}
|
|
894
|
+
var ID_SYNC_DOC = "https://lessonkit.readthedocs.io/en/latest/guides/react-developers/quickstart.html#keep-react-ids-in-sync-with-lessonkitjson";
|
|
895
|
+
function parityHint(message) {
|
|
896
|
+
return `${message} See ${ID_SYNC_DOC}`;
|
|
731
897
|
}
|
|
732
898
|
function validateReactManifestParity(opts) {
|
|
733
|
-
const
|
|
734
|
-
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
|
+
);
|
|
735
908
|
const hasDescriptorIds = Boolean(opts.descriptor.courseId) || (opts.descriptor.assessments?.length ?? 0) > 0;
|
|
736
909
|
if (!source.trim()) {
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
];
|
|
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;
|
|
744
916
|
}
|
|
745
|
-
const issues = [];
|
|
746
917
|
const courseId = opts.descriptor.courseId;
|
|
747
918
|
if (!courseIdPresent(source, courseId)) {
|
|
748
919
|
issues.push({
|
|
749
920
|
path: "course.courseId",
|
|
750
|
-
message:
|
|
921
|
+
message: parityHint(
|
|
922
|
+
`React app source does not reference courseId="${courseId}" from lessonkit.json.`
|
|
923
|
+
),
|
|
751
924
|
severity: "error"
|
|
752
925
|
});
|
|
753
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
|
+
}
|
|
754
940
|
for (const assessment of opts.descriptor.assessments ?? []) {
|
|
755
941
|
const checkId = assessment.checkId;
|
|
756
942
|
if (!checkId) continue;
|
|
757
943
|
if (!checkIdPresent(source, checkId)) {
|
|
758
944
|
issues.push({
|
|
759
945
|
path: `assessments.checkId:${checkId}`,
|
|
760
|
-
message:
|
|
946
|
+
message: parityHint(
|
|
947
|
+
`React app source missing checkId="${checkId}" declared in lessonkit.json.`
|
|
948
|
+
),
|
|
761
949
|
severity: "error"
|
|
762
950
|
});
|
|
763
951
|
}
|
|
@@ -767,7 +955,13 @@ function validateReactManifestParity(opts) {
|
|
|
767
955
|
|
|
768
956
|
// src/validateProjectPaths.ts
|
|
769
957
|
import { isAbsolute as isAbsolute2, resolve as resolve2 } from "path";
|
|
770
|
-
|
|
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) {
|
|
771
965
|
if (!isSafeRelativeSpaPath(value)) {
|
|
772
966
|
issues.push({
|
|
773
967
|
path: fieldPath,
|
|
@@ -775,6 +969,13 @@ function validatePathField(value, fieldPath, projectRoot, issues) {
|
|
|
775
969
|
});
|
|
776
970
|
return;
|
|
777
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
|
+
}
|
|
778
979
|
try {
|
|
779
980
|
assertRealPathUnderRoot(projectRoot, resolve2(projectRoot, value));
|
|
780
981
|
} catch {
|
|
@@ -791,10 +992,14 @@ function validateProjectPaths(projectRoot, paths) {
|
|
|
791
992
|
validatePathField(paths.spaDistDir.trim(), "paths.spaDistDir", root, issues);
|
|
792
993
|
}
|
|
793
994
|
if (paths.lxpackOutDir?.trim()) {
|
|
794
|
-
validatePathField(paths.lxpackOutDir.trim(), "paths.lxpackOutDir", root, issues
|
|
995
|
+
validatePathField(paths.lxpackOutDir.trim(), "paths.lxpackOutDir", root, issues, {
|
|
996
|
+
rejectReserved: true
|
|
997
|
+
});
|
|
795
998
|
}
|
|
796
999
|
if (paths.outputBaseDir?.trim()) {
|
|
797
|
-
validatePathField(paths.outputBaseDir.trim(), "paths.outputBaseDir", root, issues
|
|
1000
|
+
validatePathField(paths.outputBaseDir.trim(), "paths.outputBaseDir", root, issues, {
|
|
1001
|
+
rejectReserved: true
|
|
1002
|
+
});
|
|
798
1003
|
}
|
|
799
1004
|
return issues;
|
|
800
1005
|
}
|
|
@@ -807,11 +1012,17 @@ function resolveSafePackageOutputOverride(projectRoot, override) {
|
|
|
807
1012
|
if (isAbsolute2(trimmed)) {
|
|
808
1013
|
const resolved2 = resolve2(trimmed);
|
|
809
1014
|
assertRealPathUnderRoot(root, resolved2);
|
|
1015
|
+
if (isReservedOutputPath(trimmed)) {
|
|
1016
|
+
throw new Error(`unsafe output path: ${override} targets a reserved directory`);
|
|
1017
|
+
}
|
|
810
1018
|
return resolved2;
|
|
811
1019
|
}
|
|
812
1020
|
if (!isSafeRelativeSpaPath(trimmed)) {
|
|
813
1021
|
throw new Error(`unsafe output path: ${override}`);
|
|
814
1022
|
}
|
|
1023
|
+
if (isReservedOutputPath(trimmed)) {
|
|
1024
|
+
throw new Error(`unsafe output path: ${override} targets a reserved directory`);
|
|
1025
|
+
}
|
|
815
1026
|
const resolved = resolve2(root, trimmed);
|
|
816
1027
|
assertRealPathUnderRoot(root, resolved);
|
|
817
1028
|
return resolved;
|
|
@@ -888,7 +1099,7 @@ function descriptorToInterchange(descriptor) {
|
|
|
888
1099
|
}
|
|
889
1100
|
|
|
890
1101
|
// src/writeProject.ts
|
|
891
|
-
import { join as
|
|
1102
|
+
import { join as join5, resolve as resolve4 } from "path";
|
|
892
1103
|
import { materializeLessonkitProject } from "@lxpack/validators";
|
|
893
1104
|
|
|
894
1105
|
// src/spaDirs.ts
|
|
@@ -946,6 +1157,62 @@ async function resolveSpaDirs(options) {
|
|
|
946
1157
|
return dirs;
|
|
947
1158
|
}
|
|
948
1159
|
|
|
1160
|
+
// src/spaDistValidation.ts
|
|
1161
|
+
import { lstat, readdir } from "fs/promises";
|
|
1162
|
+
import { realpathSync as realpathSync2 } from "fs";
|
|
1163
|
+
import { join as join4 } from "path";
|
|
1164
|
+
async function assertSpaDistContentsSafe(spaDirs, projectRoot) {
|
|
1165
|
+
for (const [label, dir] of Object.entries(spaDirs)) {
|
|
1166
|
+
const dirResolved = resolveComparablePath(dir);
|
|
1167
|
+
const dirStat = await lstat(dirResolved);
|
|
1168
|
+
if (dirStat.isSymbolicLink()) {
|
|
1169
|
+
throw new Error(`spa dist for "${label}" cannot be a symlink: ${dir}`);
|
|
1170
|
+
}
|
|
1171
|
+
let rootReal;
|
|
1172
|
+
try {
|
|
1173
|
+
rootReal = realpathSync2(dirResolved);
|
|
1174
|
+
} catch {
|
|
1175
|
+
throw new Error(`spa dist for "${label}" is not readable: ${dir}`);
|
|
1176
|
+
}
|
|
1177
|
+
if (projectRoot) {
|
|
1178
|
+
assertRealPathUnderRoot(projectRoot, dir);
|
|
1179
|
+
}
|
|
1180
|
+
assertResolvedPathUnderRoot(rootReal, rootReal);
|
|
1181
|
+
await walkDistDir(rootReal, rootReal, label);
|
|
1182
|
+
}
|
|
1183
|
+
}
|
|
1184
|
+
async function walkDistDir(rootReal, current, label) {
|
|
1185
|
+
let entries;
|
|
1186
|
+
try {
|
|
1187
|
+
entries = await readdir(current, { withFileTypes: true });
|
|
1188
|
+
} catch (err) {
|
|
1189
|
+
throw new Error(
|
|
1190
|
+
`spa dist for "${label}" is not readable: ${err instanceof Error ? err.message : String(err)}`,
|
|
1191
|
+
{ cause: err }
|
|
1192
|
+
);
|
|
1193
|
+
}
|
|
1194
|
+
for (const entry of entries) {
|
|
1195
|
+
const entryPath = join4(current, entry.name);
|
|
1196
|
+
const stat2 = await lstat(entryPath);
|
|
1197
|
+
if (stat2.isSymbolicLink()) {
|
|
1198
|
+
throw new Error(`spa dist for "${label}" contains symlink: ${entryPath}`);
|
|
1199
|
+
}
|
|
1200
|
+
let entryReal;
|
|
1201
|
+
try {
|
|
1202
|
+
entryReal = realpathSync2(entryPath);
|
|
1203
|
+
} catch (err) {
|
|
1204
|
+
throw new Error(
|
|
1205
|
+
`spa dist for "${label}" could not resolve path: ${entryPath}`,
|
|
1206
|
+
{ cause: err }
|
|
1207
|
+
);
|
|
1208
|
+
}
|
|
1209
|
+
assertResolvedPathUnderRoot(rootReal, entryReal);
|
|
1210
|
+
if (stat2.isDirectory()) {
|
|
1211
|
+
await walkDistDir(rootReal, entryPath, label);
|
|
1212
|
+
}
|
|
1213
|
+
}
|
|
1214
|
+
}
|
|
1215
|
+
|
|
949
1216
|
// src/writeProject.ts
|
|
950
1217
|
async function writeLxpackProject(options) {
|
|
951
1218
|
const validation = validateDescriptor(options.descriptor);
|
|
@@ -955,11 +1222,14 @@ async function writeLxpackProject(options) {
|
|
|
955
1222
|
);
|
|
956
1223
|
}
|
|
957
1224
|
const descriptor = validation.descriptor;
|
|
958
|
-
const
|
|
959
|
-
if (
|
|
960
|
-
|
|
1225
|
+
const injectableIssues = validateInjectableAssessments(descriptor);
|
|
1226
|
+
if (injectableIssues.length > 0) {
|
|
1227
|
+
throw new Error(injectableIssues.map((i) => `${i.path}: ${i.message}`).join("; "));
|
|
961
1228
|
}
|
|
1229
|
+
const outDir = resolve4(options.outDir);
|
|
1230
|
+
assertRealPathUnderRoot(resolve4(options.projectRoot), outDir);
|
|
962
1231
|
const spaDirs = await resolveSpaDirs({ ...options, descriptor });
|
|
1232
|
+
await assertSpaDistContentsSafe(spaDirs, options.projectRoot);
|
|
963
1233
|
const interchange = descriptorToInterchange(descriptor);
|
|
964
1234
|
const materialized = await materializeLessonkitProject({
|
|
965
1235
|
interchange,
|
|
@@ -975,8 +1245,8 @@ async function writeLxpackProject(options) {
|
|
|
975
1245
|
const courseDir = materialized.courseDir;
|
|
976
1246
|
return {
|
|
977
1247
|
outDir: courseDir,
|
|
978
|
-
courseYamlPath:
|
|
979
|
-
lessonkitJsonPath:
|
|
1248
|
+
courseYamlPath: join5(courseDir, "course.yaml"),
|
|
1249
|
+
lessonkitJsonPath: join5(courseDir, "lessonkit.json")
|
|
980
1250
|
};
|
|
981
1251
|
}
|
|
982
1252
|
|
|
@@ -989,7 +1259,7 @@ import {
|
|
|
989
1259
|
} from "@lxpack/api";
|
|
990
1260
|
|
|
991
1261
|
// src/packaging/validateInputs.ts
|
|
992
|
-
import { isAbsolute as isAbsolute3, join as
|
|
1262
|
+
import { isAbsolute as isAbsolute3, join as join6, resolve as resolve5, win32 as win322 } from "path";
|
|
993
1263
|
function validatePackageInputs(options) {
|
|
994
1264
|
const { target, output, outputBaseDir } = options;
|
|
995
1265
|
const outDir = resolve5(options.outDir);
|
|
@@ -1126,13 +1396,13 @@ function remapArtifactPaths(stagingRoot, outDir, artifactPath) {
|
|
|
1126
1396
|
if (/^[a-zA-Z]:[/\\]/.test(outDir)) {
|
|
1127
1397
|
return win322.join(outDir, rel.replace(/\//g, win322.sep));
|
|
1128
1398
|
}
|
|
1129
|
-
return
|
|
1399
|
+
return join6(outDir, rel);
|
|
1130
1400
|
}
|
|
1131
1401
|
|
|
1132
1402
|
// src/packaging/promote.ts
|
|
1133
1403
|
import * as fsp from "fs/promises";
|
|
1134
1404
|
import { createHash, randomUUID } from "crypto";
|
|
1135
|
-
import { dirname, join as
|
|
1405
|
+
import { dirname, join as join7, resolve as resolve6 } from "path";
|
|
1136
1406
|
async function pathExists(path) {
|
|
1137
1407
|
try {
|
|
1138
1408
|
await fsp.access(path);
|
|
@@ -1154,23 +1424,38 @@ async function renameOrCopy(from, to) {
|
|
|
1154
1424
|
function promoteLockPath(outDir) {
|
|
1155
1425
|
const parent = dirname(outDir);
|
|
1156
1426
|
const hash = createHash("sha256").update(resolve6(outDir)).digest("hex").slice(0, 16);
|
|
1157
|
-
return
|
|
1427
|
+
return join7(parent, `.lk-promote-lock-${hash}`);
|
|
1158
1428
|
}
|
|
1159
|
-
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;
|
|
1160
1432
|
async function isStalePromoteLock(lockPath) {
|
|
1161
1433
|
try {
|
|
1434
|
+
const stat2 = await fsp.stat(lockPath);
|
|
1162
1435
|
const content = await fsp.readFile(lockPath, "utf8");
|
|
1163
|
-
const
|
|
1164
|
-
|
|
1165
|
-
|
|
1166
|
-
|
|
1167
|
-
|
|
1168
|
-
|
|
1169
|
-
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;
|
|
1170
1442
|
}
|
|
1171
1443
|
}
|
|
1172
|
-
|
|
1173
|
-
|
|
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;
|
|
1174
1459
|
} catch {
|
|
1175
1460
|
return true;
|
|
1176
1461
|
}
|
|
@@ -1183,6 +1468,8 @@ async function withPromoteLock(outDir, fn) {
|
|
|
1183
1468
|
try {
|
|
1184
1469
|
lockHandle = await fsp.open(lockPath, "wx");
|
|
1185
1470
|
await lockHandle.writeFile(`${process.pid}
|
|
1471
|
+
${randomUUID()}
|
|
1472
|
+
${Date.now()}
|
|
1186
1473
|
`, "utf8");
|
|
1187
1474
|
break;
|
|
1188
1475
|
} catch (err) {
|
|
@@ -1214,26 +1501,81 @@ async function withPromoteLock(outDir, fn) {
|
|
|
1214
1501
|
);
|
|
1215
1502
|
}
|
|
1216
1503
|
}
|
|
1217
|
-
async function
|
|
1504
|
+
async function removeStaleLegacyPromoteArtifacts(outDir) {
|
|
1218
1505
|
const legacyTmp = `${outDir}.tmp-promote`;
|
|
1219
1506
|
const legacyBak = `${outDir}.bak`;
|
|
1220
|
-
const
|
|
1221
|
-
|
|
1222
|
-
|
|
1223
|
-
|
|
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("; ");
|
|
1224
1525
|
throw new Error(
|
|
1225
|
-
`[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}`
|
|
1226
1527
|
);
|
|
1227
1528
|
}
|
|
1228
1529
|
}
|
|
1229
|
-
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
|
+
}
|
|
1230
1559
|
return withPromoteLock(outDir, async () => {
|
|
1231
|
-
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
|
+
}
|
|
1232
1568
|
const parent = dirname(outDir);
|
|
1233
|
-
|
|
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
|
+
}
|
|
1575
|
+
const tmpPromote = await fsp.mkdtemp(join7(parent, ".lk-promote-"));
|
|
1234
1576
|
await renameOrCopy(stagingDir, tmpPromote);
|
|
1235
1577
|
const hadOutDir = await pathExists(outDir);
|
|
1236
|
-
const backup = hadOutDir ? await fsp.mkdtemp(
|
|
1578
|
+
const backup = hadOutDir ? await fsp.mkdtemp(join7(parent, ".lk-backup-")) : void 0;
|
|
1237
1579
|
if (hadOutDir && backup) {
|
|
1238
1580
|
await renameOrCopy(outDir, backup);
|
|
1239
1581
|
}
|
|
@@ -1244,7 +1586,7 @@ async function promoteStagingToOutDir(stagingDir, outDir) {
|
|
|
1244
1586
|
try {
|
|
1245
1587
|
await renameOrCopy(backup, outDir);
|
|
1246
1588
|
} catch (restoreError) {
|
|
1247
|
-
const failedPromote2 =
|
|
1589
|
+
const failedPromote2 = join7(parent, `.lk-failed-promote-${randomUUID()}`);
|
|
1248
1590
|
try {
|
|
1249
1591
|
await renameOrCopy(tmpPromote, failedPromote2);
|
|
1250
1592
|
} catch {
|
|
@@ -1256,7 +1598,8 @@ async function promoteStagingToOutDir(stagingDir, outDir) {
|
|
|
1256
1598
|
const promoteMsg = promoteError instanceof Error ? promoteError.message : String(promoteError);
|
|
1257
1599
|
const restoreMsg = restoreError instanceof Error ? restoreError.message : String(restoreError);
|
|
1258
1600
|
throw new Error(
|
|
1259
|
-
`[lessonkit/lxpack] promote failed (${promoteMsg}) and could not restore ${outDir} (${restoreMsg}). Recovery: previous output may be in ${backup}; staged package may be in ${failedPromote2}
|
|
1601
|
+
`[lessonkit/lxpack] promote failed (${promoteMsg}) and could not restore ${outDir} (${restoreMsg}). Recovery: previous output may be in ${backup}; staged package may be in ${failedPromote2}.`,
|
|
1602
|
+
{ cause: restoreError }
|
|
1260
1603
|
);
|
|
1261
1604
|
}
|
|
1262
1605
|
} else {
|
|
@@ -1274,7 +1617,7 @@ async function promoteStagingToOutDir(stagingDir, outDir) {
|
|
|
1274
1617
|
}
|
|
1275
1618
|
throw promoteError;
|
|
1276
1619
|
}
|
|
1277
|
-
const failedPromote =
|
|
1620
|
+
const failedPromote = join7(parent, `.lk-failed-promote-${randomUUID()}`);
|
|
1278
1621
|
try {
|
|
1279
1622
|
await renameOrCopy(tmpPromote, failedPromote);
|
|
1280
1623
|
} catch {
|
|
@@ -1285,6 +1628,20 @@ async function promoteStagingToOutDir(stagingDir, outDir) {
|
|
|
1285
1628
|
}
|
|
1286
1629
|
throw promoteError;
|
|
1287
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
|
+
}
|
|
1288
1645
|
if (backup) {
|
|
1289
1646
|
await fsp.rm(backup, { recursive: true, force: true }).catch(
|
|
1290
1647
|
/* v8 ignore next */
|
|
@@ -1296,16 +1653,18 @@ async function promoteStagingToOutDir(stagingDir, outDir) {
|
|
|
1296
1653
|
|
|
1297
1654
|
// src/packaging/staging.ts
|
|
1298
1655
|
import * as fsp2 from "fs/promises";
|
|
1299
|
-
import { dirname as dirname2, join as
|
|
1656
|
+
import { dirname as dirname2, join as join8 } from "path";
|
|
1300
1657
|
import { tmpdir } from "os";
|
|
1301
1658
|
import { packageLessonkit } from "@lxpack/api";
|
|
1302
1659
|
async function buildStagingPackage(options) {
|
|
1303
1660
|
const { target, output, dir, outputBaseDir, descriptor, ...writeOpts } = options;
|
|
1304
|
-
const stagingDir = await fsp2.mkdtemp(
|
|
1661
|
+
const stagingDir = await fsp2.mkdtemp(join8(tmpdir(), "lessonkit-lxpack-"));
|
|
1662
|
+
let succeeded = false;
|
|
1305
1663
|
try {
|
|
1306
1664
|
let spaDirs;
|
|
1307
1665
|
try {
|
|
1308
1666
|
spaDirs = await resolveSpaDirs({ ...writeOpts, descriptor });
|
|
1667
|
+
await assertSpaDistContentsSafe(spaDirs, writeOpts.projectRoot);
|
|
1309
1668
|
} catch (err) {
|
|
1310
1669
|
return {
|
|
1311
1670
|
ok: false,
|
|
@@ -1318,10 +1677,21 @@ async function buildStagingPackage(options) {
|
|
|
1318
1677
|
]
|
|
1319
1678
|
};
|
|
1320
1679
|
}
|
|
1680
|
+
const injectableIssues = validateInjectableAssessments(descriptor);
|
|
1681
|
+
if (injectableIssues.length > 0) {
|
|
1682
|
+
return {
|
|
1683
|
+
ok: false,
|
|
1684
|
+
stagingDir,
|
|
1685
|
+
issues: injectableIssues.map((i) => ({
|
|
1686
|
+
path: i.path,
|
|
1687
|
+
message: i.message
|
|
1688
|
+
}))
|
|
1689
|
+
};
|
|
1690
|
+
}
|
|
1321
1691
|
const interchange = descriptorToInterchange(descriptor);
|
|
1322
1692
|
const outputBase = outputBaseDir ?? ".lxpack/out";
|
|
1323
|
-
await fsp2.mkdir(
|
|
1324
|
-
const defaultOutput = output ?? (dir ?
|
|
1693
|
+
await fsp2.mkdir(join8(stagingDir, outputBase), { recursive: true });
|
|
1694
|
+
const defaultOutput = output ?? (dir ? join8(outputBase, target) : join8(outputBase, `course-${target}.zip`));
|
|
1325
1695
|
const build = await packageLessonkit({
|
|
1326
1696
|
interchange,
|
|
1327
1697
|
spaDirs,
|
|
@@ -1345,6 +1715,7 @@ async function buildStagingPackage(options) {
|
|
|
1345
1715
|
}))
|
|
1346
1716
|
};
|
|
1347
1717
|
}
|
|
1718
|
+
succeeded = true;
|
|
1348
1719
|
return {
|
|
1349
1720
|
ok: true,
|
|
1350
1721
|
stagingDir,
|
|
@@ -1358,6 +1729,13 @@ async function buildStagingPackage(options) {
|
|
|
1358
1729
|
() => void 0
|
|
1359
1730
|
);
|
|
1360
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
|
+
}
|
|
1361
1739
|
}
|
|
1362
1740
|
}
|
|
1363
1741
|
async function ensureOutDirParent(outDir) {
|
|
@@ -1422,34 +1800,20 @@ async function packageLessonkitCourse(options) {
|
|
|
1422
1800
|
};
|
|
1423
1801
|
}
|
|
1424
1802
|
const descriptor = descriptorValidation.descriptor;
|
|
1425
|
-
|
|
1426
|
-
|
|
1427
|
-
|
|
1428
|
-
|
|
1429
|
-
|
|
1430
|
-
|
|
1431
|
-
if (parityErrors.length > 0) {
|
|
1432
|
-
return {
|
|
1433
|
-
ok: false,
|
|
1434
|
-
courseDir: outDir,
|
|
1435
|
-
target,
|
|
1436
|
-
issues: parityErrors.map((i) => ({
|
|
1437
|
-
path: i.path,
|
|
1438
|
-
message: i.message,
|
|
1439
|
-
severity: i.severity
|
|
1440
|
-
}))
|
|
1441
|
-
};
|
|
1442
|
-
}
|
|
1443
|
-
}
|
|
1444
|
-
const nonInjectableAssessments = (descriptor.assessments ?? []).map((assessment, index) => ({ assessment, index })).filter(({ assessment }) => assessmentDescriptorToLxpack(assessment) === null);
|
|
1445
|
-
if (nonInjectableAssessments.length > 0) {
|
|
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) {
|
|
1446
1809
|
return {
|
|
1447
1810
|
ok: false,
|
|
1448
1811
|
courseDir: outDir,
|
|
1449
1812
|
target,
|
|
1450
|
-
issues:
|
|
1451
|
-
path:
|
|
1452
|
-
message:
|
|
1813
|
+
issues: parityFailures.map((i) => ({
|
|
1814
|
+
path: i.path,
|
|
1815
|
+
message: i.message,
|
|
1816
|
+
severity: i.severity
|
|
1453
1817
|
}))
|
|
1454
1818
|
};
|
|
1455
1819
|
}
|
|
@@ -1510,7 +1874,7 @@ async function packageLessonkitCourse(options) {
|
|
|
1510
1874
|
ok: false,
|
|
1511
1875
|
courseDir: outDir,
|
|
1512
1876
|
target,
|
|
1513
|
-
validation: { ok:
|
|
1877
|
+
validation: { ok: false, manifest: build.manifest, issues: build.issues },
|
|
1514
1878
|
build,
|
|
1515
1879
|
issues: artifactIssues
|
|
1516
1880
|
};
|
|
@@ -1524,7 +1888,10 @@ async function packageLessonkitCourse(options) {
|
|
|
1524
1888
|
};
|
|
1525
1889
|
try {
|
|
1526
1890
|
await ensureOutDirParent(outDir);
|
|
1527
|
-
await promoteStagingToOutDir(stagingDir, outDir
|
|
1891
|
+
await promoteStagingToOutDir(stagingDir, outDir, {
|
|
1892
|
+
outputBaseDir: outputBaseDir ?? ".lxpack/out",
|
|
1893
|
+
projectRoot: writeOpts.projectRoot
|
|
1894
|
+
});
|
|
1528
1895
|
} catch (err) {
|
|
1529
1896
|
await fsp3.rm(stagingDir, { recursive: true, force: true }).catch(
|
|
1530
1897
|
/* v8 ignore next */
|
|
@@ -1647,6 +2014,20 @@ function parseLessonkitManifest(raw, label = "lessonkit.json", projectRoot) {
|
|
|
1647
2014
|
message: `"course.spaDistDir" (${courseSpaDistDir}) differs from "paths.spaDistDir" (${paths.spaDistDir}). Use paths.spaDistDir for CLI build and package.`
|
|
1648
2015
|
});
|
|
1649
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
|
+
}
|
|
1650
2031
|
if (projectRoot) {
|
|
1651
2032
|
const pathIssues = validateProjectPaths(projectRoot, paths);
|
|
1652
2033
|
for (const pi of pathIssues) {
|
|
@@ -1691,6 +2072,7 @@ export {
|
|
|
1691
2072
|
buildStagingPackage,
|
|
1692
2073
|
descriptorToInterchange,
|
|
1693
2074
|
ensureOutDirParent,
|
|
2075
|
+
escapeShellText,
|
|
1694
2076
|
extractAssessments,
|
|
1695
2077
|
lessonkitInterchangeSchema,
|
|
1696
2078
|
loadLessonkitManifestFromFile,
|