@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.js CHANGED
@@ -1,6 +1,6 @@
1
1
  import {
2
2
  telemetryEventToLessonkit
3
- } from "./chunk-DYQI222N.js";
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 === "fillInBlanks" && !assessment.template?.trim()) {
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, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;").replace(/'/g, "&#39;");
570
+ }
571
+ function decodeShellEntities(text) {
572
+ 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)));
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: assessment.checkId,
603
+ id: checkId,
558
604
  passingScore: assessment.passingScore ?? 1,
559
605
  questions: [
560
606
  {
561
607
  id: "q1",
562
- prompt: assessment.question,
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" && assessment.kind === "findHotspot") {
586
- return mcqToLxpack({
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
- if (target === "xapi" || target === "cmi5") {
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.assessments ?? []).forEach((assessment, index) => {
628
- if (assessmentDescriptorToLxpack(assessment) === null) {
629
- issues.push({
630
- path: `assessments[${index}]`,
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, statSync } from "fs";
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
- if (statSync(abs).isDirectory()) {
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) => join2(projectRoot, rel)).filter((abs) => existsSync2(abs)).map((abs) => readFileSync(abs, "utf8")).join("\n");
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 idPropPatterns(prop, id) {
690
- return [
691
- `${prop}="${id}"`,
692
- `${prop}='${id}'`,
693
- `${prop}={'${id}'}`,
694
- `${prop}={"${id}"}`,
695
- `${prop}={\`${id}\`}`
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(stripped, prop, id, constants) {
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) => stripped.includes(p))) return true;
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 courseIdPresent(source, courseId) {
866
+ function lessonIdInDataLiteral(source, lessonId) {
723
867
  const stripped = stripComments(source);
724
- if (idPropPatterns("courseId", courseId).some((p) => stripped.includes(p))) return true;
725
- return idUsedViaConstant(stripped, "courseId", courseId, extractStringConstants(source));
868
+ const escaped = escapeRegExp(lessonId);
869
+ return new RegExp(`\\bid\\s*:\\s*["'\`]${escaped}["'\`]`).test(stripped);
726
870
  }
727
- function checkIdPresent(source, checkId) {
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
- if (idPropPatterns("checkId", checkId).some((p) => stripped.includes(p))) return true;
730
- return idUsedViaConstant(stripped, "checkId", checkId, extractStringConstants(source));
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 appSources = opts.appSources ?? collectSourceUnderSrc(opts.projectRoot);
734
- const source = readAppSources(opts.projectRoot, appSources);
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
- return [
738
- {
739
- path: appSources.length > 0 ? appSources.join(", ") : "src/",
740
- message: hasDescriptorIds ? "React app source not found for ID parity check" : "React app source not found for ID parity check",
741
- severity: hasDescriptorIds ? "error" : "warning"
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: `React app source does not reference courseId="${courseId}" from lessonkit.json`,
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: `React app source missing checkId="${checkId}" declared in lessonkit.json`,
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
- function validatePathField(value, fieldPath, projectRoot, issues) {
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 join4, resolve as resolve4 } from "path";
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 outDir = resolve4(options.outDir);
959
- if (options.projectRoot) {
960
- assertRealPathUnderRoot(resolve4(options.projectRoot), outDir);
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: join4(courseDir, "course.yaml"),
979
- lessonkitJsonPath: join4(courseDir, "lessonkit.json")
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 join5, resolve as resolve5, win32 as win322 } from "path";
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 join5(outDir, rel);
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 join6, resolve as resolve6 } from "path";
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 join6(parent, `.lk-promote-lock-${hash}`);
1427
+ return join7(parent, `.lk-promote-lock-${hash}`);
1158
1428
  }
1159
- var STALE_LOCK_TTL_MS = 5 * 60 * 1e3;
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 pid = Number.parseInt(content.trim(), 10);
1164
- if (Number.isFinite(pid) && pid > 0) {
1165
- try {
1166
- process.kill(pid, 0);
1167
- return false;
1168
- } catch {
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
- const stat2 = await fsp.stat(lockPath);
1173
- return Date.now() - stat2.mtimeMs > STALE_LOCK_TTL_MS;
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 assertNoLegacyPromoteArtifacts(outDir) {
1504
+ async function removeStaleLegacyPromoteArtifacts(outDir) {
1218
1505
  const legacyTmp = `${outDir}.tmp-promote`;
1219
1506
  const legacyBak = `${outDir}.bak`;
1220
- const stale = [];
1221
- if (await pathExists(legacyTmp)) stale.push(legacyTmp);
1222
- if (await pathExists(legacyBak)) stale.push(legacyBak);
1223
- if (stale.length) {
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: ${stale.join(", ")}`
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 promoteStagingToOutDir(stagingDir, outDir) {
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 assertNoLegacyPromoteArtifacts(outDir);
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
- const tmpPromote = await fsp.mkdtemp(join6(parent, ".lk-promote-"));
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(join6(parent, ".lk-backup-")) : void 0;
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 = join6(parent, `.lk-failed-promote-${randomUUID()}`);
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 = join6(parent, `.lk-failed-promote-${randomUUID()}`);
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 join7 } from "path";
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(join7(tmpdir(), "lessonkit-lxpack-"));
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(join7(stagingDir, outputBase), { recursive: true });
1324
- const defaultOutput = output ?? (dir ? join7(outputBase, target) : join7(outputBase, `course-${target}.zip`));
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
- if (writeOpts.projectRoot) {
1426
- const parityIssues = validateReactManifestParity({
1427
- projectRoot: writeOpts.projectRoot,
1428
- descriptor
1429
- });
1430
- const parityErrors = parityIssues.filter((i) => i.severity === "error");
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: nonInjectableAssessments.map(({ assessment, index }) => ({
1451
- path: `assessments[${index}]`,
1452
- message: `assessment kind "${assessment.kind ?? "mcq"}" (checkId "${assessment.checkId}") is not injected into LMS shell quizzes for target "${target}"`
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: true, manifest: build.manifest, issues: build.issues },
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,