@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/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 === "fillInBlanks" && !assessment.template?.trim()) {
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, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;").replace(/'/g, "&#39;");
631
+ }
632
+ function decodeShellEntities(text) {
633
+ return text.replace(/&amp;/gi, "&").replace(/&lt;/gi, "<").replace(/&gt;/gi, ">").replace(/&quot;/gi, '"').replace(/&#39;/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: assessment.checkId,
664
+ id: checkId,
618
665
  passingScore: assessment.passingScore ?? 1,
619
666
  questions: [
620
667
  {
621
668
  id: "q1",
622
- prompt: assessment.question,
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" && assessment.kind === "findHotspot") {
646
- return mcqToLxpack({
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
- if (target === "xapi" || target === "cmi5") {
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.assessments ?? []).forEach((assessment, index) => {
688
- if (assessmentDescriptorToLxpack(assessment) === null) {
689
- issues.push({
690
- path: `assessments[${index}]`,
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
- if ((0, import_node_fs2.statSync)(abs).isDirectory()) {
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) => (0, import_node_path2.join)(projectRoot, rel)).filter((abs) => (0, import_node_fs2.existsSync)(abs)).map((abs) => (0, import_node_fs2.readFileSync)(abs, "utf8")).join("\n");
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 idPropPatterns(prop, id) {
750
- return [
751
- `${prop}="${id}"`,
752
- `${prop}='${id}'`,
753
- `${prop}={'${id}'}`,
754
- `${prop}={"${id}"}`,
755
- `${prop}={\`${id}\`}`
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(stripped, prop, id, constants) {
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) => stripped.includes(p))) return true;
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 courseIdPresent(source, courseId) {
927
+ function lessonIdInDataLiteral(source, lessonId) {
783
928
  const stripped = stripComments(source);
784
- if (idPropPatterns("courseId", courseId).some((p) => stripped.includes(p))) return true;
785
- return idUsedViaConstant(stripped, "courseId", courseId, extractStringConstants(source));
929
+ const escaped = escapeRegExp(lessonId);
930
+ return new RegExp(`\\bid\\s*:\\s*["'\`]${escaped}["'\`]`).test(stripped);
786
931
  }
787
- function checkIdPresent(source, checkId) {
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
- if (idPropPatterns("checkId", checkId).some((p) => stripped.includes(p))) return true;
790
- return idUsedViaConstant(stripped, "checkId", checkId, extractStringConstants(source));
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 appSources = opts.appSources ?? collectSourceUnderSrc(opts.projectRoot);
794
- const source = readAppSources(opts.projectRoot, appSources);
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
- return [
798
- {
799
- path: appSources.length > 0 ? appSources.join(", ") : "src/",
800
- message: hasDescriptorIds ? "React app source not found for ID parity check" : "React app source not found for ID parity check",
801
- severity: hasDescriptorIds ? "error" : "warning"
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: `React app source does not reference courseId="${courseId}" from lessonkit.json`,
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: `React app source missing checkId="${checkId}" declared in lessonkit.json`,
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
- function validatePathField(value, fieldPath, projectRoot, issues) {
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 import_node_path5 = require("path");
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 outDir = (0, import_node_path5.resolve)(options.outDir);
1019
- if (options.projectRoot) {
1020
- assertRealPathUnderRoot((0, import_node_path5.resolve)(options.projectRoot), outDir);
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, import_node_path5.join)(courseDir, "course.yaml"),
1039
- lessonkitJsonPath: (0, import_node_path5.join)(courseDir, "lessonkit.json")
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 import_node_path9 = require("path");
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 import_node_path6 = require("path");
1320
+ var import_node_path7 = require("path");
1050
1321
  function validatePackageInputs(options) {
1051
1322
  const { target, output, outputBaseDir } = options;
1052
- const outDir = (0, import_node_path6.resolve)(options.outDir);
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, import_node_path6.resolve)(options.projectRoot);
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, import_node_path6.isAbsolute)(output)) {
1360
+ if ((0, import_node_path7.isAbsolute)(output)) {
1090
1361
  try {
1091
- assertRealPathUnderRoot(projectRoot, (0, import_node_path6.resolve)(output));
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, import_node_path6.resolve)(projectRoot, outputBaseDir);
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, import_node_path6.isAbsolute)(output) ? (0, import_node_path6.resolve)(output) : (0, import_node_path6.resolve)(projectRoot, output);
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, import_node_path6.isAbsolute)(rel)) {
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 import_node_path6.win32.join(outDir, rel.replace(/\//g, import_node_path6.win32.sep));
1455
+ return import_node_path7.win32.join(outDir, rel.replace(/\//g, import_node_path7.win32.sep));
1185
1456
  }
1186
- return (0, import_node_path6.join)(outDir, rel);
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 import_node_path7 = require("path");
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, import_node_path7.dirname)(outDir);
1213
- const hash = (0, import_node_crypto.createHash)("sha256").update((0, import_node_path7.resolve)(outDir)).digest("hex").slice(0, 16);
1214
- return (0, import_node_path7.join)(parent, `.lk-promote-lock-${hash}`);
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 STALE_LOCK_TTL_MS = 5 * 60 * 1e3;
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 pid = Number.parseInt(content.trim(), 10);
1221
- if (Number.isFinite(pid) && pid > 0) {
1222
- try {
1223
- process.kill(pid, 0);
1224
- return false;
1225
- } catch {
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
- const stat2 = await fsp.stat(lockPath);
1230
- return Date.now() - stat2.mtimeMs > STALE_LOCK_TTL_MS;
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, import_node_path7.dirname)(outDir), { recursive: true });
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 assertNoLegacyPromoteArtifacts(outDir) {
1562
+ async function removeStaleLegacyPromoteArtifacts(outDir) {
1275
1563
  const legacyTmp = `${outDir}.tmp-promote`;
1276
1564
  const legacyBak = `${outDir}.bak`;
1277
- const stale = [];
1278
- if (await pathExists(legacyTmp)) stale.push(legacyTmp);
1279
- if (await pathExists(legacyBak)) stale.push(legacyBak);
1280
- if (stale.length) {
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: ${stale.join(", ")}`
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 promoteStagingToOutDir(stagingDir, outDir) {
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 assertNoLegacyPromoteArtifacts(outDir);
1289
- const parent = (0, import_node_path7.dirname)(outDir);
1290
- const tmpPromote = await fsp.mkdtemp((0, import_node_path7.join)(parent, ".lk-promote-"));
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, import_node_path7.join)(parent, ".lk-backup-")) : void 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, import_node_path7.join)(parent, `.lk-failed-promote-${(0, import_node_crypto.randomUUID)()}`);
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, import_node_path7.join)(parent, `.lk-failed-promote-${(0, import_node_crypto.randomUUID)()}`);
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 import_node_path8 = require("path");
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, import_node_path8.join)((0, import_node_os.tmpdir)(), "lessonkit-lxpack-"));
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, import_node_path8.join)(stagingDir, outputBase), { recursive: true });
1381
- const defaultOutput = output ?? (dir ? (0, import_node_path8.join)(outputBase, target) : (0, import_node_path8.join)(outputBase, `course-${target}.zip`));
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, import_node_path8.dirname)(outDir), { recursive: true });
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, import_node_path9.resolve)(options.courseDir),
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, import_node_path9.resolve)(options.courseDir),
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, import_node_path9.resolve)(writeOpts.outDir),
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
- if (writeOpts.projectRoot) {
1483
- const parityIssues = validateReactManifestParity({
1484
- projectRoot: writeOpts.projectRoot,
1485
- descriptor
1486
- });
1487
- const parityErrors = parityIssues.filter((i) => i.severity === "error");
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: nonInjectableAssessments.map(({ assessment, index }) => ({
1508
- path: `assessments[${index}]`,
1509
- message: `assessment kind "${assessment.kind ?? "mcq"}" (checkId "${assessment.checkId}") is not injected into LMS shell quizzes for target "${target}"`
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: true, manifest: build.manifest, issues: build.issues },
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 SUPPORTED = new Set(import_tracking_schema.LESSONKIT_TELEMETRY_EVENTS);
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) || isQuizCompletedData(data)) {
1760
- mapped.assessmentId = data.checkId;
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
- } else if (name === "interaction" && event.data && isInteractionData(event.data)) {
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,