@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.js CHANGED
@@ -134,6 +134,14 @@ function parseAssessmentDescriptor(raw) {
134
134
  correctTargetIds: Array.isArray(raw.correctTargetIds) ? raw.correctTargetIds.filter((id) => typeof id === "string") : []
135
135
  };
136
136
  }
137
+ if (typeof kind === "string" && kind !== "mcq" && kind !== "trueFalse" && kind !== "fillInBlanks" && kind !== "findHotspot" && kind !== "findMultipleHotspots") {
138
+ return {
139
+ kind,
140
+ ...base,
141
+ choices: [],
142
+ answer: ""
143
+ };
144
+ }
137
145
  return {
138
146
  kind: kind === "mcq" ? "mcq" : void 0,
139
147
  ...base,
@@ -183,10 +191,11 @@ function parseCourseDescriptorInput(input) {
183
191
 
184
192
  // src/descriptor/validateCourse.ts
185
193
  import { validateId as validateId3 } from "@lessonkit/core";
194
+ import { validateTheme } from "@lessonkit/themes";
186
195
 
187
196
  // src/spaPath.ts
188
- import { realpathSync } from "fs";
189
- import { isAbsolute, relative, resolve, sep, win32 } from "path";
197
+ import { existsSync, realpathSync } from "fs";
198
+ import { isAbsolute, join, relative, resolve, sep, win32 } from "path";
190
199
  function resolveComparablePath(p) {
191
200
  if (/^[a-zA-Z]:[/\\]/.test(p)) {
192
201
  return win32.resolve(p);
@@ -212,6 +221,28 @@ function assertResolvedPathUnderRoot(root, target) {
212
221
  throw new Error(`unsafe path escapes project root: ${target}`);
213
222
  }
214
223
  }
224
+ function resolveExistingPathUnderRoot(rootReal, rootResolved, targetResolved) {
225
+ const rel = relative(rootResolved, targetResolved);
226
+ if (rel.startsWith("..") || rel.includes(`..${sep}`)) {
227
+ throw new Error(`unsafe path escapes project root: ${targetResolved}`);
228
+ }
229
+ const segments = rel.split(/[/\\]/).filter((s) => s.length > 0 && s !== ".");
230
+ let current = rootReal;
231
+ for (const segment of segments) {
232
+ const next = join(current, segment);
233
+ if (existsSync(next)) {
234
+ try {
235
+ current = realpathSync(next);
236
+ } catch {
237
+ current = next;
238
+ }
239
+ } else {
240
+ current = next;
241
+ }
242
+ assertResolvedPathUnderRoot(rootReal, current);
243
+ }
244
+ return current;
245
+ }
215
246
  function assertRealPathUnderRoot(root, target) {
216
247
  const rootResolved = resolveComparablePath(root);
217
248
  const targetResolved = resolveComparablePath(target);
@@ -221,17 +252,12 @@ function assertRealPathUnderRoot(root, target) {
221
252
  } catch {
222
253
  rootReal = rootResolved;
223
254
  }
224
- let targetCheck;
225
255
  try {
226
- targetCheck = realpathSync(targetResolved);
256
+ const targetCheck = realpathSync(targetResolved);
257
+ assertResolvedPathUnderRoot(rootReal, targetCheck);
227
258
  } catch {
228
- const rel = relative(rootResolved, targetResolved);
229
- if (rel.startsWith("..") || rel.includes(`..${sep}`)) {
230
- throw new Error(`unsafe path escapes project root: ${target}`);
231
- }
232
- targetCheck = resolve(rootReal, rel);
259
+ resolveExistingPathUnderRoot(rootReal, rootResolved, targetResolved);
233
260
  }
234
- assertResolvedPathUnderRoot(rootReal, targetCheck);
235
261
  }
236
262
  function normalizePathForComparison(p) {
237
263
  const resolved = resolveComparablePath(p);
@@ -272,7 +298,12 @@ function themeToLxpackRuntime(input) {
272
298
  // src/descriptor/validateAssessments.ts
273
299
  import { validateId as validateId2 } from "@lessonkit/core";
274
300
  var validateMcqLike = (assessment, path, issues) => {
275
- if (!("choices" in assessment) || !("answer" in assessment) || typeof assessment.answer !== "string") {
301
+ if (!("choices" in assessment) || !Array.isArray(assessment.choices)) {
302
+ issues.push({ path: `${path}.choices`, message: "choices is required for mcq" });
303
+ return;
304
+ }
305
+ if (!("answer" in assessment) || typeof assessment.answer !== "string") {
306
+ issues.push({ path: `${path}.answer`, message: "answer is required for mcq" });
276
307
  return;
277
308
  }
278
309
  const trimmedChoices = assessment.choices.map((c) => c.trim()).filter((c) => c.length > 0);
@@ -285,6 +316,22 @@ var validateMcqLike = (assessment, path, issues) => {
285
316
  issues.push({ path: `${path}.answer`, message: "answer must match a choice" });
286
317
  }
287
318
  };
319
+ function countStarDelimitedBlanks(template) {
320
+ const matches = template.match(/\*[^*]+\*/g);
321
+ return matches?.length ?? 0;
322
+ }
323
+ function maxAchievableAssessmentScore(assessment) {
324
+ const kind = assessment.kind ?? "mcq";
325
+ if (kind === "fillInBlanks" && assessment.kind === "fillInBlanks") {
326
+ const explicit = assessment.blanks?.filter((b) => b?.id?.trim() && b?.answer?.trim()).length ?? 0;
327
+ if (explicit > 0) return explicit;
328
+ return countStarDelimitedBlanks(assessment.template ?? "");
329
+ }
330
+ if (kind === "findMultipleHotspots" && assessment.kind === "findMultipleHotspots") {
331
+ return assessment.correctTargetIds?.map((id) => id.trim()).filter((id) => id.length > 0).length ?? 0;
332
+ }
333
+ return 1;
334
+ }
288
335
  var ASSESSMENT_VALIDATORS = {
289
336
  mcq: validateMcqLike,
290
337
  trueFalse: (assessment, path, issues) => {
@@ -297,9 +344,33 @@ var ASSESSMENT_VALIDATORS = {
297
344
  issues.push({ path: `${path}.template`, message: "template is required for fillInBlanks" });
298
345
  }
299
346
  },
300
- findHotspot: () => {
347
+ findHotspot: (assessment, path, issues) => {
348
+ if (assessment.kind !== "findHotspot") return;
349
+ if (!assessment.src?.trim()) {
350
+ issues.push({ path: `${path}.src`, message: "src is required for findHotspot" });
351
+ }
352
+ if (!assessment.alt?.trim()) {
353
+ issues.push({ path: `${path}.alt`, message: "alt is required for findHotspot" });
354
+ }
355
+ if (!assessment.correctTargetId?.trim()) {
356
+ issues.push({ path: `${path}.correctTargetId`, message: "correctTargetId is required for findHotspot" });
357
+ }
301
358
  },
302
- findMultipleHotspots: () => {
359
+ findMultipleHotspots: (assessment, path, issues) => {
360
+ if (assessment.kind !== "findMultipleHotspots") return;
361
+ if (!assessment.src?.trim()) {
362
+ issues.push({ path: `${path}.src`, message: "src is required for findMultipleHotspots" });
363
+ }
364
+ if (!assessment.alt?.trim()) {
365
+ issues.push({ path: `${path}.alt`, message: "alt is required for findMultipleHotspots" });
366
+ }
367
+ const ids = assessment.correctTargetIds?.map((id) => id.trim()).filter((id) => id.length > 0) ?? [];
368
+ if (!ids.length) {
369
+ issues.push({
370
+ path: `${path}.correctTargetIds`,
371
+ message: "at least one non-empty correctTargetId is required for findMultipleHotspots"
372
+ });
373
+ }
303
374
  }
304
375
  };
305
376
  function validateAssessmentEntry(assessment, index, issues, checkIds) {
@@ -315,14 +386,38 @@ function validateAssessmentEntry(assessment, index, issues, checkIds) {
315
386
  if (!assessment.question?.trim()) {
316
387
  issues.push({ path: `${path}.question`, message: "question is required" });
317
388
  }
389
+ const knownKinds = Object.keys(ASSESSMENT_VALIDATORS);
390
+ if (assessment.kind !== void 0 && assessment.kind !== "mcq" && !knownKinds.includes(assessment.kind)) {
391
+ issues.push({
392
+ path: `${path}.kind`,
393
+ message: `unknown kind; use one of: ${knownKinds.join(", ")}`
394
+ });
395
+ return;
396
+ }
318
397
  const kind = assessment.kind ?? "mcq";
319
- ASSESSMENT_VALIDATORS[kind](assessment, path, issues);
398
+ const validator = ASSESSMENT_VALIDATORS[kind];
399
+ if (!validator) {
400
+ issues.push({
401
+ path: `${path}.kind`,
402
+ message: `unknown kind; use one of: ${knownKinds.join(", ")}`
403
+ });
404
+ return;
405
+ }
406
+ validator(assessment, path, issues);
320
407
  const passingScore = assessment.passingScore;
321
408
  if (passingScore !== void 0 && !(Number.isFinite(passingScore) && passingScore > 0)) {
322
409
  issues.push({
323
410
  path: `${path}.passingScore`,
324
411
  message: "passingScore must be greater than 0 (absolute point threshold)"
325
412
  });
413
+ } else if (passingScore !== void 0) {
414
+ const maxAchievable = maxAchievableAssessmentScore(assessment);
415
+ if (maxAchievable > 0 && passingScore > maxAchievable) {
416
+ issues.push({
417
+ path: `${path}.passingScore`,
418
+ message: `passingScore cannot exceed achievable score (${maxAchievable}) for this assessment kind`
419
+ });
420
+ }
326
421
  }
327
422
  }
328
423
 
@@ -356,13 +451,23 @@ function validateCourseDescriptor(input) {
356
451
  });
357
452
  }
358
453
  if (input.theme?.theme) {
359
- try {
360
- themeToLxpackRuntime({ preset: themePreset, theme: input.theme.theme });
361
- } catch (err) {
362
- issues.push({
363
- path: "theme.theme",
364
- message: err instanceof Error ? err.message : "invalid custom theme"
365
- });
454
+ const themeResult = validateTheme(input.theme.theme);
455
+ if (!themeResult.ok) {
456
+ for (const issue of themeResult.issues) {
457
+ issues.push({
458
+ path: issue.path ? `theme.theme.${issue.path}` : "theme.theme",
459
+ message: issue.message
460
+ });
461
+ }
462
+ } else {
463
+ try {
464
+ themeToLxpackRuntime({ preset: themePreset, theme: themeResult.theme });
465
+ } catch (err) {
466
+ issues.push({
467
+ path: "theme.theme",
468
+ message: err instanceof Error ? err.message : "invalid custom theme"
469
+ });
470
+ }
366
471
  }
367
472
  }
368
473
  const completionThreshold = input.tracking?.completion?.threshold;
@@ -433,19 +538,102 @@ function validateCourseDescriptor(input) {
433
538
  return issues;
434
539
  }
435
540
 
541
+ // src/assessments.ts
542
+ function slugChoiceId(text, index) {
543
+ const base = text.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "").slice(0, 32);
544
+ const stem = base.length ? base : "choice";
545
+ return `${stem}-${index + 1}`;
546
+ }
547
+ function mcqToLxpack(assessment) {
548
+ const choices = assessment.choices.map((text, index) => {
549
+ const id = slugChoiceId(text, index);
550
+ return {
551
+ id,
552
+ text,
553
+ correct: text === assessment.answer
554
+ };
555
+ });
556
+ return {
557
+ id: assessment.checkId,
558
+ passingScore: assessment.passingScore ?? 1,
559
+ questions: [
560
+ {
561
+ id: "q1",
562
+ prompt: assessment.question,
563
+ choices
564
+ }
565
+ ]
566
+ };
567
+ }
568
+ function assessmentDescriptorToLxpack(assessment) {
569
+ const kind = assessment.kind ?? "mcq";
570
+ if (kind === "trueFalse" && assessment.kind === "trueFalse") {
571
+ const choices = ["True", "False"];
572
+ const answerText = assessment.answer ? "True" : "False";
573
+ return mcqToLxpack({
574
+ kind: "mcq",
575
+ checkId: assessment.checkId,
576
+ question: assessment.question,
577
+ choices,
578
+ answer: answerText,
579
+ passingScore: assessment.passingScore
580
+ });
581
+ }
582
+ if (kind === "fillInBlanks") {
583
+ return null;
584
+ }
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
+ });
594
+ }
595
+ if (kind === "findMultipleHotspots") {
596
+ return null;
597
+ }
598
+ if ("choices" in assessment && "answer" in assessment && typeof assessment.answer === "string") {
599
+ return mcqToLxpack(assessment);
600
+ }
601
+ return null;
602
+ }
603
+ function extractAssessments(descriptor) {
604
+ return (descriptor.assessments ?? []).map(assessmentDescriptorToLxpack).filter((a) => a !== null);
605
+ }
606
+
436
607
  // src/descriptor/validateForTarget.ts
608
+ var LMS_SHELL_TARGETS = /* @__PURE__ */ new Set([
609
+ "scorm12",
610
+ "scorm2004",
611
+ "standalone",
612
+ "xapi",
613
+ "cmi5"
614
+ ]);
437
615
  function validateDescriptorForExportTarget(descriptor, target) {
438
- if (target !== "xapi" && target !== "cmi5") return [];
439
- const activityIri = descriptor.tracking?.xapi?.activityIri?.trim();
440
- if (!activityIri) {
441
- return [
442
- {
616
+ const issues = [];
617
+ if (target === "xapi" || target === "cmi5") {
618
+ const activityIri = descriptor.tracking?.xapi?.activityIri?.trim();
619
+ if (!activityIri) {
620
+ issues.push({
443
621
  path: "course.tracking.xapi.activityIri",
444
622
  message: "tracking.xapi.activityIri is required for xapi and cmi5 export targets"
623
+ });
624
+ }
625
+ }
626
+ 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
+ });
445
633
  }
446
- ];
634
+ });
447
635
  }
448
- return [];
636
+ return issues;
449
637
  }
