@lessonkit/lxpack 1.2.0 → 1.3.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
@@ -193,6 +193,14 @@ function parseAssessmentDescriptor(raw) {
193
193
  correctTargetIds: Array.isArray(raw.correctTargetIds) ? raw.correctTargetIds.filter((id) => typeof id === "string") : []
194
194
  };
195
195
  }
196
+ if (typeof kind === "string" && kind !== "mcq" && kind !== "trueFalse" && kind !== "fillInBlanks" && kind !== "findHotspot" && kind !== "findMultipleHotspots") {
197
+ return {
198
+ kind,
199
+ ...base,
200
+ choices: [],
201
+ answer: ""
202
+ };
203
+ }
196
204
  return {
197
205
  kind: kind === "mcq" ? "mcq" : void 0,
198
206
  ...base,
@@ -242,6 +250,7 @@ function parseCourseDescriptorInput(input) {
242
250
 
243
251
  // src/descriptor/validateCourse.ts
244
252
  var import_core3 = require("@lessonkit/core");
253
+ var import_themes2 = require("@lessonkit/themes");
245
254
 
246
255
  // src/spaPath.ts
247
256
  var import_node_fs = require("fs");
@@ -271,6 +280,28 @@ function assertResolvedPathUnderRoot(root, target) {
271
280
  throw new Error(`unsafe path escapes project root: ${target}`);
272
281
  }
273
282
  }
283
+ function resolveExistingPathUnderRoot(rootReal, rootResolved, targetResolved) {
284
+ const rel = (0, import_node_path.relative)(rootResolved, targetResolved);
285
+ if (rel.startsWith("..") || rel.includes(`..${import_node_path.sep}`)) {
286
+ throw new Error(`unsafe path escapes project root: ${targetResolved}`);
287
+ }
288
+ const segments = rel.split(/[/\\]/).filter((s) => s.length > 0 && s !== ".");
289
+ let current = rootReal;
290
+ for (const segment of segments) {
291
+ const next = (0, import_node_path.join)(current, segment);
292
+ if ((0, import_node_fs.existsSync)(next)) {
293
+ try {
294
+ current = (0, import_node_fs.realpathSync)(next);
295
+ } catch {
296
+ current = next;
297
+ }
298
+ } else {
299
+ current = next;
300
+ }
301
+ assertResolvedPathUnderRoot(rootReal, current);
302
+ }
303
+ return current;
304
+ }
274
305
  function assertRealPathUnderRoot(root, target) {
275
306
  const rootResolved = resolveComparablePath(root);
276
307
  const targetResolved = resolveComparablePath(target);
@@ -280,17 +311,12 @@ function assertRealPathUnderRoot(root, target) {
280
311
  } catch {
281
312
  rootReal = rootResolved;
282
313
  }
283
- let targetCheck;
284
314
  try {
285
- targetCheck = (0, import_node_fs.realpathSync)(targetResolved);
315
+ const targetCheck = (0, import_node_fs.realpathSync)(targetResolved);
316
+ assertResolvedPathUnderRoot(rootReal, targetCheck);
286
317
  } catch {
287
- const rel = (0, import_node_path.relative)(rootResolved, targetResolved);
288
- if (rel.startsWith("..") || rel.includes(`..${import_node_path.sep}`)) {
289
- throw new Error(`unsafe path escapes project root: ${target}`);
290
- }
291
- targetCheck = (0, import_node_path.resolve)(rootReal, rel);
318
+ resolveExistingPathUnderRoot(rootReal, rootResolved, targetResolved);
292
319
  }
293
- assertResolvedPathUnderRoot(rootReal, targetCheck);
294
320
  }
295
321
  function normalizePathForComparison(p) {
296
322
  const resolved = resolveComparablePath(p);
@@ -331,7 +357,12 @@ function themeToLxpackRuntime(input) {
331
357
  // src/descriptor/validateAssessments.ts
332
358
  var import_core2 = require("@lessonkit/core");
333
359
  var validateMcqLike = (assessment, path, issues) => {
334
- if (!("choices" in assessment) || !("answer" in assessment) || typeof assessment.answer !== "string") {
360
+ if (!("choices" in assessment) || !Array.isArray(assessment.choices)) {
361
+ issues.push({ path: `${path}.choices`, message: "choices is required for mcq" });
362
+ return;
363
+ }
364
+ if (!("answer" in assessment) || typeof assessment.answer !== "string") {
365
+ issues.push({ path: `${path}.answer`, message: "answer is required for mcq" });
335
366
  return;
336
367
  }
337
368
  const trimmedChoices = assessment.choices.map((c) => c.trim()).filter((c) => c.length > 0);
@@ -344,6 +375,22 @@ var validateMcqLike = (assessment, path, issues) => {
344
375
  issues.push({ path: `${path}.answer`, message: "answer must match a choice" });
345
376
  }
346
377
  };
378
+ function countStarDelimitedBlanks(template) {
379
+ const matches = template.match(/\*[^*]+\*/g);
380
+ return matches?.length ?? 0;
381
+ }
382
+ function maxAchievableAssessmentScore(assessment) {
383
+ const kind = assessment.kind ?? "mcq";
384
+ if (kind === "fillInBlanks" && assessment.kind === "fillInBlanks") {
385
+ const explicit = assessment.blanks?.filter((b) => b?.id?.trim() && b?.answer?.trim()).length ?? 0;
386
+ if (explicit > 0) return explicit;
387
+ return countStarDelimitedBlanks(assessment.template ?? "");
388
+ }
389
+ if (kind === "findMultipleHotspots" && assessment.kind === "findMultipleHotspots") {
390
+ return assessment.correctTargetIds?.map((id) => id.trim()).filter((id) => id.length > 0).length ?? 0;
391
+ }
392
+ return 1;
393
+ }
347
394
  var ASSESSMENT_VALIDATORS = {
348
395
  mcq: validateMcqLike,
349
396
  trueFalse: (assessment, path, issues) => {
@@ -356,9 +403,33 @@ var ASSESSMENT_VALIDATORS = {
356
403
  issues.push({ path: `${path}.template`, message: "template is required for fillInBlanks" });
357
404
  }
358
405
  },
359
- findHotspot: () => {
406
+ findHotspot: (assessment, path, issues) => {
407
+ if (assessment.kind !== "findHotspot") return;
408
+ if (!assessment.src?.trim()) {
409
+ issues.push({ path: `${path}.src`, message: "src is required for findHotspot" });
410
+ }
411
+ if (!assessment.alt?.trim()) {
412
+ issues.push({ path: `${path}.alt`, message: "alt is required for findHotspot" });
413
+ }
414
+ if (!assessment.correctTargetId?.trim()) {
415
+ issues.push({ path: `${path}.correctTargetId`, message: "correctTargetId is required for findHotspot" });
416
+ }
360
417
  },
361
- findMultipleHotspots: () => {
418
+ findMultipleHotspots: (assessment, path, issues) => {
419
+ if (assessment.kind !== "findMultipleHotspots") return;
420
+ if (!assessment.src?.trim()) {
421
+ issues.push({ path: `${path}.src`, message: "src is required for findMultipleHotspots" });
422
+ }
423
+ if (!assessment.alt?.trim()) {
424
+ issues.push({ path: `${path}.alt`, message: "alt is required for findMultipleHotspots" });
425
+ }
426
+ const ids = assessment.correctTargetIds?.map((id) => id.trim()).filter((id) => id.length > 0) ?? [];
427
+ if (!ids.length) {
428
+ issues.push({
429
+ path: `${path}.correctTargetIds`,
430
+ message: "at least one non-empty correctTargetId is required for findMultipleHotspots"
431
+ });
432
+ }
362
433
  }
363
434
  };
364
435
  function validateAssessmentEntry(assessment, index, issues, checkIds) {
@@ -374,14 +445,38 @@ function validateAssessmentEntry(assessment, index, issues, checkIds) {
374
445
  if (!assessment.question?.trim()) {
375
446
  issues.push({ path: `${path}.question`, message: "question is required" });
376
447
  }
448
+ const knownKinds = Object.keys(ASSESSMENT_VALIDATORS);
449
+ if (assessment.kind !== void 0 && assessment.kind !== "mcq" && !knownKinds.includes(assessment.kind)) {
450
+ issues.push({
451
+ path: `${path}.kind`,
452
+ message: `unknown kind; use one of: ${knownKinds.join(", ")}`
453
+ });
454
+ return;
455
+ }
377
456
  const kind = assessment.kind ?? "mcq";
378
- ASSESSMENT_VALIDATORS[kind](assessment, path, issues);
457
+ const validator = ASSESSMENT_VALIDATORS[kind];
458
+ if (!validator) {
459
+ issues.push({
460
+ path: `${path}.kind`,
461
+ message: `unknown kind; use one of: ${knownKinds.join(", ")}`
462
+ });
463
+ return;
464
+ }
465
+ validator(assessment, path, issues);
379
466
  const passingScore = assessment.passingScore;
380
467
  if (passingScore !== void 0 && !(Number.isFinite(passingScore) && passingScore > 0)) {
381
468
  issues.push({
382
469
  path: `${path}.passingScore`,
383
470
  message: "passingScore must be greater than 0 (absolute point threshold)"
384
471
  });
472
+ } else if (passingScore !== void 0) {
473
+ const maxAchievable = maxAchievableAssessmentScore(assessment);
474
+ if (maxAchievable > 0 && passingScore > maxAchievable) {
475
+ issues.push({
476
+ path: `${path}.passingScore`,
477
+ message: `passingScore cannot exceed achievable score (${maxAchievable}) for this assessment kind`
478
+ });
479
+ }
385
480
  }
386
481
  }
387
482
 
@@ -415,13 +510,23 @@ function validateCourseDescriptor(input) {
415
510
  });
416
511
  }
417
512
  if (input.theme?.theme) {
418
- try {
419
- themeToLxpackRuntime({ preset: themePreset, theme: input.theme.theme });
420
- } catch (err) {
421
- issues.push({
422
- path: "theme.theme",
423
- message: err instanceof Error ? err.message : "invalid custom theme"
424
- });
513
+ const themeResult = (0, import_themes2.validateTheme)(input.theme.theme);
514
+ if (!themeResult.ok) {
515
+ for (const issue of themeResult.issues) {
516
+ issues.push({
517
+ path: issue.path ? `theme.theme.${issue.path}` : "theme.theme",
518
+ message: issue.message
519
+ });
520
+ }
521
+ } else {
522
+ try {
523
+ themeToLxpackRuntime({ preset: themePreset, theme: themeResult.theme });
524
+ } catch (err) {
525
+ issues.push({
526
+ path: "theme.theme",
527
+ message: err instanceof Error ? err.message : "invalid custom theme"
528
+ });
529
+ }
425
530
  }
426
531
  }
427
532
  const completionThreshold = input.tracking?.completion?.threshold;
@@ -492,19 +597,102 @@ function validateCourseDescriptor(input) {
492
597
  return issues;
493
598
  }
494
599
 
600
+ // src/assessments.ts
601
+ function slugChoiceId(text, index) {
602
+ const base = text.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "").slice(0, 32);
603
+ const stem = base.length ? base : "choice";
604
+ return `${stem}-${index + 1}`;
605
+ }
606
+ function mcqToLxpack(assessment) {
607
+ const choices = assessment.choices.map((text, index) => {
608
+ const id = slugChoiceId(text, index);
609
+ return {
610
+ id,
611
+ text,
612
+ correct: text === assessment.answer
613
+ };
614
+ });
615
+ return {
616
+ id: assessment.checkId,
617
+ passingScore: assessment.passingScore ?? 1,
618
+ questions: [
619
+ {
620
+ id: "q1",
621
+ prompt: assessment.question,
622
+ choices
623
+ }
624
+ ]
625
+ };
626
+ }
627
+ function assessmentDescriptorToLxpack(assessment) {
628
+ const kind = assessment.kind ?? "mcq";
629
+ if (kind === "trueFalse" && assessment.kind === "trueFalse") {
630
+ const choices = ["True", "False"];
631
+ const answerText = assessment.answer ? "True" : "False";
632
+ return mcqToLxpack({
633
+ kind: "mcq",
634
+ checkId: assessment.checkId,
635
+ question: assessment.question,
636
+ choices,
637
+ answer: answerText,
638
+ passingScore: assessment.passingScore
639
+ });
640
+ }
641
+ if (kind === "fillInBlanks") {
642
+ return null;
643
+ }
644
+ if (kind === "findHotspot" && assessment.kind === "findHotspot") {
645
+ return mcqToLxpack({
646
+ kind: "mcq",
647
+ checkId: assessment.checkId,
648
+ question: assessment.question,
649
+ choices: [assessment.correctTargetId, "other"],
650
+ answer: assessment.correctTargetId,
651
+ passingScore: assessment.passingScore
652
+ });
653
+ }
654
+ if (kind === "findMultipleHotspots") {
655
+ return null;
656
+ }
657
+ if ("choices" in assessment && "answer" in assessment && typeof assessment.answer === "string") {
658
+ return mcqToLxpack(assessment);
659
+ }
660
+ return null;
661
+ }
662
+ function extractAssessments(descriptor) {
663
+ return (descriptor.assessments ?? []).map(assessmentDescriptorToLxpack).filter((a) => a !== null);
664
+ }
665
+
495
666
  // src/descriptor/validateForTarget.ts
667
+ var LMS_SHELL_TARGETS = /* @__PURE__ */ new Set([
668
+ "scorm12",
669
+ "scorm2004",
670
+ "standalone",
671
+ "xapi",
672
+ "cmi5"
673
+ ]);
496
674
  function validateDescriptorForExportTarget(descriptor, target) {
497
- if (target !== "xapi" && target !== "cmi5") return [];
498
- const activityIri = descriptor.tracking?.xapi?.activityIri?.trim();
499
- if (!activityIri) {
500
- return [
501
- {
675
+ const issues = [];
676
+ if (target === "xapi" || target === "cmi5") {
677
+ const activityIri = descriptor.tracking?.xapi?.activityIri?.trim();
678
+ if (!activityIri) {
679
+ issues.push({
502
680
  path: "course.tracking.xapi.activityIri",
503
681
  message: "tracking.xapi.activityIri is required for xapi and cmi5 export targets"
682
+ });
683
+ }
684
+ }
685
+ if (LMS_SHELL_TARGETS.has(target)) {
686
+ (descriptor.assessments ?? []).forEach((assessment, index) => {
687
+ if (assessmentDescriptorToLxpack(assessment) === null) {
688
+ issues.push({
689
+ path: `assessments[${index}]`,
690
+ message: `assessment kind "${assessment.kind ?? "mcq"}" (checkId "${assessment.checkId}") is not injected into LMS shell quizzes for target "${target}"`
691
+ });
504
692
  }
505
- ];
693
+ });
506
694
  }
507
- return [];
695
+ return issues;
508
696
  }
509
697
 
510
698
  // src/validateDescriptor.ts
@@ -593,72 +781,6 @@ function mapLessonkitIds(descriptor) {
593
781
  return { courseId, lessonIds, checkIds };
594
782
  }
595
783
 
596
- // src/assessments.ts
597
- function slugChoiceId(text, index) {
598
- const base = text.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "").slice(0, 32);
599
- const stem = base.length ? base : "choice";
600
- return `${stem}-${index + 1}`;
601
- }
602
- function mcqToLxpack(assessment) {
603
- const choices = assessment.choices.map((text, index) => {
604
- const id = slugChoiceId(text, index);
605
- return {
606
- id,
607
- text,
608
- correct: text === assessment.answer
609
- };
610
- });
611
- return {
612
- id: assessment.checkId,
613
- passingScore: assessment.passingScore ?? 1,
614
- questions: [
615
- {
616
- id: "q1",
617
- prompt: assessment.question,
618
- choices
619
- }
620
- ]
621
- };
622
- }
623
- function assessmentDescriptorToLxpack(assessment) {
624
- const kind = assessment.kind ?? "mcq";
625
- if (kind === "trueFalse" && assessment.kind === "trueFalse") {
626
- const choices = ["True", "False"];
627
- const answerText = assessment.answer ? "True" : "False";
628
- return mcqToLxpack({
629
- kind: "mcq",
630
- checkId: assessment.checkId,
631
- question: assessment.question,
632
- choices,
633
- answer: answerText,
634
- passingScore: assessment.passingScore
635
- });
636
- }
637
- if (kind === "fillInBlanks") {
638
- return null;
639
- }
640
- if (kind === "findHotspot" && assessment.kind === "findHotspot") {
641
- return mcqToLxpack({
642
- kind: "mcq",
643
- checkId: assessment.checkId,
644
- question: assessment.question,
645
- choices: [assessment.correctTargetId, "other"],
646
- answer: assessment.correctTargetId,
647
- passingScore: assessment.passingScore
648
- });
649
- }
650
- if (kind === "findMultipleHotspots") {
651
- return null;
652
- }
653
- if ("choices" in assessment && "answer" in assessment && typeof assessment.answer === "string") {
654
- return mcqToLxpack(assessment);
655
- }
656
- return null;
657
- }
658
- function extractAssessments(descriptor) {
659
- return (descriptor.assessments ?? []).map(assessmentDescriptorToLxpack).filter((a) => a !== null);
660
- }
661
-
662
784
  // src/interchange.ts
663
785
  function mapDescriptorTracking(tracking) {
664
786
  if (!tracking) return void 0;
@@ -821,44 +943,71 @@ var import_node_path5 = require("path");
821
943
  function validatePackageInputs(options) {
822
944
  const { target, output, outputBaseDir } = options;
823
945
  const outDir = (0, import_node_path5.resolve)(options.outDir);
824
- const projectRoot = options.projectRoot ? (0, import_node_path5.resolve)(options.projectRoot) : void 0;
825
- if (projectRoot) {
826
- try {
827
- assertRealPathUnderRoot(projectRoot, outDir);
828
- } catch (err) {
829
- return {
830
- ok: false,
831
- courseDir: outDir,
832
- target,
833
- issues: [
834
- {
835
- path: "outDir",
836
- message: (
837
- /* v8 ignore next */
838
- err instanceof Error ? err.message : String(err)
839
- )
840
- }
841
- ]
842
- };
843
- }
946
+ if (!options.projectRoot) {
947
+ return {
948
+ ok: false,
949
+ courseDir: outDir,
950
+ target,
951
+ issues: [{ path: "projectRoot", message: "projectRoot is required for packageLessonkitCourse" }]
952
+ };
844
953
  }
845
- if (outputBaseDir && !isSafeRelativeSpaPath(outputBaseDir)) {
954
+ const projectRoot = (0, import_node_path5.resolve)(options.projectRoot);
955
+ try {
956
+ assertRealPathUnderRoot(projectRoot, outDir);
957
+ } catch (err) {
846
958
  return {
847
959
  ok: false,
848
960
  courseDir: outDir,
849
961
  target,
850
- issues: [{ path: "outputBaseDir", message: `unsafe outputBaseDir: ${outputBaseDir}` }]
962
+ issues: [
963
+ {
964
+ path: "outDir",
965
+ message: (
966
+ /* v8 ignore next */
967
+ err instanceof Error ? err.message : String(err)
968
+ )
969
+ }
970
+ ]
851
971
  };
852
972
  }
853
- if (output && !projectRoot && !isSafeRelativeSpaPath(output)) {
973
+ if (outputBaseDir && !isSafeRelativeSpaPath(outputBaseDir)) {
854
974
  return {
855
975
  ok: false,
856
976
  courseDir: outDir,
857
977
  target,
858
- issues: [{ path: "output", message: `unsafe output: ${output}` }]
978
+ issues: [{ path: "outputBaseDir", message: `unsafe outputBaseDir: ${outputBaseDir}` }]
859
979
  };
860
980
  }
861
- if (projectRoot && outputBaseDir) {
981
+ if (output && !isSafeRelativeSpaPath(output)) {
982
+ if ((0, import_node_path5.isAbsolute)(output)) {
983
+ try {
984
+ assertRealPathUnderRoot(projectRoot, (0, import_node_path5.resolve)(output));
985
+ } catch (err) {
986
+ return {
987
+ ok: false,
988
+ courseDir: outDir,
989
+ target,
990
+ issues: [
991
+ {
992
+ path: "output",
993
+ message: (
994
+ /* v8 ignore next */
995
+ err instanceof Error ? err.message : `unsafe output: ${output}`
996
+ )
997
+ }
998
+ ]
999
+ };
1000
+ }
1001
+ } else {
1002
+ return {
1003
+ ok: false,
1004
+ courseDir: outDir,
1005
+ target,
1006
+ issues: [{ path: "output", message: `unsafe output: ${output}` }]
1007
+ };
1008
+ }
1009
+ }
1010
+ if (outputBaseDir) {
862
1011
  const resolvedOutputBase = (0, import_node_path5.resolve)(projectRoot, outputBaseDir);
863
1012
  try {
864
1013
  assertRealPathUnderRoot(projectRoot, resolvedOutputBase);
@@ -879,8 +1028,8 @@ function validatePackageInputs(options) {
879
1028
  };
880
1029
  }
881
1030
  }
882
- if (projectRoot && output) {
883
- const resolvedOutput = (0, import_node_path5.resolve)(projectRoot, output);
1031
+ if (output) {
1032
+ const resolvedOutput = (0, import_node_path5.isAbsolute)(output) ? (0, import_node_path5.resolve)(output) : (0, import_node_path5.resolve)(projectRoot, output);
884
1033
  try {
885
1034
  assertRealPathUnderRoot(projectRoot, resolvedOutput);
886
1035
  } catch (err) {
@@ -917,11 +1066,11 @@ function remapArtifactPaths(stagingRoot, outDir, artifactPath) {
917
1066
  if (!artifactPath) return void 0;
918
1067
  const resolved = resolveComparablePath(artifactPath);
919
1068
  if (!isResolvedPathUnderRoot(stagingRoot, resolved)) {
920
- return artifactPath;
1069
+ throw new Error(`${artifactPath} is outside the staging directory`);
921
1070
  }
922
1071
  const rel = relativePathUnderRoot(stagingRoot, resolved);
923
1072
  if (rel.startsWith("..") || (0, import_node_path5.isAbsolute)(rel)) {
924
- return artifactPath;
1073
+ throw new Error(`${artifactPath} is outside the staging directory`);
925
1074
  }
926
1075
  if (!rel) return outDir;
927
1076
  if (/^[a-zA-Z]:[/\\]/.test(outDir)) {
@@ -952,6 +1101,68 @@ async function renameOrCopy(from, to) {
952
1101
  await fsp.rm(from, { recursive: true, force: true });
953
1102
  }
954
1103
  }
1104
+ function promoteLockPath(outDir) {
1105
+ const parent = (0, import_node_path6.dirname)(outDir);
1106
+ const hash = (0, import_node_crypto.createHash)("sha256").update((0, import_node_path6.resolve)(outDir)).digest("hex").slice(0, 16);
1107
+ return (0, import_node_path6.join)(parent, `.lk-promote-lock-${hash}`);
1108
+ }
1109
+ var STALE_LOCK_TTL_MS = 5 * 60 * 1e3;
1110
+ async function isStalePromoteLock(lockPath) {
1111
+ try {
1112
+ const stat2 = await fsp.stat(lockPath);
1113
+ if (Date.now() - stat2.mtimeMs > STALE_LOCK_TTL_MS) return true;
1114
+ const content = await fsp.readFile(lockPath, "utf8");
1115
+ const pid = Number.parseInt(content.trim(), 10);
1116
+ if (!Number.isFinite(pid) || pid <= 0) return true;
1117
+ try {
1118
+ process.kill(pid, 0);
1119
+ return false;
1120
+ } catch {
1121
+ return true;
1122
+ }
1123
+ } catch {
1124
+ return true;
1125
+ }
1126
+ }
1127
+ async function withPromoteLock(outDir, fn) {
1128
+ const lockPath = promoteLockPath(outDir);
1129
+ await fsp.mkdir((0, import_node_path6.dirname)(outDir), { recursive: true });
1130
+ let lockHandle;
1131
+ for (let attempt = 0; attempt < 200; attempt++) {
1132
+ try {
1133
+ lockHandle = await fsp.open(lockPath, "wx");
1134
+ await lockHandle.writeFile(`${process.pid}
1135
+ `, "utf8");
1136
+ break;
1137
+ } catch (err) {
1138
+ const code = err && typeof err === "object" && "code" in err ? String(err.code) : "";
1139
+ if (code !== "EEXIST") throw err;
1140
+ if (await isStalePromoteLock(lockPath)) {
1141
+ await fsp.rm(lockPath, { force: true }).catch(
1142
+ /* v8 ignore next */
1143
+ () => void 0
1144
+ );
1145
+ continue;
1146
+ }
1147
+ await new Promise((resolveWait) => setTimeout(resolveWait, 25));
1148
+ }
1149
+ }
1150
+ if (!lockHandle) {
1151
+ throw new Error(`[lessonkit/lxpack] timed out acquiring promote lock for ${outDir}`);
1152
+ }
1153
+ try {
1154
+ return await fn();
1155
+ } finally {
1156
+ await lockHandle.close().catch(
1157
+ /* v8 ignore next */
1158
+ () => void 0
1159
+ );
1160
+ await fsp.rm(lockPath, { force: true }).catch(
1161
+ /* v8 ignore next */
1162
+ () => void 0
1163
+ );
1164
+ }
1165
+ }
955
1166
  async function assertNoLegacyPromoteArtifacts(outDir) {
956
1167
  const legacyTmp = `${outDir}.tmp-promote`;
957
1168
  const legacyBak = `${outDir}.bak`;
@@ -965,45 +1176,57 @@ async function assertNoLegacyPromoteArtifacts(outDir) {
965
1176
  }
966
1177
  }
967
1178
  async function promoteStagingToOutDir(stagingDir, outDir) {
968
- await assertNoLegacyPromoteArtifacts(outDir);
969
- const parent = (0, import_node_path6.dirname)(outDir);
970
- const tmpPromote = await fsp.mkdtemp((0, import_node_path6.join)(parent, ".lk-promote-"));
971
- await renameOrCopy(stagingDir, tmpPromote);
972
- const hadOutDir = await pathExists(outDir);
973
- const backup = hadOutDir ? await fsp.mkdtemp((0, import_node_path6.join)(parent, ".lk-backup-")) : void 0;
974
- if (hadOutDir && backup) {
975
- await renameOrCopy(outDir, backup);
976
- }
977
- try {
978
- await renameOrCopy(tmpPromote, outDir);
979
- } catch (promoteError) {
1179
+ return withPromoteLock(outDir, async () => {
1180
+ await assertNoLegacyPromoteArtifacts(outDir);
1181
+ const parent = (0, import_node_path6.dirname)(outDir);
1182
+ const tmpPromote = await fsp.mkdtemp((0, import_node_path6.join)(parent, ".lk-promote-"));
1183
+ await renameOrCopy(stagingDir, tmpPromote);
1184
+ const hadOutDir = await pathExists(outDir);
1185
+ const backup = hadOutDir ? await fsp.mkdtemp((0, import_node_path6.join)(parent, ".lk-backup-")) : void 0;
980
1186
  if (hadOutDir && backup) {
981
- try {
982
- await renameOrCopy(backup, outDir);
983
- } catch (restoreError) {
984
- const failedPromote2 = (0, import_node_path6.join)(parent, `.lk-failed-promote-${(0, import_node_crypto.randomUUID)()}`);
1187
+ await renameOrCopy(outDir, backup);
1188
+ }
1189
+ try {
1190
+ await renameOrCopy(tmpPromote, outDir);
1191
+ } catch (promoteError) {
1192
+ if (hadOutDir && backup) {
985
1193
  try {
986
- await renameOrCopy(tmpPromote, failedPromote2);
987
- } catch {
1194
+ await renameOrCopy(backup, outDir);
1195
+ } catch (restoreError) {
1196
+ const failedPromote2 = (0, import_node_path6.join)(parent, `.lk-failed-promote-${(0, import_node_crypto.randomUUID)()}`);
1197
+ try {
1198
+ await renameOrCopy(tmpPromote, failedPromote2);
1199
+ } catch {
1200
+ await fsp.rm(tmpPromote, { recursive: true, force: true }).catch(
1201
+ /* v8 ignore next */
1202
+ () => void 0
1203
+ );
1204
+ }
1205
+ const promoteMsg = promoteError instanceof Error ? promoteError.message : String(promoteError);
1206
+ const restoreMsg = restoreError instanceof Error ? restoreError.message : String(restoreError);
1207
+ throw new Error(
1208
+ `[lessonkit/lxpack] promote failed (${promoteMsg}) and could not restore ${outDir} (${restoreMsg}). Recovery: previous output may be in ${backup}; staged package may be in ${failedPromote2}.`
1209
+ );
1210
+ }
1211
+ } else {
1212
+ try {
1213
+ await renameOrCopy(tmpPromote, stagingDir);
1214
+ } catch (restoreError) {
1215
+ console.warn(
1216
+ `[lessonkit/lxpack] failed to restore ${stagingDir} after promote error:`,
1217
+ restoreError instanceof Error ? restoreError.message : restoreError
1218
+ );
988
1219
  await fsp.rm(tmpPromote, { recursive: true, force: true }).catch(
989
1220
  /* v8 ignore next */
990
1221
  () => void 0
991
1222
  );
992
1223
  }
993
- const promoteMsg = promoteError instanceof Error ? promoteError.message : String(promoteError);
994
- const restoreMsg = restoreError instanceof Error ? restoreError.message : String(restoreError);
995
- throw new Error(
996
- `[lessonkit/lxpack] promote failed (${promoteMsg}) and could not restore ${outDir} (${restoreMsg}). Recovery: previous output may be in ${backup}; staged package may be in ${failedPromote2}.`
997
- );
1224
+ throw promoteError;
998
1225
  }
999
- } else {
1226
+ const failedPromote = (0, import_node_path6.join)(parent, `.lk-failed-promote-${(0, import_node_crypto.randomUUID)()}`);
1000
1227
  try {
1001
- await renameOrCopy(tmpPromote, stagingDir);
1002
- } catch (restoreError) {
1003
- console.warn(
1004
- `[lessonkit/lxpack] failed to restore ${stagingDir} after promote error:`,
1005
- restoreError instanceof Error ? restoreError.message : restoreError
1006
- );
1228
+ await renameOrCopy(tmpPromote, failedPromote);
1229
+ } catch {
1007
1230
  await fsp.rm(tmpPromote, { recursive: true, force: true }).catch(
1008
1231
  /* v8 ignore next */
1009
1232
  () => void 0
@@ -1011,23 +1234,13 @@ async function promoteStagingToOutDir(stagingDir, outDir) {
1011
1234
  }
1012
1235
  throw promoteError;
1013
1236
  }
1014
- const failedPromote = (0, import_node_path6.join)(parent, `.lk-failed-promote-${(0, import_node_crypto.randomUUID)()}`);
1015
- try {
1016
- await renameOrCopy(tmpPromote, failedPromote);
1017
- } catch {
1018
- await fsp.rm(tmpPromote, { recursive: true, force: true }).catch(
1237
+ if (backup) {
1238
+ await fsp.rm(backup, { recursive: true, force: true }).catch(
1019
1239
  /* v8 ignore next */
1020
1240
  () => void 0
1021
1241
  );
1022
1242
  }
1023
- throw promoteError;
1024
- }
1025
- if (backup) {
1026
- await fsp.rm(backup, { recursive: true, force: true }).catch(
1027
- /* v8 ignore next */
1028
- () => void 0
1029
- );
1030
- }
1243
+ });
1031
1244
  }
1032
1245
 
1033
1246
  // src/packaging/staging.ts
@@ -1100,6 +1313,15 @@ async function ensureOutDirParent(outDir) {
1100
1313
  await fsp2.mkdir((0, import_node_path7.dirname)(outDir), { recursive: true });
1101
1314
  }
1102
1315
 
1316
+ // src/packaging/issueSeverity.ts
1317
+ function isPackagingErrorIssue(issue) {
1318
+ const severity = issue.severity?.toLowerCase();
1319
+ return severity === "error" || severity === "fatal";
1320
+ }
1321
+ function findPackagingErrorIssues(issues) {
1322
+ return (issues ?? []).filter(isPackagingErrorIssue);
1323
+ }
1324
+
1103
1325
  // src/packageCourse.ts
1104
1326
  async function validateLessonkitProject(options) {
1105
1327
  return (0, import_api2.validateCourse)({
@@ -1149,6 +1371,18 @@ async function packageLessonkitCourse(options) {
1149
1371
  };
1150
1372
  }
1151
1373
  const descriptor = descriptorValidation.descriptor;
1374
+ const nonInjectableAssessments = (descriptor.assessments ?? []).map((assessment, index) => ({ assessment, index })).filter(({ assessment }) => assessmentDescriptorToLxpack(assessment) === null);
1375
+ if (nonInjectableAssessments.length > 0) {
1376
+ return {
1377
+ ok: false,
1378
+ courseDir: outDir,
1379
+ target,
1380
+ issues: nonInjectableAssessments.map(({ assessment, index }) => ({
1381
+ path: `assessments[${index}]`,
1382
+ message: `assessment kind "${assessment.kind ?? "mcq"}" (checkId "${assessment.checkId}") is not injected into LMS shell quizzes for target "${target}"`
1383
+ }))
1384
+ };
1385
+ }
1152
1386
  const staged = await buildStagingPackage({
1153
1387
  ...writeOpts,
1154
1388
  descriptor,
@@ -1173,6 +1407,25 @@ async function packageLessonkitCourse(options) {
1173
1407
  };
1174
1408
  }
1175
1409
  const { stagingDir, build } = staged;
1410
+ const buildErrorIssues = findPackagingErrorIssues(build.issues);
1411
+ if (buildErrorIssues.length > 0) {
1412
+ await fsp3.rm(stagingDir, { recursive: true, force: true }).catch(
1413
+ /* v8 ignore next */
1414
+ () => void 0
1415
+ );
1416
+ return {
1417
+ ok: false,
1418
+ courseDir: outDir,
1419
+ target,
1420
+ validation: { ok: false, manifest: build.manifest, issues: build.issues },
1421
+ build,
1422
+ issues: build.issues.filter((i) => findPackagingErrorIssues([i]).length > 0).map((i) => ({
1423
+ path: i.path ?? "build",
1424
+ message: i.message,
1425
+ severity: i.severity
1426
+ }))
1427
+ };
1428
+ }
1176
1429
  const stagingRoot = await fsp3.realpath(stagingDir);
1177
1430
  const artifactIssues = [
1178
1431
  validateArtifactInStaging(stagingRoot, staged.outputPath, "outputPath"),
@@ -1203,6 +1456,10 @@ async function packageLessonkitCourse(options) {
1203
1456
  await ensureOutDirParent(outDir);
1204
1457
  await promoteStagingToOutDir(stagingDir, outDir);
1205
1458
  } catch (err) {
1459
+ await fsp3.rm(stagingDir, { recursive: true, force: true }).catch(
1460
+ /* v8 ignore next */
1461
+ () => void 0
1462
+ );
1206
1463
  return {
1207
1464
  ok: false,
1208
1465
  courseDir: outDir,