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