450
638
 
451
639
  // src/validateDescriptor.ts
@@ -534,72 +722,6 @@ function mapLessonkitIds(descriptor) {
534
722
  return { courseId, lessonIds, checkIds };
535
723
  }
536
724
 
537
- // src/assessments.ts
538
- function slugChoiceId(text, index) {
539
- const base = text.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "").slice(0, 32);
540
- const stem = base.length ? base : "choice";
541
- return `${stem}-${index + 1}`;
542
- }
543
- function mcqToLxpack(assessment) {
544
- const choices = assessment.choices.map((text, index) => {
545
- const id = slugChoiceId(text, index);
546
- return {
547
- id,
548
- text,
549
- correct: text === assessment.answer
550
- };
551
- });
552
- return {
553
- id: assessment.checkId,
554
- passingScore: assessment.passingScore ?? 1,
555
- questions: [
556
- {
557
- id: "q1",
558
- prompt: assessment.question,
559
- choices
560
- }
561
- ]
562
- };
563
- }
564
- function assessmentDescriptorToLxpack(assessment) {
565
- const kind = assessment.kind ?? "mcq";
566
- if (kind === "trueFalse" && assessment.kind === "trueFalse") {
567
- const choices = ["True", "False"];
568
- const answerText = assessment.answer ? "True" : "False";
569
- return mcqToLxpack({
570
- kind: "mcq",
571
- checkId: assessment.checkId,
572
- question: assessment.question,
573
- choices,
574
- answer: answerText,
575
- passingScore: assessment.passingScore
576
- });
577
- }
578
- if (kind === "fillInBlanks") {
579
- return null;
580
- }
581
- if (kind === "findHotspot" && assessment.kind === "findHotspot") {
582
- return mcqToLxpack({
583
- kind: "mcq",
584
- checkId: assessment.checkId,
585
- question: assessment.question,
586
- choices: [assessment.correctTargetId, "other"],
587
- answer: assessment.correctTargetId,
588
- passingScore: assessment.passingScore
589
- });
590
- }
591
- if (kind === "findMultipleHotspots") {
592
- return null;
593
- }
594
- if ("choices" in assessment && "answer" in assessment && typeof assessment.answer === "string") {
595
- return mcqToLxpack(assessment);
596
- }
597
- return null;
598
- }
599
- function extractAssessments(descriptor) {
600
- return (descriptor.assessments ?? []).map(assessmentDescriptorToLxpack).filter((a) => a !== null);
601
- }
602
-
603
725
  // src/interchange.ts
