@lessonkit/lxpack 1.4.0 → 1.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/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,
@@ -404,8 +405,30 @@ var ASSESSMENT_VALIDATORS = {
404
405
  }
405
406
  },
406
407
  fillInBlanks: (assessment, path, issues) => {
407
- if (assessment.kind === "fillInBlanks" && !assessment.template?.trim()) {
408
+ if (assessment.kind !== "fillInBlanks") return;
409
+ if (!assessment.template?.trim()) {
408
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
+ });
409
432
  }
410
433
  },
411
434
  findHotspot: (assessment, path, issues) => {
@@ -603,27 +626,47 @@ function validateCourseDescriptor(input) {
603
626
  }
604
627
 
605
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
+ }
606
643
  function slugChoiceId(text, index) {
607
644
  const base = text.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "").slice(0, 32);
608
645
  const stem = base.length ? base : "choice";
609
646
  return `${stem}-${index + 1}`;
610
647
  }
611
648
  function mcqToLxpack(assessment) {
649
+ const checkId = sanitizeShellField(assessment.checkId);
650
+ const prompt = sanitizeShellField(assessment.question);
651
+ if (!checkId || !prompt) return null;
612
652
  const choices = assessment.choices.map((text, index) => {
653
+ const sanitizedText = sanitizeShellField(text);
654
+ if (!sanitizedText) return null;
613
655
  const id = slugChoiceId(text, index);
614
656
  return {
615
657
  id,
616
- text,
658
+ text: sanitizedText,
617
659
  correct: text === assessment.answer
618
660
  };
619
661
  });
662
+ if (choices.some((choice) => choice === null)) return null;
620
663
  return {
621
- id: assessment.checkId,
664
+ id: checkId,
622
665
  passingScore: assessment.passingScore ?? 1,
623
666
  questions: [
624
667
  {
625
668
  id: "q1",
626
- prompt: assessment.question,
669
+ prompt,
627
670
  choices
628
671
  }
629
672
  ]
@@ -683,22 +726,29 @@ var LMS_SHELL_TARGETS = /* @__PURE__ */ new Set([
683
726
  "xapi",
684
727
  "cmi5"
685
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
+ }
686
749
  function validateDescriptorForExportTarget(descriptor, target) {
687
750
  const issues = [];
688
- if (target === "xapi" || target === "cmi5") {
689
- const activityIri = descriptor.tracking?.xapi?.activityIri?.trim();
690
- if (!activityIri) {
691
- issues.push({
692
- path: "tracking.xapi.activityIri",
693
- message: "tracking.xapi.activityIri is required for xapi and cmi5 export targets"
694
- });
695
- } else if (!/^https:\/\/.+/i.test(activityIri)) {
696
- issues.push({
697
- path: "tracking.xapi.activityIri",
698
- message: "tracking.xapi.activityIri must be an HTTPS URL for xapi and cmi5 export targets"
699
- });
700
- }
701
- }
751
+ appendActivityIriIssues(issues, descriptor, target);
702
752
  if (LMS_SHELL_TARGETS.has(target)) {
703
753
  issues.push(...validateInjectableAssessments(descriptor).map((issue) => ({
704
754
  ...issue,
@@ -735,16 +785,50 @@ function validateDescriptorForTarget(input, target) {
735
785
  var import_node_fs2 = require("fs");
736
786
  var import_node_path2 = require("path");
737
787
  var SCANNABLE_EXTENSIONS = [".tsx", ".ts", ".jsx", ".js"];
738
- function collectSourceUnderSrc(projectRoot) {
788
+ function collectSourceUnderSrc(projectRoot, issues) {
739
789
  const srcDir = (0, import_node_path2.join)(projectRoot, "src");
740
790
  if (!(0, import_node_fs2.existsSync)(srcDir)) return [];
741
791
  const results = [];
742
792
  const walk = (dir) => {
743
793
  for (const entry of (0, import_node_fs2.readdirSync)(dir)) {
744
794
  const abs = (0, import_node_path2.join)(dir, entry);
745
- 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
+ }
746
820
  walk(abs);
747
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
+ }
748
832
  results.push((0, import_node_path2.relative)(projectRoot, abs));
749
833
  }
750
834
  }
@@ -752,20 +836,69 @@ function collectSourceUnderSrc(projectRoot) {
752
836
  walk(srcDir);
753
837
  return results;
754
838
  }
755
- function readAppSources(projectRoot, appSources) {
756
- 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");
757
873
  }
758
874
  function stripComments(source) {
759
875
  return source.replace(/\/\*[\s\S]*?\*\//g, " ").replace(/\/\/[^\n]*/g, " ");
760
876
  }
761
- function idPropPatterns(prop, id) {
762
- return [
763
- `${prop}="${id}"`,
764
- `${prop}='${id}'`,
765
- `${prop}={'${id}'}`,
766
- `${prop}={"${id}"}`,
767
- `${prop}={\`${id}\`}`
768
- ];
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, '""');
769
902
  }
770
903
  function extractStringConstants(source) {
771
904
  const stripped = stripComments(source);
@@ -776,7 +909,9 @@ function extractStringConstants(source) {
776
909
  }
777
910
  return map;
778
911
  }
779
- function idUsedViaConstant(stripped, prop, id, constants) {
912
+ function idUsedViaConstant(source, prop, id, constants) {
913
+ const stripped = stripComments(source);
914
+ const masked = maskStringLiterals(stripped);
780
915
  for (const [name, value] of constants) {
781
916
  if (value !== id) continue;
782
917
  const jsxPatterns = [
@@ -785,40 +920,61 @@ function idUsedViaConstant(stripped, prop, id, constants) {
785
920
  `${prop}={${name} }`,
786
921
  `${prop}={ ${name}}`
787
922
  ];
788
- if (jsxPatterns.some((p) => stripped.includes(p))) return true;
789
- const objPatterns = [`${prop}: ${name}`, `${prop}:${name}`];
790
- if (objPatterns.some((p) => stripped.includes(p))) return true;
923
+ if (jsxPatterns.some((p) => masked.includes(p))) return true;
791
924
  }
792
925
  return false;
793
926
  }
794
- function courseIdPresent(source, courseId) {
927
+ function lessonIdInDataLiteral(source, lessonId) {
795
928
  const stripped = stripComments(source);
796
- if (idPropPatterns("courseId", courseId).some((p) => stripped.includes(p))) return true;
797
- return idUsedViaConstant(stripped, "courseId", courseId, extractStringConstants(source));
929
+ const escaped = escapeRegExp(lessonId);
930
+ return new RegExp(`\\bid\\s*:\\s*["'\`]${escaped}["'\`]`).test(stripped);
798
931
  }
799
- 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) {
800
938
  const stripped = stripComments(source);
801
- if (idPropPatterns("checkId", checkId).some((p) => stripped.includes(p))) return true;
802
- 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));
803
954
  }
804
955
  var ID_SYNC_DOC = "https://lessonkit.readthedocs.io/en/latest/guides/react-developers/quickstart.html#keep-react-ids-in-sync-with-lessonkitjson";
805
956
  function parityHint(message) {
806
957
  return `${message} See ${ID_SYNC_DOC}`;
807
958
  }
808
959
  function validateReactManifestParity(opts) {
809
- const appSources = opts.appSources ?? collectSourceUnderSrc(opts.projectRoot);
810
- 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
+ );
811
969
  const hasDescriptorIds = Boolean(opts.descriptor.courseId) || (opts.descriptor.assessments?.length ?? 0) > 0;
812
970
  if (!source.trim()) {
813
- return [
814
- {
815
- path: appSources.length > 0 ? appSources.join(", ") : "src/",
816
- message: hasDescriptorIds ? "React app source not found for ID parity check" : "React app source not found for ID parity check",
817
- severity: hasDescriptorIds ? "error" : "warning"
818
- }
819
- ];
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;
820
977
  }
821
- const issues = [];
822
978
  const courseId = opts.descriptor.courseId;
823
979
  if (!courseIdPresent(source, courseId)) {
824
980
  issues.push({
@@ -829,6 +985,19 @@ function validateReactManifestParity(opts) {
829
985
  severity: "error"
830
986
  });
831
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
+ }
832
1001
  for (const assessment of opts.descriptor.assessments ?? []) {
833
1002
  const checkId = assessment.checkId;
834
1003
  if (!checkId) continue;
@@ -847,7 +1016,13 @@ function validateReactManifestParity(opts) {
847
1016
 
848
1017
  // src/validateProjectPaths.ts
849
1018
  var import_node_path3 = require("path");
850
- 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) {
851
1026
  if (!isSafeRelativeSpaPath(value)) {
852
1027
  issues.push({
853
1028
  path: fieldPath,
@@ -855,6 +1030,13 @@ function validatePathField(value, fieldPath, projectRoot, issues) {
855
1030
  });
856
1031
  return;
857
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
+ }
858
1040
  try {
859
1041
  assertRealPathUnderRoot(projectRoot, (0, import_node_path3.resolve)(projectRoot, value));
860
1042
  } catch {
@@ -871,10 +1053,14 @@ function validateProjectPaths(projectRoot, paths) {
871
1053
  validatePathField(paths.spaDistDir.trim(), "paths.spaDistDir", root, issues);
872
1054
  }
873
1055
  if (paths.lxpackOutDir?.trim()) {
874
- validatePathField(paths.lxpackOutDir.trim(), "paths.lxpackOutDir", root, issues);
1056
+ validatePathField(paths.lxpackOutDir.trim(), "paths.lxpackOutDir", root, issues, {
1057
+ rejectReserved: true
1058
+ });
875
1059
  }
876
1060
  if (paths.outputBaseDir?.trim()) {
877
- validatePathField(paths.outputBaseDir.trim(), "paths.outputBaseDir", root, issues);
1061
+ validatePathField(paths.outputBaseDir.trim(), "paths.outputBaseDir", root, issues, {
1062
+ rejectReserved: true
1063
+ });
878
1064
  }
879
1065
  return issues;
880
1066
  }
@@ -887,11 +1073,17 @@ function resolveSafePackageOutputOverride(projectRoot, override) {
887
1073
  if ((0, import_node_path3.isAbsolute)(trimmed)) {
888
1074
  const resolved2 = (0, import_node_path3.resolve)(trimmed);
889
1075
  assertRealPathUnderRoot(root, resolved2);
1076
+ if (isReservedOutputPath(trimmed)) {
1077
+ throw new Error(`unsafe output path: ${override} targets a reserved directory`);
1078
+ }
890
1079
  return resolved2;
891
1080
  }
892
1081
  if (!isSafeRelativeSpaPath(trimmed)) {
893
1082
  throw new Error(`unsafe output path: ${override}`);
894
1083
  }
1084
+ if (isReservedOutputPath(trimmed)) {
1085
+ throw new Error(`unsafe output path: ${override} targets a reserved directory`);
1086
+ }
895
1087
  const resolved = (0, import_node_path3.resolve)(root, trimmed);
896
1088
  assertRealPathUnderRoot(root, resolved);
897
1089
  return resolved;
@@ -1069,8 +1261,11 @@ async function walkDistDir(rootReal, current, label) {
1069
1261
  let entryReal;
1070
1262
  try {
1071
1263
  entryReal = (0, import_node_fs3.realpathSync)(entryPath);
1072
- } catch {
1073
- entryReal = entryPath;
1264
+ } catch (err) {
1265
+ throw new Error(
1266
+ `spa dist for "${label}" could not resolve path: ${entryPath}`,
1267
+ { cause: err }
1268
+ );
1074
1269
  }
1075
1270
  assertResolvedPathUnderRoot(rootReal, entryReal);
1076
1271
  if (stat2.isDirectory()) {
@@ -1093,9 +1288,7 @@ async function writeLxpackProject(options) {
1093
1288
  throw new Error(injectableIssues.map((i) => `${i.path}: ${i.message}`).join("; "));
1094
1289
  }
1095
1290
  const outDir = (0, import_node_path6.resolve)(options.outDir);
1096
- if (options.projectRoot) {
1097
- assertRealPathUnderRoot((0, import_node_path6.resolve)(options.projectRoot), outDir);
1098
- }
1291
+ assertRealPathUnderRoot((0, import_node_path6.resolve)(options.projectRoot), outDir);
1099
1292
  const spaDirs = await resolveSpaDirs({ ...options, descriptor });
1100
1293
  await assertSpaDistContentsSafe(spaDirs, options.projectRoot);
1101
1294
  const interchange = descriptorToInterchange(descriptor);
@@ -1291,21 +1484,36 @@ function promoteLockPath(outDir) {
1291
1484
  const hash = (0, import_node_crypto.createHash)("sha256").update((0, import_node_path8.resolve)(outDir)).digest("hex").slice(0, 16);
1292
1485
  return (0, import_node_path8.join)(parent, `.lk-promote-lock-${hash}`);
1293
1486
  }
1294
- 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;
1295
1490
  async function isStalePromoteLock(lockPath) {
1296
1491
  try {
1492
+ const stat2 = await fsp.stat(lockPath);
1297
1493
  const content = await fsp.readFile(lockPath, "utf8");
1298
- const pid = Number.parseInt(content.trim(), 10);
1299
- if (Number.isFinite(pid) && pid > 0) {
1300
- try {
1301
- process.kill(pid, 0);
1302
- return false;
1303
- } catch {
1304
- 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;
1305
1500
  }
1306
1501
  }
1307
- const stat2 = await fsp.stat(lockPath);
1308
- 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;
1309
1517
  } catch {
1310
1518
  return true;
1311
1519
  }
@@ -1318,6 +1526,8 @@ async function withPromoteLock(outDir, fn) {
1318
1526
  try {
1319
1527
  lockHandle = await fsp.open(lockPath, "wx");
1320
1528
  await lockHandle.writeFile(`${process.pid}
1529
+ ${(0, import_node_crypto.randomUUID)()}
1530
+ ${Date.now()}
1321
1531
  `, "utf8");
1322
1532
  break;
1323
1533
  } catch (err) {
@@ -1349,22 +1559,77 @@ async function withPromoteLock(outDir, fn) {
1349
1559
  );
1350
1560
  }
1351
1561
  }
1352
- async function assertNoLegacyPromoteArtifacts(outDir) {
1562
+ async function removeStaleLegacyPromoteArtifacts(outDir) {
1353
1563
  const legacyTmp = `${outDir}.tmp-promote`;
1354
1564
  const legacyBak = `${outDir}.bak`;
1355
- const stale = [];
1356
- if (await pathExists(legacyTmp)) stale.push(legacyTmp);
1357
- if (await pathExists(legacyBak)) stale.push(legacyBak);
1358
- 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("; ");
1359
1583
  throw new Error(
1360
- `[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}`
1361
1585
  );
1362
1586
  }
1363
1587
  }
1364
- 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
+ }
1365
1617
  return withPromoteLock(outDir, async () => {
1366
- await assertNoLegacyPromoteArtifacts(outDir);
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
+ }
1367
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
+ }
1368
1633
  const tmpPromote = await fsp.mkdtemp((0, import_node_path8.join)(parent, ".lk-promote-"));
1369
1634
  await renameOrCopy(stagingDir, tmpPromote);
1370
1635
  const hadOutDir = await pathExists(outDir);
@@ -1421,6 +1686,20 @@ async function promoteStagingToOutDir(stagingDir, outDir) {
1421
1686
  }
1422
1687
  throw promoteError;
1423
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
+ }
1424
1703
  if (backup) {
1425
1704
  await fsp.rm(backup, { recursive: true, force: true }).catch(
1426
1705
  /* v8 ignore next */
@@ -1438,6 +1717,7 @@ var import_api = require("@lxpack/api");
1438
1717
  async function buildStagingPackage(options) {
1439
1718
  const { target, output, dir, outputBaseDir, descriptor, ...writeOpts } = options;
1440
1719
  const stagingDir = await fsp2.mkdtemp((0, import_node_path9.join)((0, import_node_os.tmpdir)(), "lessonkit-lxpack-"));
1720
+ let succeeded = false;
1441
1721
  try {
1442
1722
  let spaDirs;
1443
1723
  try {
@@ -1493,6 +1773,7 @@ async function buildStagingPackage(options) {
1493
1773
  }))
1494
1774
  };
1495
1775
  }
1776
+ succeeded = true;
1496
1777
  return {
1497
1778
  ok: true,
1498
1779
  stagingDir,
@@ -1506,6 +1787,13 @@ async function buildStagingPackage(options) {
1506
1787
  () => void 0
1507
1788
  );
1508
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
+ }
1509
1797
  }
1510
1798
  }
1511
1799
  async function ensureOutDirParent(outDir) {
@@ -1570,24 +1858,22 @@ async function packageLessonkitCourse(options) {
1570
1858
  };
1571
1859
  }
1572
1860
  const descriptor = descriptorValidation.descriptor;
1573
- if (writeOpts.projectRoot) {
1574
- const parityIssues = validateReactManifestParity({
1575
- projectRoot: writeOpts.projectRoot,
1576
- descriptor
1577
- });
1578
- const parityErrors = parityIssues.filter((i) => i.severity === "error");
1579
- if (parityErrors.length > 0) {
1580
- return {
1581
- ok: false,
1582
- courseDir: outDir,
1583
- target,
1584
- issues: parityErrors.map((i) => ({
1585
- path: i.path,
1586
- message: i.message,
1587
- severity: i.severity
1588
- }))
1589
- };
1590
- }
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) {
1867
+ return {
1868
+ ok: false,
1869
+ courseDir: outDir,
1870
+ target,
1871
+ issues: parityFailures.map((i) => ({
1872
+ path: i.path,
1873
+ message: i.message,
1874
+ severity: i.severity
1875
+ }))
1876
+ };
1591
1877
  }
1592
1878
  const staged = await buildStagingPackage({
1593
1879
  ...writeOpts,
@@ -1646,7 +1932,7 @@ async function packageLessonkitCourse(options) {
1646
1932
  ok: false,
1647
1933
  courseDir: outDir,
1648
1934
  target,
1649
- validation: { ok: true, manifest: build.manifest, issues: build.issues },
1935
+ validation: { ok: false, manifest: build.manifest, issues: build.issues },
1650
1936
  build,
1651
1937
  issues: artifactIssues
1652
1938
  };
@@ -1660,7 +1946,10 @@ async function packageLessonkitCourse(options) {
1660
1946
  };
1661
1947
  try {
1662
1948
  await ensureOutDirParent(outDir);
1663
- await promoteStagingToOutDir(stagingDir, outDir);
1949
+ await promoteStagingToOutDir(stagingDir, outDir, {
1950
+ outputBaseDir: outputBaseDir ?? ".lxpack/out",
1951
+ projectRoot: writeOpts.projectRoot
1952
+ });
1664
1953
  } catch (err) {
1665
1954
  await fsp3.rm(stagingDir, { recursive: true, force: true }).catch(
1666
1955
  /* v8 ignore next */
@@ -1783,6 +2072,20 @@ function parseLessonkitManifest(raw, label = "lessonkit.json", projectRoot) {
1783
2072
  message: `"course.spaDistDir" (${courseSpaDistDir}) differs from "paths.spaDistDir" (${paths.spaDistDir}). Use paths.spaDistDir for CLI build and package.`
1784
2073
  });
1785
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
+ }
1786
2089
  if (projectRoot) {
1787
2090
  const pathIssues = validateProjectPaths(projectRoot, paths);
1788
2091
  for (const pi of pathIssues) {
@@ -1814,37 +2117,56 @@ var import_tracking_schema2 = require("@lxpack/tracking-schema");
1814
2117
 
1815
2118
  // src/telemetry.ts
1816
2119
  var import_tracking_schema = require("@lxpack/tracking-schema");
1817
- 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
+ ]);
1818
2127
  function isQuizAnsweredData(data) {
1819
- 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;
1820
2129
  }
1821
2130
  function isQuizCompletedData(data) {
1822
- 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;
1823
2135
  }
1824
2136
  function isInteractionData(data) {
1825
2137
  return typeof data === "object" && data !== null;
1826
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
+ }
1827
2145
  function telemetryEventToLessonkit(event) {
1828
2146
  if (!SUPPORTED.has(event.name)) {
1829
2147
  return null;
1830
2148
  }
1831
- const name = event.name;
1832
2149
  const mapped = {
1833
- name,
2150
+ name: event.name,
1834
2151
  lessonId: event.lessonId
1835
2152
  };
1836
- if (name === "quiz_completed" || name === "quiz_answered") {
2153
+ if (event.name === "quiz_completed" || event.name === "quiz_answered" || event.name === "assessment_answered") {
1837
2154
  const data = event.data;
1838
- if (isQuizAnsweredData(data) || isQuizCompletedData(data)) {
1839
- mapped.assessmentId = data.checkId;
1840
- if ("score" in data) {
1841
- mapped.score = data.score;
1842
- mapped.maxScore = data.maxScore;
1843
- mapped.passingScore = data.passingScore;
1844
- }
1845
- mapped.data = data;
2155
+ if (!isQuizAnsweredData(data) && !isQuizCompletedData(data) && !isAssessmentAnsweredData(data)) {
2156
+ return null;
2157
+ }
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;
1846
2163
  }
1847
- } else if (name === "interaction" && event.data && isInteractionData(event.data)) {
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)) {
1848
2170
  mapped.data = event.data;
1849
2171
  }
1850
2172
  return mapped;
@@ -1860,6 +2182,7 @@ var import_validators2 = require("@lxpack/validators");
1860
2182
  buildStagingPackage,
1861
2183
  descriptorToInterchange,
1862
2184
  ensureOutDirParent,
2185
+ escapeShellText,
1863
2186
  extractAssessments,
1864
2187
  lessonkitInterchangeSchema,
1865
2188
  loadLessonkitManifestFromFile,