604
726
  function mapDescriptorTracking(tracking) {
605
727
  if (!tracking) return void 0;
@@ -660,12 +782,12 @@ function descriptorToInterchange(descriptor) {
660
782
  }
661
783
 
662
784
  // src/writeProject.ts
663
- import { join as join2, resolve as resolve4 } from "path";
785
+ import { join as join3, resolve as resolve4 } from "path";
664
786
  import { materializeLessonkitProject } from "@lxpack/validators";
665
787
 
666
788
  // src/spaDirs.ts
667
789
  import { access } from "fs/promises";
668
- import { join, resolve as resolve3 } from "path";
790
+ import { join as join2, resolve as resolve3 } from "path";
669
791
  async function resolveSpaDirs(options) {
670
792
  const { descriptor, spaDistDir, lessonSpaDirs, projectRoot } = options;
671
793
  const spaLessons = resolveSpaLessons(descriptor);
@@ -682,9 +804,9 @@ async function resolveSpaDirs(options) {
682
804
  throw new Error(`spaDistDir not found: ${srcDist}`);
683
805
  }
684
806
  try {
685
- await access(join(srcDist, "index.html"));
807
+ await access(join2(srcDist, "index.html"));
686
808
  } catch {
687
- throw new Error(`spaDistDir must contain index.html: ${join(srcDist, "index.html")}`);
809
+ throw new Error(`spaDistDir must contain index.html: ${join2(srcDist, "index.html")}`);
688
810
  }
689
811
  const lessonId = spaLessons[0]?.id ?? /* v8 ignore next */
690
812
  "main";
@@ -707,10 +829,10 @@ async function resolveSpaDirs(options) {
707
829
  throw new Error(`lessonSpaDirs path not found for lesson "${lesson.id}": ${resolved}`);
708
830
  }
709
831
  try {
710
- await access(join(resolved, "index.html"));
832
+ await access(join2(resolved, "index.html"));
711
833
  } catch {
712
834
  throw new Error(
713
- `lessonSpaDirs must contain index.html for lesson "${lesson.id}": ${join(resolved, "index.html")}`
835
+ `lessonSpaDirs must contain index.html for lesson "${lesson.id}": ${join2(resolved, "index.html")}`
714
836
  );
715
837
  }
716
838
  dirs[lesson.id] = resolved;
@@ -747,13 +869,13 @@ async function writeLxpackProject(options) {
747
869
  const courseDir = materialized.courseDir;
748
870
  return {
749
871
  outDir: courseDir,
750
- courseYamlPath: join2(courseDir, "course.yaml"),
751
- lessonkitJsonPath: join2(courseDir, "lessonkit.json")
872
+ courseYamlPath: join3(courseDir, "course.yaml"),
873
+ lessonkitJsonPath: join3(courseDir, "lessonkit.json")
752
874
  };
753
875
  }
754
876
 
755
877
  // src/packageCourse.ts
756
- import { resolve as resolve6 } from "path";
878
+ import { resolve as resolve7 } from "path";
757
879
  import * as fsp3 from "fs/promises";
758
880
  import {
759
881
  buildCourse,
@@ -761,48 +883,75 @@ import {
761
883
  } from "@lxpack/api";
762
884
 
763
885
  // src/packaging/validateInputs.ts
764
- import { isAbsolute as isAbsolute3, join as join3, resolve as resolve5, win32 as win322 } from "path";
886
+ import { isAbsolute as isAbsolute3, join as join4, resolve as resolve5, win32 as win322 } from "path";
765
887
  function validatePackageInputs(options) {
766
888
  const { target, output, outputBaseDir } = options;
767
889
  const outDir = resolve5(options.outDir);
768
- const projectRoot = options.projectRoot ? resolve5(options.projectRoot) : void 0;
769
- if (projectRoot) {
770
- try {
771
- assertRealPathUnderRoot(projectRoot, outDir);
772
- } catch (err) {
773
- return {
774
- ok: false,
775
- courseDir: outDir,
776
- target,
777
- issues: [
778
- {
779
- path: "outDir",
780
- message: (
781
- /* v8 ignore next */
782
- err instanceof Error ? err.message : String(err)
783
- )
784
- }
785
- ]
786
- };
787
- }
890
+ if (!options.projectRoot) {
891
+ return {
892
+ ok: false,
893
+ courseDir: outDir,
894
+ target,
895
+ issues: [{ path: "projectRoot", message: "projectRoot is required for packageLessonkitCourse" }]
896
+ };
788
897
  }
789
- if (outputBaseDir && !isSafeRelativeSpaPath(outputBaseDir)) {
898
+ const projectRoot = resolve5(options.projectRoot);
899
+ try {
900
+ assertRealPathUnderRoot(projectRoot, outDir);
901
+ } catch (err) {
790
902
  return {
791
903
  ok: false,
792
904
  courseDir: outDir,
793
905
  target,
794
- issues: [{ path: "outputBaseDir", message: `unsafe outputBaseDir: ${outputBaseDir}` }]
906
+ issues: [
907
+ {
908
+ path: "outDir",
909
+ message: (
910
+ /* v8 ignore next */
911
+ err instanceof Error ? err.message : String(err)
912
+ )
913
+ }
914
+ ]
795
915
  };
796
916
  }
797
- if (output && !projectRoot && !isSafeRelativeSpaPath(output)) {
917
+ if (outputBaseDir && !isSafeRelativeSpaPath(outputBaseDir)) {
798
918
  return {
799
919
  ok: false,
800
920
  courseDir: outDir,
801
921
  target,
802
- issues: [{ path: "output", message: `unsafe output: ${output}` }]
922
+ issues: [{ path: "outputBaseDir", message: `unsafe outputBaseDir: ${outputBaseDir}` }]
803
923
  };
804
924
  }
805
- if (projectRoot && outputBaseDir) {
925
+ if (output && !isSafeRelativeSpaPath(output)) {
926
+ if (isAbsolute3(output)) {
927
+ try {
928
+ assertRealPathUnderRoot(projectRoot, resolve5(output));
929
+ } catch (err) {
930
+ return {
931
+ ok: false,
932
+ courseDir: outDir,
933
+ target,
934
+ issues: [
935
+ {
936
+ path: "output",
937
+ message: (
938
+ /* v8 ignore next */
939
+ err instanceof Error ? err.message : `unsafe output: ${output}`
940
+ )
941
+ }
942
+ ]
943
+ };
944
+ }
945
+ } else {
946
+ return {
947
+ ok: false,
948
+ courseDir: outDir,
949
+ target,
950
+ issues: [{ path: "output", message: `unsafe output: ${output}` }]
951
+ };
952
+ }
953
+ }
954
+ if (outputBaseDir) {
806
955
  const resolvedOutputBase = resolve5(projectRoot, outputBaseDir);
807
956
  try {
808
957
  assertRealPathUnderRoot(projectRoot, resolvedOutputBase);
@@ -823,8 +972,8 @@ function validatePackageInputs(options) {
823
972
  };
824
973
  }
825
974
  }
826
- if (projectRoot && output) {
827
- const resolvedOutput = resolve5(projectRoot, output);
975
+ if (output) {
976
+ const resolvedOutput = isAbsolute3(output) ? resolve5(output) : resolve5(projectRoot, output);
828
977
  try {
829
978
  assertRealPathUnderRoot(projectRoot, resolvedOutput);
830
979
  } catch (err) {
@@ -861,23 +1010,23 @@ function remapArtifactPaths(stagingRoot, outDir, artifactPath) {
861
1010
  if (!artifactPath) return void 0;
862
1011
  const resolved = resolveComparablePath(artifactPath);
863
1012
  if (!isResolvedPathUnderRoot(stagingRoot, resolved)) {
864
- return artifactPath;
1013
+ throw new Error(`${artifactPath} is outside the staging directory`);
865
1014
  }
866
1015
  const rel = relativePathUnderRoot(stagingRoot, resolved);
867
1016
  if (rel.startsWith("..") || isAbsolute3(rel)) {
868
- return artifactPath;
1017
+ throw new Error(`${artifactPath} is outside the staging directory`);
869
1018
  }
870
1019
  if (!rel) return outDir;
871
1020
  if (/^[a-zA-Z]:[/\\]/.test(outDir)) {
872
1021
  return win322.join(outDir, rel.replace(/\//g, win322.sep));
873
1022
  }
874
- return join3(outDir, rel);
1023
+ return join4(outDir, rel);
875
1024
  }
876
1025
 
877
1026
  // src/packaging/promote.ts
878
1027
  import * as fsp from "fs/promises";
879
- import { randomUUID } from "crypto";
880
- import { dirname, join as join4 } from "path";
1028
+ import { createHash, randomUUID } from "crypto";
1029
+ import { dirname, join as join5, resolve as resolve6 } from "path";
881
1030
  async function pathExists(path) {
882
1031
  try {
883
1032
  await fsp.access(path);
@@ -896,6 +1045,68 @@ async function renameOrCopy(from, to) {
896
1045
  await fsp.rm(from, { recursive: true, force: true });
897
1046
  }
898
1047
  }
1048
+ function promoteLockPath(outDir) {
1049
+ const parent = dirname(outDir);
1050
+ const hash = createHash("sha256").update(resolve6(outDir)).digest("hex").slice(0, 16);
1051
+ return join5(parent, `.lk-promote-lock-${hash}`);
1052
+ }
1053
+ var STALE_LOCK_TTL_MS = 5 * 60 * 1e3;
1054
+ async function isStalePromoteLock(lockPath) {
1055
+ try {
1056
+ const stat2 = await fsp.stat(lockPath);
1057
+ if (Date.now() - stat2.mtimeMs > STALE_LOCK_TTL_MS) return true;
1058
+ const content = await fsp.readFile(lockPath, "utf8");
1059
+ const pid = Number.parseInt(content.trim(), 10);
1060
+ if (!Number.isFinite(pid) || pid <= 0) return true;
1061
+ try {
1062
+ process.kill(pid, 0);
1063
+ return false;
1064
+ } catch {
1065
+ return true;
1066
+ }
1067
+ } catch {
1068
+ return true;
1069
+ }
1070
+ }
1071
+ async function withPromoteLock(outDir, fn) {
1072
+ const lockPath = promoteLockPath(outDir);
1073
+ await fsp.mkdir(dirname(outDir), { recursive: true });
1074
+ let lockHandle;
1075
+ for (let attempt = 0; attempt < 200; attempt++) {
1076
+ try {
1077
+ lockHandle = await fsp.open(lockPath, "wx");
1078
+ await lockHandle.writeFile(`${process.pid}
1079
+ `, "utf8");
1080
+ break;
1081
+ } catch (err) {
1082
+ const code = err && typeof err === "object" && "code" in err ? String(err.code) : "";
1083
+ if (code !== "EEXIST") throw err;
1084
+ if (await isStalePromoteLock(lockPath)) {
1085
+ await fsp.rm(lockPath, { force: true }).catch(
1086
+ /* v8 ignore next */
1087
+ () => void 0
1088
+ );
1089
+ continue;
1090
+ }
1091
+ await new Promise((resolveWait) => setTimeout(resolveWait, 25));
1092
+ }
1093
+ }
1094
+ if (!lockHandle) {
1095
+ throw new Error(`[lessonkit/lxpack] timed out acquiring promote lock for ${outDir}`);
1096
+ }
1097
+ try {
1098
+ return await fn();
1099
+ } finally {
1100
+ await lockHandle.close().catch(
1101
+ /* v8 ignore next */
1102
+ () => void 0
1103
+ );
1104
+ await fsp.rm(lockPath, { force: true }).catch(
1105
+ /* v8 ignore next */
1106
+ () => void 0
1107
+ );
1108
+ }
1109
+ }
899
1110
  async function assertNoLegacyPromoteArtifacts(outDir) {
900
1111
  const legacyTmp = `${outDir}.tmp-promote`;
901
1112
  const legacyBak = `${outDir}.bak`;
@@ -909,45 +1120,57 @@ async function assertNoLegacyPromoteArtifacts(outDir) {
909
1120
  }
910
1121
  }
911
1122
  async function promoteStagingToOutDir(stagingDir, outDir) {
912
- await assertNoLegacyPromoteArtifacts(outDir);
913
- const parent = dirname(outDir);
914
- const tmpPromote = await fsp.mkdtemp(join4(parent, ".lk-promote-"));
915
- await renameOrCopy(stagingDir, tmpPromote);
916
- const hadOutDir = await pathExists(outDir);
917
- const backup = hadOutDir ? await fsp.mkdtemp(join4(parent, ".lk-backup-")) : void 0;
918
- if (hadOutDir && backup) {
919
- await renameOrCopy(outDir, backup);
920
- }
921
- try {
922
- await renameOrCopy(tmpPromote, outDir);
923
- } catch (promoteError) {
1123
+ return withPromoteLock(outDir, async () => {
1124
+ await assertNoLegacyPromoteArtifacts(outDir);
1125
+ const parent = dirname(outDir);
1126
+ const tmpPromote = await fsp.mkdtemp(join5(parent, ".lk-promote-"));
1127
+ await renameOrCopy(stagingDir, tmpPromote);
1128
+ const hadOutDir = await pathExists(outDir);
1129
+ const backup = hadOutDir ? await fsp.mkdtemp(join5(parent, ".lk-backup-")) : void 0;
924
1130
  if (hadOutDir && backup) {
925
- try {
926
- await renameOrCopy(backup, outDir);
927
- } catch (restoreError) {
928
- const failedPromote2 = join4(parent, `.lk-failed-promote-${randomUUID()}`);
1131
+ await renameOrCopy(outDir, backup);
1132
+ }
1133
+ try {
1134
+ await renameOrCopy(tmpPromote, outDir);
1135
+ } catch (promoteError) {
1136
+ if (hadOutDir && backup) {
929
1137
  try {
930
- await renameOrCopy(tmpPromote, failedPromote2);
931
- } catch {
1138
+ await renameOrCopy(backup, outDir);
1139
+ } catch (restoreError) {
1140
+ const failedPromote2 = join5(parent, `.lk-failed-promote-${randomUUID()}`);
1141
+ try {
1142
+ await renameOrCopy(tmpPromote, failedPromote2);
1143
+ } catch {
1144
+ await fsp.rm(tmpPromote, { recursive: true, force: true }).catch(
1145
+ /* v8 ignore next */
1146
+ () => void 0
1147
+ );
1148
+ }
1149
+ const promoteMsg = promoteError instanceof Error ? promoteError.message : String(promoteError);
1150
+ const restoreMsg = restoreError instanceof Error ? restoreError.message : String(restoreError);
1151
+ throw new Error(
1152
+ `[lessonkit/lxpack] promote failed (${promoteMsg}) and could not restore ${outDir} (${restoreMsg}). Recovery: previous output may be in ${backup}; staged package may be in ${failedPromote2}.`
1153
+ );
1154
+ }
1155
+ } else {
1156
+ try {
1157
+ await renameOrCopy(tmpPromote, stagingDir);
1158
+ } catch (restoreError) {
1159
+ console.warn(
1160
+ `[lessonkit/lxpack] failed to restore ${stagingDir} after promote error:`,
1161
+ restoreError instanceof Error ? restoreError.message : restoreError
1162
+ );
932
1163
  await fsp.rm(tmpPromote, { recursive: true, force: true }).catch(
933
1164
  /* v8 ignore next */
934
1165
  () => void 0
935
1166
  );
936
1167
  }
937
- const promoteMsg = promoteError instanceof Error ? promoteError.message : String(promoteError);
938
- const restoreMsg = restoreError instanceof Error ? restoreError.message : String(restoreError);
939
- throw new Error(
940
- `[lessonkit/lxpack] promote failed (${promoteMsg}) and could not restore ${outDir} (${restoreMsg}). Recovery: previous output may be in ${backup}; staged package may be in ${failedPromote2}.`
941
- );
1168
+ throw promoteError;
942
1169
  }
943
- } else {
1170
+ const failedPromote = join5(parent, `.lk-failed-promote-${randomUUID()}`);
944
1171
  try {
945
- await renameOrCopy(tmpPromote, stagingDir);
946
- } catch (restoreError) {
947
- console.warn(
948
- `[lessonkit/lxpack] failed to restore ${stagingDir} after promote error:`,
949
- restoreError instanceof Error ? restoreError.message : restoreError
950
- );
1172
+ await renameOrCopy(tmpPromote, failedPromote);
1173
+ } catch {
951
1174
  await fsp.rm(tmpPromote, { recursive: true, force: true }).catch(
952
1175
  /* v8 ignore next */
953
1176
  () => void 0
@@ -955,33 +1178,23 @@ async function promoteStagingToOutDir(stagingDir, outDir) {
955
1178
  }
956
1179
  throw promoteError;
957
1180
  }
958
- const failedPromote = join4(parent, `.lk-failed-promote-${randomUUID()}`);
959
- try {
960
- await renameOrCopy(tmpPromote, failedPromote);
961
- } catch {
962
- await fsp.rm(tmpPromote, { recursive: true, force: true }).catch(
1181
+ if (backup) {
1182
+ await fsp.rm(backup, { recursive: true, force: true }).catch(
963
1183
  /* v8 ignore next */
964
1184
  () => void 0
965
1185
  );
966
1186
  }
967
- throw promoteError;
968
- }
969
- if (backup) {
970
- await fsp.rm(backup, { recursive: true, force: true }).catch(
971
- /* v8 ignore next */
972
- () => void 0
973
- );
974
- }
1187
+ });
975
1188
  }
976
1189
 
977
1190
  // src/packaging/staging.ts
978
1191
  import * as fsp2 from "fs/promises";
979
- import { dirname as dirname2, join as join5 } from "path";
1192
+ import { dirname as dirname2, join as join6 } from "path";
980
1193
  import { tmpdir } from "os";
981
1194
  import { packageLessonkit } from "@lxpack/api";
982
1195
  async function buildStagingPackage(options) {
983
1196
  const { target, output, dir, outputBaseDir, descriptor, ...writeOpts } = options;
984
- const stagingDir = await fsp2.mkdtemp(join5(tmpdir(), "lessonkit-lxpack-"));
1197
+ const stagingDir = await fsp2.mkdtemp(join6(tmpdir(), "lessonkit-lxpack-"));
985
1198
  try {
986
1199
  let spaDirs;
987
1200
  try {
@@ -1000,8 +1213,8 @@ async function buildStagingPackage(options) {
1000
1213
  }
1001
1214
  const interchange = descriptorToInterchange(descriptor);
1002
1215
  const outputBase = outputBaseDir ?? ".lxpack/out";
1003
- await fsp2.mkdir(join5(stagingDir, outputBase), { recursive: true });
1004
- const defaultOutput = output ?? (dir ? join5(outputBase, target) : join5(outputBase, `course-${target}.zip`));
1216
+ await fsp2.mkdir(join6(stagingDir, outputBase), { recursive: true });
1217
+ const defaultOutput = output ?? (dir ? join6(outputBase, target) : join6(outputBase, `course-${target}.zip`));
1005
1218
  const build = await packageLessonkit({
1006
1219
  interchange,
1007
1220
  spaDirs,
@@ -1044,16 +1257,25 @@ async function ensureOutDirParent(outDir) {
1044
1257
  await fsp2.mkdir(dirname2(outDir), { recursive: true });
1045
1258
  }
1046
1259
 
1260
+ // src/packaging/issueSeverity.ts
1261
+ function isPackagingErrorIssue(issue) {
1262
+ const severity = issue.severity?.toLowerCase();
1263
+ return severity === "error" || severity === "fatal";
1264
+ }
1265
+ function findPackagingErrorIssues(issues) {
1266
+ return (issues ?? []).filter(isPackagingErrorIssue);
1267
+ }
1268
+
1047
1269
  // src/packageCourse.ts
1048
1270
  async function validateLessonkitProject(options) {
1049
1271
  return validateCourse({
1050
- courseDir: resolve6(options.courseDir),
1272
+ courseDir: resolve7(options.courseDir),
1051
1273
  target: options.target
1052
1274
  });
1053
1275
  }
1054
1276
  async function buildLessonkitProject(options) {
1055
1277
  const buildOptions = {
1056
- courseDir: resolve6(options.courseDir),
1278
+ courseDir: resolve7(options.courseDir),
1057
1279
  target: options.target,
1058
1280
  output: options.output,
1059
1281
  dir: options.dir,
@@ -1084,7 +1306,7 @@ async function packageLessonkitCourse(options) {
1084
1306
  if (!descriptorValidation.ok) {
1085
1307
  return {
1086
1308
  ok: false,
1087
- courseDir: resolve6(writeOpts.outDir),
1309
+ courseDir: resolve7(writeOpts.outDir),
1088
1310
  target,
1089
1311
  issues: descriptorValidation.issues.map((i) => ({
1090
1312
  path: i.path,
@@ -1093,6 +1315,18 @@ async function packageLessonkitCourse(options) {
1093
1315
  };
1094
1316
  }
1095
1317
  const descriptor = descriptorValidation.descriptor;
1318
+ const nonInjectableAssessments = (descriptor.assessments ?? []).map((assessment, index) => ({ assessment, index })).filter(({ assessment }) => assessmentDescriptorToLxpack(assessment) === null);
1319
+ if (nonInjectableAssessments.length > 0) {
1320
+ return {
1321
+ ok: false,
1322
+ courseDir: outDir,
1323
+ target,
1324
+ issues: nonInjectableAssessments.map(({ assessment, index }) => ({
1325
+ path: `assessments[${index}]`,
1326
+ message: `assessment kind "${assessment.kind ?? "mcq"}" (checkId "${assessment.checkId}") is not injected into LMS shell quizzes for target "${target}"`
1327
+ }))
1328
+ };
1329
+ }
1096
1330
  const staged = await buildStagingPackage({
1097
1331
  ...writeOpts,
1098
1332
  descriptor,
@@ -1117,6 +1351,25 @@ async function packageLessonkitCourse(options) {
1117
1351
  };
1118
1352
  }
1119
1353
  const { stagingDir, build } = staged;
1354
+ const buildErrorIssues = findPackagingErrorIssues(build.issues);
1355
+ if (buildErrorIssues.length > 0) {
1356
+ await fsp3.rm(stagingDir, { recursive: true, force: true }).catch(
1357
+ /* v8 ignore next */
1358
+ () => void 0
1359
+ );
1360
+ return {
1361
+ ok: false,
1362
+ courseDir: outDir,
1363
+ target,
1364
+ validation: { ok: false, manifest: build.manifest, issues: build.issues },
1365
+ build,
1366
+ issues: build.issues.filter((i) => findPackagingErrorIssues([i]).length > 0).map((i) => ({
1367
+ path: i.path ?? "build",
1368
+ message: i.message,
1369
+ severity: i.severity
1370
+ }))
1371
+ };
1372
+ }
1120
1373
  const stagingRoot = await fsp3.realpath(stagingDir);
1121
1374
  const artifactIssues = [
1122
1375
  validateArtifactInStaging(stagingRoot, staged.outputPath, "outputPath"),
@@ -1147,6 +1400,10 @@ async function packageLessonkitCourse(options) {
1147
1400
  await ensureOutDirParent(outDir);
1148
1401
  await promoteStagingToOutDir(stagingDir, outDir);
1149
1402
  } catch (err) {
1403
+ await fsp3.rm(stagingDir, { recursive: true, force: true }).catch(
1404
+ /* v8 ignore next */
1405
+ () => void 0
1406
+ );
1150
1407
  return {
1151
1408
  ok: false,
1152
1409
  courseDir: outDir,