@lessonkit/lxpack 1.4.0 → 1.6.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";
@@ -99,10 +99,18 @@ function parseAssessmentDescriptor(raw) {
99
99
  };
100
100
  const kind = raw.kind;
101
101
  if (kind === "trueFalse") {
102
+ let answer;
103
+ if (typeof raw.answer === "boolean") {
104
+ answer = raw.answer;
105
+ } else if (raw.answer === "true") {
106
+ answer = true;
107
+ } else if (raw.answer === "false") {
108
+ answer = false;
109
+ }
102
110
  return {
103
111
  kind: "trueFalse",
104
112
  ...base,
105
- answer: typeof raw.answer === "boolean" ? raw.answer : raw.answer === "true"
113
+ answer
106
114
  };
107
115
  }
108
116
  if (kind === "fillInBlanks") {
@@ -280,6 +288,98 @@ function isResolvedPathUnderRoot(root, target) {
280
288
  return !rel.startsWith("..") && !isAbsolute(rel);
281
289
  }
282
290
 
291
+ // src/validateProjectPaths.ts
292
+ import { existsSync as existsSync2, realpathSync as realpathSync2 } from "fs";
293
+ import { isAbsolute as isAbsolute2, resolve as resolve2 } from "path";
294
+ var RESERVED_OUTPUT_SEGMENTS = /* @__PURE__ */ new Set([".git", "node_modules", ".github"]);
295
+ function isReservedOutputPath(value) {
296
+ let normalized = value.replace(/\\/g, "/");
297
+ while (normalized.startsWith("/")) normalized = normalized.slice(1);
298
+ while (normalized.endsWith("/")) normalized = normalized.slice(0, -1);
299
+ const segments = normalized.split("/").filter(Boolean);
300
+ return segments.some((segment) => RESERVED_OUTPUT_SEGMENTS.has(segment));
301
+ }
302
+ function isReservedResolvedOutputPath(projectRoot, resolved) {
303
+ const rootResolved = resolveComparablePath(projectRoot);
304
+ const targetResolved = resolveComparablePath(resolved);
305
+ try {
306
+ const rootReal = existsSync2(rootResolved) ? realpathSync2(rootResolved) : rootResolved;
307
+ const targetReal = existsSync2(targetResolved) ? realpathSync2(targetResolved) : targetResolved;
308
+ const rel = relativePathUnderRoot(rootReal, targetReal);
309
+ return isReservedOutputPath(rel);
310
+ } catch {
311
+ return isReservedOutputPath(resolved);
312
+ }
313
+ }
314
+ function validatePathField(value, fieldPath, projectRoot, issues, options) {
315
+ if (!isSafeRelativeSpaPath(value)) {
316
+ issues.push({
317
+ path: fieldPath,
318
+ message: "path must be relative without '..' segments or absolute prefixes"
319
+ });
320
+ return;
321
+ }
322
+ if (options?.rejectReserved && isReservedOutputPath(value)) {
323
+ issues.push({
324
+ path: fieldPath,
325
+ message: "path must not target reserved directories (.git, node_modules, .github)"
326
+ });
327
+ return;
328
+ }
329
+ try {
330
+ assertRealPathUnderRoot(projectRoot, resolve2(projectRoot, value));
331
+ } catch {
332
+ issues.push({
333
+ path: fieldPath,
334
+ message: "path must resolve inside the project root"
335
+ });
336
+ }
337
+ }
338
+ function validateProjectPaths(projectRoot, paths) {
339
+ const issues = [];
340
+ const root = resolve2(projectRoot);
341
+ if (paths.spaDistDir?.trim()) {
342
+ validatePathField(paths.spaDistDir.trim(), "paths.spaDistDir", root, issues, {
343
+ rejectReserved: true
344
+ });
345
+ }
346
+ if (paths.lxpackOutDir?.trim()) {
347
+ validatePathField(paths.lxpackOutDir.trim(), "paths.lxpackOutDir", root, issues, {
348
+ rejectReserved: true
349
+ });
350
+ }
351
+ if (paths.outputBaseDir?.trim()) {
352
+ validatePathField(paths.outputBaseDir.trim(), "paths.outputBaseDir", root, issues, {
353
+ rejectReserved: true
354
+ });
355
+ }
356
+ return issues;
357
+ }
358
+ function resolveSafePackageOutputOverride(projectRoot, override) {
359
+ const root = resolve2(projectRoot);
360
+ const trimmed = override.trim();
361
+ if (!trimmed) {
362
+ throw new Error("output override must be a non-empty path");
363
+ }
364
+ if (isAbsolute2(trimmed)) {
365
+ const resolved2 = resolve2(trimmed);
366
+ assertRealPathUnderRoot(root, resolved2);
367
+ if (isReservedOutputPath(trimmed) || isReservedResolvedOutputPath(root, resolved2)) {
368
+ throw new Error(`unsafe output path: ${override} targets a reserved directory`);
369
+ }
370
+ return resolved2;
371
+ }
372
+ if (!isSafeRelativeSpaPath(trimmed)) {
373
+ throw new Error(`unsafe output path: ${override}`);
374
+ }
375
+ const resolved = resolve2(root, trimmed);
376
+ assertRealPathUnderRoot(root, resolved);
377
+ if (isReservedOutputPath(trimmed) || isReservedResolvedOutputPath(root, resolved)) {
378
+ throw new Error(`unsafe output path: ${override} targets a reserved directory`);
379
+ }
380
+ return resolved;
381
+ }
382
+
283
383
  // src/theme.ts
284
384
  import { getPresetTheme, themeToCssVariables } from "@lessonkit/themes";
285
385
  function themeToLxpackRuntime(input) {
@@ -344,8 +444,52 @@ var ASSESSMENT_VALIDATORS = {
344
444
  }
345
445
  },
346
446
  fillInBlanks: (assessment, path, issues) => {
347
- if (assessment.kind === "fillInBlanks" && !assessment.template?.trim()) {
447
+ if (assessment.kind !== "fillInBlanks") return;
448
+ if (!assessment.template?.trim()) {
348
449
  issues.push({ path: `${path}.template`, message: "template is required for fillInBlanks" });
450
+ return;
451
+ }
452
+ const templateBlankCount = countStarDelimitedBlanks(assessment.template);
453
+ if (templateBlankCount === 0) {
454
+ issues.push({
455
+ path: `${path}.template`,
456
+ message: "template must include at least one blank wrapped in asterisks for fillInBlanks"
457
+ });
458
+ }
459
+ const explicitBlanks = [];
460
+ if (assessment.blanks !== void 0) {
461
+ for (let i = 0; i < assessment.blanks.length; i++) {
462
+ const blank = assessment.blanks[i];
463
+ if (!blank || typeof blank !== "object") {
464
+ issues.push({
465
+ path: `${path}.blanks[${i}]`,
466
+ message: "blank entry must be an object with non-empty id and answer"
467
+ });
468
+ continue;
469
+ }
470
+ const id = blank.id?.trim() ?? "";
471
+ const answer = blank.answer?.trim() ?? "";
472
+ if (!id || !answer) {
473
+ issues.push({
474
+ path: `${path}.blanks[${i}]`,
475
+ message: "blank entry must include non-empty id and answer"
476
+ });
477
+ continue;
478
+ }
479
+ explicitBlanks.push({ id, answer });
480
+ }
481
+ }
482
+ if (assessment.blanks !== void 0 && explicitBlanks.length === 0 && !issues.some((issue) => issue.path?.startsWith(`${path}.blanks`))) {
483
+ issues.push({
484
+ path: `${path}.blanks`,
485
+ message: "blanks must include at least one entry with non-empty id and answer"
486
+ });
487
+ }
488
+ if (explicitBlanks.length > 0 && explicitBlanks.length !== templateBlankCount) {
489
+ issues.push({
490
+ path: `${path}.blanks`,
491
+ message: `blanks length (${explicitBlanks.length}) must match template blank count (${templateBlankCount})`
492
+ });
349
493
  }
350
494
  },
351
495
  findHotspot: (assessment, path, issues) => {
@@ -483,6 +627,20 @@ function validateCourseDescriptor(input) {
483
627
  });
484
628
  }
485
629
  }
630
+ const descriptorSpaDistDir = input.spaDistDir?.trim();
631
+ if (descriptorSpaDistDir) {
632
+ if (!isSafeRelativeSpaPath(descriptorSpaDistDir)) {
633
+ issues.push({
634
+ path: "spaDistDir",
635
+ message: "spaDistDir must be a relative path without '..' segments or absolute prefixes"
636
+ });
637
+ } else if (isReservedOutputPath(descriptorSpaDistDir)) {
638
+ issues.push({
639
+ path: "spaDistDir",
640
+ message: "spaDistDir must not target reserved directories (.git, node_modules, .github)"
641
+ });
642
+ }
643
+ }
486
644
  if (layout === "single-spa" && (input.lessons?.length ?? 0) > 1) {
487
645
  issues.push({
488
646
  path: "lessons",
@@ -543,27 +701,49 @@ function validateCourseDescriptor(input) {
543
701
  }
544
702
 
545
703
  // src/assessments.ts
704
+ var DEFAULT_SHELL_PASSING_SCORE = 1;
705
+ function escapeShellText(text) {
706
+ return text.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;").replace(/'/g, "&#39;");
707
+ }
708
+ function decodeShellEntities(text) {
709
+ 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)));
710
+ }
711
+ function containsUnsafeShellMarkup(text) {
712
+ const decoded = decodeShellEntities(text);
713
+ return /<\/script/i.test(decoded) || /<!--/.test(decoded) || /<[a-zA-Z!/]/.test(decoded);
714
+ }
715
+ function sanitizeShellField(text) {
716
+ if (containsUnsafeShellMarkup(text)) return null;
717
+ return escapeShellText(text);
718
+ }
546
719
  function slugChoiceId(text, index) {
547
720
  const base = text.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "").slice(0, 32);
548
721
  const stem = base.length ? base : "choice";
549
722
  return `${stem}-${index + 1}`;
550
723
  }
551
724
  function mcqToLxpack(assessment) {
725
+ const checkId = sanitizeShellField(assessment.checkId);
726
+ const prompt = sanitizeShellField(assessment.question);
727
+ if (!checkId || !prompt) return null;
728
+ const normalizedAnswer = assessment.answer.trim();
552
729
  const choices = assessment.choices.map((text, index) => {
730
+ const sanitizedText = sanitizeShellField(text);
731
+ if (!sanitizedText) return null;
553
732
  const id = slugChoiceId(text, index);
554
733
  return {
555
734
  id,
556
- text,
557
- correct: text === assessment.answer
735
+ text: sanitizedText,
736
+ correct: text.trim() === normalizedAnswer
558
737
  };
559
738
  });
739
+ if (choices.some((choice) => choice === null)) return null;
560
740
  return {
561
- id: assessment.checkId,
562
- passingScore: assessment.passingScore ?? 1,
741
+ id: checkId,
742
+ passingScore: assessment.passingScore ?? DEFAULT_SHELL_PASSING_SCORE,
563
743
  questions: [
564
744
  {
565
745
  id: "q1",
566
- prompt: assessment.question,
746
+ prompt,
567
747
  choices
568
748
  }
569
749
  ]
@@ -604,11 +784,14 @@ function extractAssessments(descriptor) {
604
784
  // src/descriptor/validateInjectableAssessments.ts
605
785
  function validateInjectableAssessments(descriptor) {
606
786
  const issues = [];
787
+ const spaOnlyKinds = /* @__PURE__ */ new Set(["fillInBlanks", "findHotspot", "findMultipleHotspots"]);
607
788
  (descriptor.assessments ?? []).forEach((assessment, index) => {
608
789
  if (assessmentDescriptorToLxpack(assessment) === null) {
790
+ const kind = assessment.kind ?? "mcq";
791
+ const hint = spaOnlyKinds.has(kind) ? " \u2014 score in the SPA only; remove from lessonkit.json for LMS targets or use an injectable kind (mcq, trueFalse)" : "";
609
792
  issues.push({
610
793
  path: `assessments[${index}]`,
611
- message: `assessment kind "${assessment.kind ?? "mcq"}" (checkId "${assessment.checkId}") is not injected into LMS shell quizzes`
794
+ message: `assessment kind "${kind}" (checkId "${assessment.checkId}") is not injected into LMS shell quizzes${hint}`
612
795
  });
613
796
  }
614
797
  });
@@ -623,22 +806,29 @@ var LMS_SHELL_TARGETS = /* @__PURE__ */ new Set([
623
806
  "xapi",
624
807
  "cmi5"
625
808
  ]);
809
+ function appendActivityIriIssues(issues, descriptor, target) {
810
+ const hasXapiTracking = Boolean(descriptor.tracking?.xapi?.activityIri?.trim());
811
+ const requiresForTarget = target === "xapi" || target === "cmi5";
812
+ if (!hasXapiTracking && !requiresForTarget) return;
813
+ const activityIri = descriptor.tracking?.xapi?.activityIri?.trim();
814
+ const targetSuffix = target === "xapi" || target === "cmi5" ? ` for ${target} export targets` : " when tracking.xapi is configured";
815
+ if (!activityIri) {
816
+ issues.push({
817
+ path: "tracking.xapi.activityIri",
818
+ message: `tracking.xapi.activityIri is required${targetSuffix}`
819
+ });
820
+ return;
821
+ }
822
+ if (!/^https:\/\/.+/i.test(activityIri)) {
823
+ issues.push({
824
+ path: "tracking.xapi.activityIri",
825
+ message: `tracking.xapi.activityIri must be an HTTPS URL${targetSuffix}`
826
+ });
827
+ }
828
+ }
626
829
  function validateDescriptorForExportTarget(descriptor, target) {
627
830
  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
- }
831
+ appendActivityIriIssues(issues, descriptor, target);
642
832
  if (LMS_SHELL_TARGETS.has(target)) {
643
833
  issues.push(...validateInjectableAssessments(descriptor).map((issue) => ({
644
834
  ...issue,
@@ -672,19 +862,53 @@ function validateDescriptorForTarget(input, target) {
672
862
  }
673
863
 
674
864
  // src/validateReactParity.ts
675
- import { readFileSync, existsSync as existsSync2, readdirSync, statSync } from "fs";
865
+ import { readFileSync, existsSync as existsSync3, readdirSync, lstatSync } from "fs";
676
866
  import { join as join2, relative as relative2 } from "path";
677
867
  var SCANNABLE_EXTENSIONS = [".tsx", ".ts", ".jsx", ".js"];
678
- function collectSourceUnderSrc(projectRoot) {
868
+ function collectSourceUnderSrc(projectRoot, issues) {
679
869
  const srcDir = join2(projectRoot, "src");
680
- if (!existsSync2(srcDir)) return [];
870
+ if (!existsSync3(srcDir)) return [];
681
871
  const results = [];
682
872
  const walk = (dir) => {
683
873
  for (const entry of readdirSync(dir)) {
684
874
  const abs = join2(dir, entry);
685
- if (statSync(abs).isDirectory()) {
875
+ let stat2;
876
+ try {
877
+ stat2 = lstatSync(abs);
878
+ } catch {
879
+ continue;
880
+ }
881
+ if (stat2.isSymbolicLink()) {
882
+ issues.push({
883
+ path: relative2(projectRoot, abs),
884
+ message: `Source tree contains symlink (rejected for parity scan): ${relative2(projectRoot, abs)}`,
885
+ severity: "error"
886
+ });
887
+ continue;
888
+ }
889
+ if (stat2.isDirectory()) {
890
+ try {
891
+ assertRealPathUnderRoot(projectRoot, abs);
892
+ } catch {
893
+ issues.push({
894
+ path: relative2(projectRoot, abs),
895
+ message: `Source directory escapes project root: ${relative2(projectRoot, abs)}`,
896
+ severity: "error"
897
+ });
898
+ continue;
899
+ }
686
900
  walk(abs);
687
901
  } else if (SCANNABLE_EXTENSIONS.some((ext) => entry.endsWith(ext))) {
902
+ try {
903
+ assertRealPathUnderRoot(projectRoot, abs);
904
+ } catch {
905
+ issues.push({
906
+ path: relative2(projectRoot, abs),
907
+ message: `Source file escapes project root: ${relative2(projectRoot, abs)}`,
908
+ severity: "error"
909
+ });
910
+ continue;
911
+ }
688
912
  results.push(relative2(projectRoot, abs));
689
913
  }
690
914
  }
@@ -692,20 +916,69 @@ function collectSourceUnderSrc(projectRoot) {
692
916
  walk(srcDir);
693
917
  return results;
694
918
  }
695
- function readAppSources(projectRoot, appSources) {
696
- return appSources.map((rel) => join2(projectRoot, rel)).filter((abs) => existsSync2(abs)).map((abs) => readFileSync(abs, "utf8")).join("\n");
919
+ function readAppSources(projectRoot, appSources, issues, customSourcesProvided) {
920
+ return appSources.map((rel) => {
921
+ if (!isSafeRelativeSpaPath(rel)) {
922
+ if (customSourcesProvided) {
923
+ issues.push({
924
+ path: rel,
925
+ message: `Unsafe appSources path skipped: ${rel}`,
926
+ severity: "warning"
927
+ });
928
+ }
929
+ return null;
930
+ }
931
+ const abs = join2(projectRoot, rel);
932
+ try {
933
+ assertRealPathUnderRoot(projectRoot, abs);
934
+ if (existsSync3(abs) && lstatSync(abs).isSymbolicLink()) {
935
+ issues.push({
936
+ path: rel,
937
+ message: `appSources path is a symlink: ${rel}`,
938
+ severity: "error"
939
+ });
940
+ return null;
941
+ }
942
+ } catch {
943
+ issues.push({
944
+ path: rel,
945
+ message: `appSources path escapes project root: ${rel}`,
946
+ severity: "error"
947
+ });
948
+ return null;
949
+ }
950
+ if (!existsSync3(abs)) return null;
951
+ return readFileSync(abs, "utf8");
952
+ }).filter((content) => content != null).join("\n");
697
953
  }
698
954
  function stripComments(source) {
699
955
  return source.replace(/\/\*[\s\S]*?\*\//g, " ").replace(/\/\/[^\n]*/g, " ");
700
956
  }
701
- function idPropPatterns(prop, id) {
702
- return [
703
- `${prop}="${id}"`,
704
- `${prop}='${id}'`,
705
- `${prop}={'${id}'}`,
706
- `${prop}={"${id}"}`,
707
- `${prop}={\`${id}\`}`
708
- ];
957
+ function maskUnrelatedStringLiterals(source) {
958
+ return source.replace(/(["'`])(?:\\.|(?!\1).)*\1/g, (match, _quote, offset, full) => {
959
+ const before = full.slice(Math.max(0, offset - 24), offset);
960
+ if (/\b(?:courseId|checkId|lessonId)\s*=\s*$/.test(before)) {
961
+ return match;
962
+ }
963
+ return '""';
964
+ });
965
+ }
966
+ function idPropPresent(source, prop, id) {
967
+ const stripped = stripComments(source);
968
+ const masked = maskUnrelatedStringLiterals(stripped);
969
+ return jsxPropRegex(prop, id).test(masked);
970
+ }
971
+ function escapeRegExp(value) {
972
+ return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
973
+ }
974
+ function jsxPropRegex(prop, id) {
975
+ const escapedId = escapeRegExp(id);
976
+ return new RegExp(
977
+ `(?<![A-Za-z0-9_$])${prop}\\s*=\\s*(?:"${escapedId}"|'${escapedId}'|\\{\\s*["'\`]${escapedId}["'\`]\\s*\\}|\\{\\s*\`${escapedId}\`\\s*\\})`
978
+ );
979
+ }
980
+ function maskStringLiterals(source) {
981
+ return source.replace(/(["'`])(?:\\.|(?!\1).)*\1/g, '""');
709
982
  }
710
983
  function extractStringConstants(source) {
711
984
  const stripped = stripComments(source);
@@ -716,7 +989,9 @@ function extractStringConstants(source) {
716
989
  }
717
990
  return map;
718
991
  }
719
- function idUsedViaConstant(stripped, prop, id, constants) {
992
+ function idUsedViaConstant(source, prop, id, constants) {
993
+ const stripped = stripComments(source);
994
+ const masked = maskStringLiterals(stripped);
720
995
  for (const [name, value] of constants) {
721
996
  if (value !== id) continue;
722
997
  const jsxPatterns = [
@@ -725,40 +1000,72 @@ function idUsedViaConstant(stripped, prop, id, constants) {
725
1000
  `${prop}={${name} }`,
726
1001
  `${prop}={ ${name}}`
727
1002
  ];
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;
1003
+ if (jsxPatterns.some((p) => masked.includes(p))) return true;
731
1004
  }
732
1005
  return false;
733
1006
  }
734
- function courseIdPresent(source, courseId) {
1007
+ function lessonIdInDataLiteral(source, lessonId) {
735
1008
  const stripped = stripComments(source);
736
- if (idPropPatterns("courseId", courseId).some((p) => stripped.includes(p))) return true;
737
- return idUsedViaConstant(stripped, "courseId", courseId, extractStringConstants(source));
1009
+ const escaped = escapeRegExp(lessonId);
1010
+ return new RegExp(`\\bid\\s*:\\s*["'\`]${escaped}["'\`]`).test(stripped);
738
1011
  }
739
- function checkIdPresent(source, checkId) {
1012
+ function lessonIdPresent(source, lessonId) {
1013
+ if (idPropPresent(source, "lessonId", lessonId)) return true;
1014
+ if (idUsedViaConstant(source, "lessonId", lessonId, extractStringConstants(source))) return true;
1015
+ return lessonIdInDataLiteral(source, lessonId);
1016
+ }
1017
+ function courseConfigCourseIdPresent(source, courseId) {
1018
+ const stripped = stripComments(source);
1019
+ const escaped = escapeRegExp(courseId);
1020
+ const literalPattern = new RegExp(
1021
+ `(?<![A-Za-z0-9_$])courseId\\s*:\\s*(?:"${escaped}"|'${escaped}')`
1022
+ );
1023
+ if (literalPattern.test(stripped)) return true;
1024
+ return idUsedViaConstant(source, "courseId", courseId, extractStringConstants(source));
1025
+ }
1026
+ function courseMetaCourseIdPresent(source, courseId) {
1027
+ const constants = extractStringConstants(source);
740
1028
  const stripped = stripComments(source);
741
- if (idPropPatterns("checkId", checkId).some((p) => stripped.includes(p))) return true;
742
- return idUsedViaConstant(stripped, "checkId", checkId, extractStringConstants(source));
1029
+ for (const [name, value] of constants) {
1030
+ if (value !== courseId) continue;
1031
+ if (!new RegExp(`\\bcourseId\\s*:\\s*${name}\\b`).test(stripped)) continue;
1032
+ if (/\blessons\s*:\s*\S/.test(stripped)) return true;
1033
+ }
1034
+ return false;
1035
+ }
1036
+ function courseIdPresent(source, courseId) {
1037
+ if (idPropPresent(source, "courseId", courseId)) return true;
1038
+ if (idUsedViaConstant(source, "courseId", courseId, extractStringConstants(source))) return true;
1039
+ if (courseMetaCourseIdPresent(source, courseId)) return true;
1040
+ return courseConfigCourseIdPresent(source, courseId);
1041
+ }
1042
+ function checkIdPresent(source, checkId) {
1043
+ if (idPropPresent(source, "checkId", checkId)) return true;
1044
+ return idUsedViaConstant(source, "checkId", checkId, extractStringConstants(source));
743
1045
  }
744
1046
  var ID_SYNC_DOC = "https://lessonkit.readthedocs.io/en/latest/guides/react-developers/quickstart.html#keep-react-ids-in-sync-with-lessonkitjson";
745
1047
  function parityHint(message) {
746
1048
  return `${message} See ${ID_SYNC_DOC}`;
747
1049
  }
748
1050
  function validateReactManifestParity(opts) {
749
- const appSources = opts.appSources ?? collectSourceUnderSrc(opts.projectRoot);
750
- const source = readAppSources(opts.projectRoot, appSources);
1051
+ const issues = [];
1052
+ const customSourcesProvided = opts.appSources !== void 0;
1053
+ const appSources = opts.appSources ?? collectSourceUnderSrc(opts.projectRoot, issues);
1054
+ const source = readAppSources(
1055
+ opts.projectRoot,
1056
+ appSources,
1057
+ issues,
1058
+ customSourcesProvided
1059
+ );
751
1060
  const hasDescriptorIds = Boolean(opts.descriptor.courseId) || (opts.descriptor.assessments?.length ?? 0) > 0;
752
1061
  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
- ];
1062
+ issues.push({
1063
+ path: appSources.length > 0 ? appSources.join(", ") : "src/",
1064
+ 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",
1065
+ severity: hasDescriptorIds ? "error" : "warning"
1066
+ });
1067
+ return issues;
760
1068
  }
761
- const issues = [];
762
1069
  const courseId = opts.descriptor.courseId;
763
1070
  if (!courseIdPresent(source, courseId)) {
764
1071
  issues.push({
@@ -769,6 +1076,19 @@ function validateReactManifestParity(opts) {
769
1076
  severity: "error"
770
1077
  });
771
1078
  }
1079
+ for (const lesson of opts.descriptor.lessons ?? []) {
1080
+ const lessonId = lesson.id;
1081
+ if (!lessonId) continue;
1082
+ if (!lessonIdPresent(source, lessonId)) {
1083
+ issues.push({
1084
+ path: `lessons.id:${lessonId}`,
1085
+ message: parityHint(
1086
+ `React app source missing lessonId="${lessonId}" declared in lessonkit.json.`
1087
+ ),
1088
+ severity: "error"
1089
+ });
1090
+ }
1091
+ }
772
1092
  for (const assessment of opts.descriptor.assessments ?? []) {
773
1093
  const checkId = assessment.checkId;
774
1094
  if (!checkId) continue;
@@ -785,58 +1105,6 @@ function validateReactManifestParity(opts) {
785
1105
  return issues;
786
1106
  }
787
1107
 
788
- // src/validateProjectPaths.ts
789
- import { isAbsolute as isAbsolute2, resolve as resolve2 } from "path";
790
- function validatePathField(value, fieldPath, projectRoot, issues) {
791
- if (!isSafeRelativeSpaPath(value)) {
792
- issues.push({
793
- path: fieldPath,
794
- message: "path must be relative without '..' segments or absolute prefixes"
795
- });
796
- return;
797
- }
798
- try {
799
- assertRealPathUnderRoot(projectRoot, resolve2(projectRoot, value));
800
- } catch {
801
- issues.push({
802
- path: fieldPath,
803
- message: "path must resolve inside the project root"
804
- });
805
- }
806
- }
807
- function validateProjectPaths(projectRoot, paths) {
808
- const issues = [];
809
- const root = resolve2(projectRoot);
810
- if (paths.spaDistDir?.trim()) {
811
- validatePathField(paths.spaDistDir.trim(), "paths.spaDistDir", root, issues);
812
- }
813
- if (paths.lxpackOutDir?.trim()) {
814
- validatePathField(paths.lxpackOutDir.trim(), "paths.lxpackOutDir", root, issues);
815
- }
816
- if (paths.outputBaseDir?.trim()) {
817
- validatePathField(paths.outputBaseDir.trim(), "paths.outputBaseDir", root, issues);
818
- }
819
- return issues;
820
- }
821
- function resolveSafePackageOutputOverride(projectRoot, override) {
822
- const root = resolve2(projectRoot);
823
- const trimmed = override.trim();
824
- if (!trimmed) {
825
- throw new Error("output override must be a non-empty path");
826
- }
827
- if (isAbsolute2(trimmed)) {
828
- const resolved2 = resolve2(trimmed);
829
- assertRealPathUnderRoot(root, resolved2);
830
- return resolved2;
831
- }
832
- if (!isSafeRelativeSpaPath(trimmed)) {
833
- throw new Error(`unsafe output path: ${override}`);
834
- }
835
- const resolved = resolve2(root, trimmed);
836
- assertRealPathUnderRoot(root, resolved);
837
- return resolved;
838
- }
839
-
840
1108
  // src/mapIds.ts
841
1109
  import { assertValidId } from "@lessonkit/core";
842
1110
  function mapLessonkitIds(descriptor) {
@@ -968,7 +1236,7 @@ async function resolveSpaDirs(options) {
968
1236
 
969
1237
  // src/spaDistValidation.ts
970
1238
  import { lstat, readdir } from "fs/promises";
971
- import { realpathSync as realpathSync2 } from "fs";
1239
+ import { realpathSync as realpathSync3 } from "fs";
972
1240
  import { join as join4 } from "path";
973
1241
  async function assertSpaDistContentsSafe(spaDirs, projectRoot) {
974
1242
  for (const [label, dir] of Object.entries(spaDirs)) {
@@ -979,7 +1247,7 @@ async function assertSpaDistContentsSafe(spaDirs, projectRoot) {
979
1247
  }
980
1248
  let rootReal;
981
1249
  try {
982
- rootReal = realpathSync2(dirResolved);
1250
+ rootReal = realpathSync3(dirResolved);
983
1251
  } catch {
984
1252
  throw new Error(`spa dist for "${label}" is not readable: ${dir}`);
985
1253
  }
@@ -1008,9 +1276,12 @@ async function walkDistDir(rootReal, current, label) {
1008
1276
  }
1009
1277
  let entryReal;
1010
1278
  try {
1011
- entryReal = realpathSync2(entryPath);
1012
- } catch {
1013
- entryReal = entryPath;
1279
+ entryReal = realpathSync3(entryPath);
1280
+ } catch (err) {
1281
+ throw new Error(
1282
+ `spa dist for "${label}" could not resolve path: ${entryPath}`,
1283
+ { cause: err }
1284
+ );
1014
1285
  }
1015
1286
  assertResolvedPathUnderRoot(rootReal, entryReal);
1016
1287
  if (stat2.isDirectory()) {
@@ -1030,12 +1301,12 @@ async function writeLxpackProject(options) {
1030
1301
  const descriptor = validation.descriptor;
1031
1302
  const injectableIssues = validateInjectableAssessments(descriptor);
1032
1303
  if (injectableIssues.length > 0) {
1033
- throw new Error(injectableIssues.map((i) => `${i.path}: ${i.message}`).join("; "));
1304
+ throw new Error(
1305
+ injectableIssues.map((i) => `${i.path ?? "assessments"}: ${i.message}`).join("; ")
1306
+ );
1034
1307
  }
1035
1308
  const outDir = resolve4(options.outDir);
1036
- if (options.projectRoot) {
1037
- assertRealPathUnderRoot(resolve4(options.projectRoot), outDir);
1038
- }
1309
+ assertRealPathUnderRoot(resolve4(options.projectRoot), outDir);
1039
1310
  const spaDirs = await resolveSpaDirs({ ...options, descriptor });
1040
1311
  await assertSpaDistContentsSafe(spaDirs, options.projectRoot);
1041
1312
  const interchange = descriptorToInterchange(descriptor);
@@ -1098,6 +1369,19 @@ function validatePackageInputs(options) {
1098
1369
  ]
1099
1370
  };
1100
1371
  }
1372
+ if (isReservedOutputPath(outDir) || isReservedResolvedOutputPath(projectRoot, outDir)) {
1373
+ return {
1374
+ ok: false,
1375
+ courseDir: outDir,
1376
+ target,
1377
+ issues: [
1378
+ {
1379
+ path: "outDir",
1380
+ message: "outDir must not target reserved directories (.git, node_modules, .github)"
1381
+ }
1382
+ ]
1383
+ };
1384
+ }
1101
1385
  if (outputBaseDir && !isSafeRelativeSpaPath(outputBaseDir)) {
1102
1386
  return {
1103
1387
  ok: false,
@@ -1155,6 +1439,19 @@ function validatePackageInputs(options) {
1155
1439
  ]
1156
1440
  };
1157
1441
  }
1442
+ if (isReservedOutputPath(outputBaseDir) || isReservedResolvedOutputPath(projectRoot, resolvedOutputBase)) {
1443
+ return {
1444
+ ok: false,
1445
+ courseDir: outDir,
1446
+ target,
1447
+ issues: [
1448
+ {
1449
+ path: "outputBaseDir",
1450
+ message: "outputBaseDir must not target reserved directories (.git, node_modules, .github)"
1451
+ }
1452
+ ]
1453
+ };
1454
+ }
1158
1455
  }
1159
1456
  if (output) {
1160
1457
  const resolvedOutput = isAbsolute3(output) ? resolve5(output) : resolve5(projectRoot, output);
@@ -1176,22 +1473,51 @@ function validatePackageInputs(options) {
1176
1473
  ]
1177
1474
  };
1178
1475
  }
1179
- }
1180
- return { ok: true, outDir, projectRoot };
1181
- }
1182
- function validateArtifactInStaging(stagingRoot, artifactPath, field) {
1183
- if (!artifactPath) return null;
1184
- const resolved = resolveComparablePath(artifactPath);
1185
- if (!isResolvedPathUnderRoot(stagingRoot, resolved)) {
1186
- return {
1187
- path: field,
1188
- message: `${field} is outside the staging directory: ${artifactPath}`
1189
- };
1190
- }
1191
- return null;
1192
- }
1193
- function remapArtifactPaths(stagingRoot, outDir, artifactPath) {
1194
- if (!artifactPath) return void 0;
1476
+ const outputRel = isAbsolute3(output) ? output : output;
1477
+ if (isReservedOutputPath(outputRel) || isReservedResolvedOutputPath(projectRoot, resolvedOutput)) {
1478
+ return {
1479
+ ok: false,
1480
+ courseDir: outDir,
1481
+ target,
1482
+ issues: [
1483
+ {
1484
+ path: "output",
1485
+ message: "output must not target reserved directories (.git, node_modules, .github)"
1486
+ }
1487
+ ]
1488
+ };
1489
+ }
1490
+ try {
1491
+ relativePathUnderRoot(outDir, resolvedOutput);
1492
+ } catch {
1493
+ return {
1494
+ ok: false,
1495
+ courseDir: outDir,
1496
+ target,
1497
+ issues: [
1498
+ {
1499
+ path: "output",
1500
+ message: "output must resolve inside outDir"
1501
+ }
1502
+ ]
1503
+ };
1504
+ }
1505
+ }
1506
+ return { ok: true, outDir, projectRoot };
1507
+ }
1508
+ function validateArtifactInStaging(stagingRoot, artifactPath, field) {
1509
+ if (!artifactPath) return null;
1510
+ const resolved = resolveComparablePath(artifactPath);
1511
+ if (!isResolvedPathUnderRoot(stagingRoot, resolved)) {
1512
+ return {
1513
+ path: field,
1514
+ message: `${field} is outside the staging directory: ${artifactPath}`
1515
+ };
1516
+ }
1517
+ return null;
1518
+ }
1519
+ function remapArtifactPaths(stagingRoot, outDir, artifactPath) {
1520
+ if (!artifactPath) return void 0;
1195
1521
  const resolved = resolveComparablePath(artifactPath);
1196
1522
  if (!isResolvedPathUnderRoot(stagingRoot, resolved)) {
1197
1523
  throw new Error(`${artifactPath} is outside the staging directory`);
@@ -1234,33 +1560,53 @@ function promoteLockPath(outDir) {
1234
1560
  const hash = createHash("sha256").update(resolve6(outDir)).digest("hex").slice(0, 16);
1235
1561
  return join7(parent, `.lk-promote-lock-${hash}`);
1236
1562
  }
1237
- var STALE_LOCK_TTL_MS = 5 * 60 * 1e3;
1563
+ var STALE_ARTIFACT_TTL_MS = 5 * 60 * 1e3;
1564
+ var MAX_LOCK_AGE_MS = 30 * 60 * 1e3;
1565
+ var LOCK_TOKEN_RE = /^(\d+)\n([0-9a-f-]{36})(?:\n(\d+))?\n?$/i;
1238
1566
  async function isStalePromoteLock(lockPath) {
1239
1567
  try {
1568
+ const stat2 = await fsp.stat(lockPath);
1240
1569
  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;
1570
+ const match = content.match(LOCK_TOKEN_RE);
1571
+ let lockAgeMs = Date.now() - stat2.mtimeMs;
1572
+ if (match?.[3]) {
1573
+ const startedAt = Number.parseInt(match[3], 10);
1574
+ if (Number.isFinite(startedAt) && startedAt > 0) {
1575
+ lockAgeMs = Date.now() - startedAt;
1248
1576
  }
1249
1577
  }
1250
- const stat2 = await fsp.stat(lockPath);
1251
- return Date.now() - stat2.mtimeMs > STALE_LOCK_TTL_MS;
1578
+ if (lockAgeMs > MAX_LOCK_AGE_MS) {
1579
+ return true;
1580
+ }
1581
+ if (match) {
1582
+ const pid = Number.parseInt(match[1], 10);
1583
+ if (Number.isFinite(pid) && pid > 0) {
1584
+ try {
1585
+ process.kill(pid, 0);
1586
+ return false;
1587
+ } catch {
1588
+ return true;
1589
+ }
1590
+ }
1591
+ }
1592
+ return lockAgeMs > STALE_ARTIFACT_TTL_MS;
1252
1593
  } catch {
1253
1594
  return true;
1254
1595
  }
1255
1596
  }
1597
+ var PROMOTE_LOCK_TIMEOUT_MS = 15e3;
1256
1598
  async function withPromoteLock(outDir, fn) {
1257
1599
  const lockPath = promoteLockPath(outDir);
1258
1600
  await fsp.mkdir(dirname(outDir), { recursive: true });
1259
1601
  let lockHandle;
1260
- for (let attempt = 0; attempt < 200; attempt++) {
1602
+ const maxAttempts = 400;
1603
+ const started = Date.now();
1604
+ for (let attempt = 0; attempt < maxAttempts; attempt++) {
1261
1605
  try {
1262
1606
  lockHandle = await fsp.open(lockPath, "wx");
1263
1607
  await lockHandle.writeFile(`${process.pid}
1608
+ ${randomUUID()}
1609
+ ${Date.now()}
1264
1610
  `, "utf8");
1265
1611
  break;
1266
1612
  } catch (err) {
@@ -1273,7 +1619,9 @@ async function withPromoteLock(outDir, fn) {
1273
1619
  );
1274
1620
  continue;
1275
1621
  }
1276
- await new Promise((resolveWait) => setTimeout(resolveWait, 25));
1622
+ if (Date.now() - started >= PROMOTE_LOCK_TIMEOUT_MS) break;
1623
+ const delayMs = Math.min(25 * 2 ** Math.floor(attempt / 20), 250);
1624
+ await new Promise((resolveWait) => setTimeout(resolveWait, delayMs));
1277
1625
  }
1278
1626
  }
1279
1627
  if (!lockHandle) {
@@ -1292,22 +1640,77 @@ async function withPromoteLock(outDir, fn) {
1292
1640
  );
1293
1641
  }
1294
1642
  }
1295
- async function assertNoLegacyPromoteArtifacts(outDir) {
1643
+ async function removeStaleLegacyPromoteArtifacts(outDir) {
1296
1644
  const legacyTmp = `${outDir}.tmp-promote`;
1297
1645
  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) {
1646
+ const blocked = [];
1647
+ for (const legacyPath of [legacyTmp, legacyBak]) {
1648
+ if (!await pathExists(legacyPath)) continue;
1649
+ try {
1650
+ const stat2 = await fsp.stat(legacyPath);
1651
+ if (Date.now() - stat2.mtimeMs > STALE_ARTIFACT_TTL_MS) {
1652
+ await fsp.rm(legacyPath, { recursive: true, force: true }).catch(
1653
+ /* v8 ignore next */
1654
+ () => void 0
1655
+ );
1656
+ continue;
1657
+ }
1658
+ } catch {
1659
+ }
1660
+ blocked.push(legacyPath);
1661
+ }
1662
+ if (blocked.length) {
1663
+ const rmHint = blocked.map((p) => `rm -rf ${JSON.stringify(p)}`).join("; ");
1302
1664
  throw new Error(
1303
- `[lessonkit/lxpack] cannot promote: remove stale packaging artifacts from a previous failed run: ${stale.join(", ")}`
1665
+ `[lessonkit/lxpack] cannot promote: remove stale packaging artifacts from a previous failed run: ${blocked.join(", ")}. Try: ${rmHint}`
1304
1666
  );
1305
1667
  }
1306
1668
  }
1307
- async function promoteStagingToOutDir(stagingDir, outDir) {
1669
+ async function listRelativePaths(root, dir = root) {
1670
+ const entries = await fsp.readdir(dir, { withFileTypes: true });
1671
+ const paths = [];
1672
+ for (const entry of entries) {
1673
+ const full = join7(dir, entry.name);
1674
+ if (entry.isDirectory()) {
1675
+ paths.push(...await listRelativePaths(root, full));
1676
+ } else if (entry.isFile()) {
1677
+ paths.push(full.slice(root.length + 1));
1678
+ } else {
1679
+ }
1680
+ }
1681
+ return paths;
1682
+ }
1683
+ async function mergePreservedOutArtifacts(priorArtifactsDir, destArtifactsDir, newArtifactPaths) {
1684
+ if (!await pathExists(priorArtifactsDir)) return;
1685
+ for (const rel of await listRelativePaths(priorArtifactsDir)) {
1686
+ if (newArtifactPaths.has(rel)) continue;
1687
+ const src = join7(priorArtifactsDir, rel);
1688
+ const dest = join7(destArtifactsDir, rel);
1689
+ await fsp.mkdir(dirname(dest), { recursive: true });
1690
+ await fsp.cp(src, dest, { force: true });
1691
+ }
1692
+ }
1693
+ async function promoteStagingToOutDir(stagingDir, outDir, options) {
1694
+ const outputBaseDir = options?.outputBaseDir ?? ".lxpack/out";
1695
+ if (options?.projectRoot) {
1696
+ assertRealPathUnderRoot(resolve6(options.projectRoot), resolve6(outDir));
1697
+ }
1308
1698
  return withPromoteLock(outDir, async () => {
1309
- await assertNoLegacyPromoteArtifacts(outDir);
1699
+ await removeStaleLegacyPromoteArtifacts(outDir);
1700
+ const stagingArtifactsDir = join7(stagingDir, outputBaseDir);
1701
+ const newArtifactPaths = /* @__PURE__ */ new Set();
1702
+ if (await pathExists(stagingArtifactsDir)) {
1703
+ for (const rel of await listRelativePaths(stagingArtifactsDir)) {
1704
+ newArtifactPaths.add(rel);
1705
+ }
1706
+ }
1310
1707
  const parent = dirname(outDir);
1708
+ let priorArtifactsBackup;
1709
+ const existingArtifactsDir = join7(outDir, outputBaseDir);
1710
+ if (await pathExists(outDir) && await pathExists(existingArtifactsDir)) {
1711
+ priorArtifactsBackup = await fsp.mkdtemp(join7(parent, ".lk-prior-out-"));
1712
+ await fsp.cp(existingArtifactsDir, join7(priorArtifactsBackup, outputBaseDir), { recursive: true });
1713
+ }
1311
1714
  const tmpPromote = await fsp.mkdtemp(join7(parent, ".lk-promote-"));
1312
1715
  await renameOrCopy(stagingDir, tmpPromote);
1313
1716
  const hadOutDir = await pathExists(outDir);
@@ -1364,6 +1767,20 @@ async function promoteStagingToOutDir(stagingDir, outDir) {
1364
1767
  }
1365
1768
  throw promoteError;
1366
1769
  }
1770
+ if (priorArtifactsBackup) {
1771
+ try {
1772
+ await mergePreservedOutArtifacts(
1773
+ join7(priorArtifactsBackup, outputBaseDir),
1774
+ join7(outDir, outputBaseDir),
1775
+ newArtifactPaths
1776
+ );
1777
+ } finally {
1778
+ await fsp.rm(priorArtifactsBackup, { recursive: true, force: true }).catch(
1779
+ /* v8 ignore next */
1780
+ () => void 0
1781
+ );
1782
+ }
1783
+ }
1367
1784
  if (backup) {
1368
1785
  await fsp.rm(backup, { recursive: true, force: true }).catch(
1369
1786
  /* v8 ignore next */
@@ -1381,6 +1798,7 @@ import { packageLessonkit } from "@lxpack/api";
1381
1798
  async function buildStagingPackage(options) {
1382
1799
  const { target, output, dir, outputBaseDir, descriptor, ...writeOpts } = options;
1383
1800
  const stagingDir = await fsp2.mkdtemp(join8(tmpdir(), "lessonkit-lxpack-"));
1801
+ let succeeded = false;
1384
1802
  try {
1385
1803
  let spaDirs;
1386
1804
  try {
@@ -1436,6 +1854,7 @@ async function buildStagingPackage(options) {
1436
1854
  }))
1437
1855
  };
1438
1856
  }
1857
+ succeeded = true;
1439
1858
  return {
1440
1859
  ok: true,
1441
1860
  stagingDir,
@@ -1449,6 +1868,13 @@ async function buildStagingPackage(options) {
1449
1868
  () => void 0
1450
1869
  );
1451
1870
  throw err;
1871
+ } finally {
1872
+ if (!succeeded) {
1873
+ await fsp2.rm(stagingDir, { recursive: true, force: true }).catch(
1874
+ /* v8 ignore next */
1875
+ () => void 0
1876
+ );
1877
+ }
1452
1878
  }
1453
1879
  }
1454
1880
  async function ensureOutDirParent(outDir) {
@@ -1463,6 +1889,12 @@ function isPackagingErrorIssue(issue) {
1463
1889
  function findPackagingErrorIssues(issues) {
1464
1890
  return (issues ?? []).filter(isPackagingErrorIssue);
1465
1891
  }
1892
+ function isPackagingWarningIssue(issue) {
1893
+ return issue.severity?.toLowerCase() === "warning";
1894
+ }
1895
+ function findPackagingWarningIssues(issues) {
1896
+ return (issues ?? []).filter(isPackagingWarningIssue);
1897
+ }
1466
1898
 
1467
1899
  // src/packageCourse.ts
1468
1900
  async function validateLessonkitProject(options) {
@@ -1513,33 +1945,46 @@ async function packageLessonkitCourse(options) {
1513
1945
  };
1514
1946
  }
1515
1947
  const descriptor = descriptorValidation.descriptor;
1516
- if (writeOpts.projectRoot) {
1517
- const parityIssues = validateReactManifestParity({
1518
- projectRoot: writeOpts.projectRoot,
1519
- descriptor
1948
+ const parityIssues = validateReactManifestParity({
1949
+ projectRoot: writeOpts.projectRoot,
1950
+ descriptor
1951
+ });
1952
+ const parityFailures = writeOpts.strictParity ? parityIssues : parityIssues.filter((i) => i.severity === "error");
1953
+ if (parityFailures.length > 0) {
1954
+ return {
1955
+ ok: false,
1956
+ courseDir: outDir,
1957
+ target,
1958
+ issues: parityFailures.map((i) => ({
1959
+ path: i.path,
1960
+ message: i.message,
1961
+ severity: i.severity
1962
+ }))
1963
+ };
1964
+ }
1965
+ let staged;
1966
+ try {
1967
+ staged = await buildStagingPackage({
1968
+ ...writeOpts,
1969
+ descriptor,
1970
+ target,
1971
+ output,
1972
+ dir,
1973
+ outputBaseDir
1520
1974
  });
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
- }
1975
+ } catch (err) {
1976
+ return {
1977
+ ok: false,
1978
+ courseDir: outDir,
1979
+ target,
1980
+ issues: [
1981
+ {
1982
+ path: "staging",
1983
+ message: err instanceof Error ? err.message : String(err)
1984
+ }
1985
+ ]
1986
+ };
1534
1987
  }
1535
- const staged = await buildStagingPackage({
1536
- ...writeOpts,
1537
- descriptor,
1538
- target,
1539
- output,
1540
- dir,
1541
- outputBaseDir
1542
- });
1543
1988
  if (!staged.ok) {
1544
1989
  await fsp3.rm(staged.stagingDir, { recursive: true, force: true }).catch(
1545
1990
  /* v8 ignore next */
@@ -1589,11 +2034,30 @@ async function packageLessonkitCourse(options) {
1589
2034
  ok: false,
1590
2035
  courseDir: outDir,
1591
2036
  target,
1592
- validation: { ok: true, manifest: build.manifest, issues: build.issues },
2037
+ validation: { ok: false, manifest: build.manifest, issues: build.issues },
1593
2038
  build,
1594
2039
  issues: artifactIssues
1595
2040
  };
1596
2041
  }
2042
+ const buildWarningIssues = findPackagingWarningIssues(build.issues);
2043
+ if (options.strictBuild && buildWarningIssues.length > 0) {
2044
+ await fsp3.rm(stagingDir, { recursive: true, force: true }).catch(
2045
+ /* v8 ignore next */
2046
+ () => void 0
2047
+ );
2048
+ return {
2049
+ ok: false,
2050
+ courseDir: outDir,
2051
+ target,
2052
+ validation: { ok: false, manifest: build.manifest, issues: build.issues },
2053
+ build,
2054
+ issues: buildWarningIssues.map((i) => ({
2055
+ path: i.path ?? "build",
2056
+ message: i.message ?? "build warning",
2057
+ severity: i.severity
2058
+ }))
2059
+ };
2060
+ }
1597
2061
  const remappedOutputPath = remapArtifactPaths(stagingRoot, outDir, staged.outputPath);
1598
2062
  const remappedOutputDir = remapArtifactPaths(stagingRoot, outDir, staged.outputDir);
1599
2063
  const validation = {
@@ -1603,7 +2067,10 @@ async function packageLessonkitCourse(options) {
1603
2067
  };
1604
2068
  try {
1605
2069
  await ensureOutDirParent(outDir);
1606
- await promoteStagingToOutDir(stagingDir, outDir);
2070
+ await promoteStagingToOutDir(stagingDir, outDir, {
2071
+ outputBaseDir: outputBaseDir ?? ".lxpack/out",
2072
+ projectRoot: writeOpts.projectRoot
2073
+ });
1607
2074
  } catch (err) {
1608
2075
  await fsp3.rm(stagingDir, { recursive: true, force: true }).catch(
1609
2076
  /* v8 ignore next */
@@ -1726,6 +2193,20 @@ function parseLessonkitManifest(raw, label = "lessonkit.json", projectRoot) {
1726
2193
  message: `"course.spaDistDir" (${courseSpaDistDir}) differs from "paths.spaDistDir" (${paths.spaDistDir}). Use paths.spaDistDir for CLI build and package.`
1727
2194
  });
1728
2195
  }
2196
+ for (const key of ["spaDistDir", "lxpackOutDir", "outputBaseDir"]) {
2197
+ const value = paths[key];
2198
+ if (!isSafeRelativeSpaPath(value)) {
2199
+ issues.push({
2200
+ path: `paths.${key}`,
2201
+ message: "path must be relative without '..' segments or absolute prefixes"
2202
+ });
2203
+ } else if (isReservedOutputPath(value)) {
2204
+ issues.push({
2205
+ path: `paths.${key}`,
2206
+ message: "path must not target reserved directories (.git, node_modules, .github)"
2207
+ });
2208
+ }
2209
+ }
1729
2210
  if (projectRoot) {
1730
2211
  const pathIssues = validateProjectPaths(projectRoot, paths);
1731
2212
  for (const pi of pathIssues) {
@@ -1761,16 +2242,821 @@ import {
1761
2242
  import {
1762
2243
  lessonkitInterchangeSchema,
1763
2244
  materializeLessonkitProject as materializeLessonkitProject2,
1764
- parseLessonkitInterchange
2245
+ parseLessonkitInterchange as parseLessonkitInterchange3
1765
2246
  } from "@lxpack/validators";
2247
+
2248
+ // src/lkcourse/zip.ts
2249
+ import { readFileSync as readFileSync2, statSync } from "fs";
2250
+ import { dirname as dirname3, join as join9, normalize } from "path";
2251
+ import { strFromU8, strToU8, unzipSync, zipSync } from "fflate";
2252
+ var MAX_LKCOURSE_UNCOMPRESSED_BYTES = 256 * 1024 * 1024;
2253
+ function canonicalZipEntryPath(entryPath) {
2254
+ const slashNormalized = entryPath.replace(/\\/g, "/");
2255
+ const canonical = normalize(slashNormalized).replace(/\\/g, "/");
2256
+ if (canonical !== slashNormalized) return null;
2257
+ return canonical;
2258
+ }
2259
+ function isSafeZipEntryPath(entryPath) {
2260
+ const canonical = canonicalZipEntryPath(entryPath);
2261
+ if (!canonical?.length || canonical.startsWith("/") || canonical.includes("\0")) {
2262
+ return false;
2263
+ }
2264
+ const segments = canonical.split("/").filter((s) => s.length > 0);
2265
+ if (segments.some((s) => s === "..")) return false;
2266
+ return segments.length > 0;
2267
+ }
2268
+ function createZip(entries) {
2269
+ const zipped = {};
2270
+ for (const [path, data] of entries) {
2271
+ if (!isSafeZipEntryPath(path)) {
2272
+ throw new Error(`unsafe zip entry path: ${path}`);
2273
+ }
2274
+ zipped[path.replace(/\\/g, "/")] = data instanceof Uint8Array ? data : new Uint8Array(data);
2275
+ }
2276
+ return zipSync(zipped, { level: 6 });
2277
+ }
2278
+ function readZip(archivePath) {
2279
+ const issues = [];
2280
+ let raw;
2281
+ try {
2282
+ raw = readFileSync2(archivePath);
2283
+ } catch {
2284
+ return { ok: false, issues: [{ path: archivePath, message: "failed to read archive" }] };
2285
+ }
2286
+ if (!raw.length) {
2287
+ return { ok: false, issues: [{ path: archivePath, message: "archive is empty" }] };
2288
+ }
2289
+ let unzipped;
2290
+ try {
2291
+ unzipped = unzipSync(raw);
2292
+ } catch {
2293
+ return { ok: false, issues: [{ path: archivePath, message: "invalid zip archive" }] };
2294
+ }
2295
+ const entries = /* @__PURE__ */ new Map();
2296
+ let totalUncompressed = 0;
2297
+ for (const [path, data] of Object.entries(unzipped)) {
2298
+ const canonical = canonicalZipEntryPath(path);
2299
+ if (!canonical || !isSafeZipEntryPath(canonical)) {
2300
+ issues.push({ path, message: "unsafe zip entry path" });
2301
+ continue;
2302
+ }
2303
+ if (entries.has(canonical)) {
2304
+ issues.push({ path: canonical, message: "duplicate zip entry path" });
2305
+ continue;
2306
+ }
2307
+ totalUncompressed += data.byteLength;
2308
+ if (totalUncompressed > MAX_LKCOURSE_UNCOMPRESSED_BYTES) {
2309
+ return {
2310
+ ok: false,
2311
+ issues: [
2312
+ {
2313
+ path: archivePath,
2314
+ message: `archive exceeds max uncompressed size (${MAX_LKCOURSE_UNCOMPRESSED_BYTES} bytes)`
2315
+ }
2316
+ ]
2317
+ };
2318
+ }
2319
+ entries.set(canonical, data);
2320
+ }
2321
+ if (issues.length) return { ok: false, issues };
2322
+ return { ok: true, entries };
2323
+ }
2324
+ async function collectDistEntries(distDir, spaDistRelative) {
2325
+ const { lstat: lstat2, readdir: readdir4, readFile: readFile2 } = await import("fs/promises");
2326
+ const entries = /* @__PURE__ */ new Map();
2327
+ const walk = async (absDir, relPrefix) => {
2328
+ const dirEntries = await readdir4(absDir, { withFileTypes: true });
2329
+ for (const entry of dirEntries) {
2330
+ const abs = join9(absDir, entry.name);
2331
+ const rel = relPrefix ? `${relPrefix}/${entry.name}` : entry.name;
2332
+ const zipPath = `${spaDistRelative}/${rel}`.replace(/\\/g, "/");
2333
+ if (!isSafeRelativeSpaPath(zipPath)) {
2334
+ throw new Error(`unsafe dist path: ${zipPath}`);
2335
+ }
2336
+ const stat2 = await lstat2(abs);
2337
+ if (stat2.isSymbolicLink()) {
2338
+ throw new Error(`dist contains symlink: ${abs}`);
2339
+ }
2340
+ if (stat2.isDirectory()) {
2341
+ await walk(abs, rel);
2342
+ } else if (stat2.isFile()) {
2343
+ entries.set(zipPath.replace(/\\/g, "/"), await readFile2(abs));
2344
+ }
2345
+ }
2346
+ };
2347
+ await walk(distDir, "");
2348
+ return entries;
2349
+ }
2350
+ function entryToUtf8(data) {
2351
+ return strFromU8(data);
2352
+ }
2353
+ function utf8ToEntry(text) {
2354
+ return strToU8(text);
2355
+ }
2356
+
2357
+ // src/lkcourse/parseEnvelope.ts
2358
+ function parseLkcourseEnvelope(raw, label = "manifest.json") {
2359
+ const issues = [];
2360
+ if (!raw || typeof raw !== "object") {
2361
+ return { ok: false, issues: [{ path: label, message: "must be a JSON object" }] };
2362
+ }
2363
+ const obj = raw;
2364
+ if (obj.format !== "lkcourse") {
2365
+ issues.push({
2366
+ path: "format",
2367
+ message: `must be "lkcourse" (got ${String(obj.format)})`
2368
+ });
2369
+ }
2370
+ let schemaVersion = obj.schemaVersion;
2371
+ if (schemaVersion === "1") schemaVersion = 1;
2372
+ if (schemaVersion !== 1) {
2373
+ issues.push({
2374
+ path: "schemaVersion",
2375
+ message: `must be 1 (got ${String(obj.schemaVersion)})`
2376
+ });
2377
+ }
2378
+ const lessonkitVersion = typeof obj.lessonkitVersion === "string" ? obj.lessonkitVersion.trim() : "";
2379
+ if (!lessonkitVersion) {
2380
+ issues.push({ path: "lessonkitVersion", message: "must be a non-empty string" });
2381
+ }
2382
+ const exportedAt = typeof obj.exportedAt === "string" ? obj.exportedAt.trim() : "";
2383
+ if (!exportedAt) {
2384
+ issues.push({ path: "exportedAt", message: "must be a non-empty string" });
2385
+ }
2386
+ const entriesRaw = obj.entries;
2387
+ const entries = [];
2388
+ if (!Array.isArray(entriesRaw) || entriesRaw.length === 0) {
2389
+ issues.push({ path: "entries", message: "must be a non-empty array of relative paths" });
2390
+ } else {
2391
+ for (let i = 0; i < entriesRaw.length; i++) {
2392
+ const entry = entriesRaw[i];
2393
+ if (typeof entry !== "string" || !entry.trim()) {
2394
+ issues.push({ path: `entries[${i}]`, message: "must be a non-empty string" });
2395
+ } else {
2396
+ const trimmed = entry.trim();
2397
+ if (!isSafeZipEntryPath(trimmed)) {
2398
+ issues.push({ path: `entries[${i}]`, message: "must be a safe relative path" });
2399
+ } else {
2400
+ entries.push(trimmed);
2401
+ }
2402
+ }
2403
+ }
2404
+ }
2405
+ if (issues.length) return { ok: false, issues };
2406
+ const manifestParsed = parseLessonkitManifest(obj.sourceManifest, `${label}.sourceManifest`);
2407
+ if (!manifestParsed.ok) {
2408
+ return {
2409
+ ok: false,
2410
+ issues: manifestParsed.issues.map((issue) => ({
2411
+ path: `sourceManifest.${issue.path}`,
2412
+ message: issue.message
2413
+ }))
2414
+ };
2415
+ }
2416
+ return {
2417
+ ok: true,
2418
+ envelope: {
2419
+ format: "lkcourse",
2420
+ schemaVersion: 1,
2421
+ lessonkitVersion,
2422
+ exportedAt,
2423
+ sourceManifest: manifestParsed.manifest,
2424
+ entries
2425
+ }
2426
+ };
2427
+ }
2428
+
2429
+ // src/lkcourse/blockTree.ts
2430
+ import { existsSync as existsSync4, lstatSync as lstatSync2, readdirSync as readdirSync2, readFileSync as readFileSync3 } from "fs";
2431
+ import { createRequire } from "module";
2432
+ import { join as join10, relative as relative3 } from "path";
2433
+ import { validateId as validateId4 } from "@lessonkit/core";
2434
+ var SCANNABLE_EXTENSIONS2 = [".tsx", ".ts", ".jsx", ".js"];
2435
+ var ID_PROPS = ["courseId", "lessonId", "checkId", "blockId", "nodeId"];
2436
+ function stripComments2(source) {
2437
+ return source.replace(/\/\*[\s\S]*?\*\//g, " ").replace(/\/\/[^\n]*/g, " ");
2438
+ }
2439
+ function collectSourceUnderSrc2(projectRoot) {
2440
+ const srcDir = join10(projectRoot, "src");
2441
+ if (!existsSync4(srcDir)) return [];
2442
+ const results = [];
2443
+ const walk = (dir) => {
2444
+ for (const entry of readdirSync2(dir)) {
2445
+ const abs = join10(dir, entry);
2446
+ try {
2447
+ assertRealPathUnderRoot(projectRoot, abs);
2448
+ } catch {
2449
+ continue;
2450
+ }
2451
+ const stat2 = lstatSync2(abs);
2452
+ if (stat2.isSymbolicLink()) continue;
2453
+ if (stat2.isDirectory()) {
2454
+ walk(abs);
2455
+ } else if (SCANNABLE_EXTENSIONS2.some((ext) => entry.endsWith(ext))) {
2456
+ results.push(relative3(projectRoot, abs));
2457
+ }
2458
+ }
2459
+ };
2460
+ walk(srcDir);
2461
+ return results;
2462
+ }
2463
+ function loadCatalogBlockTypes(blockTypes) {
2464
+ if (blockTypes?.length) return blockTypes;
2465
+ try {
2466
+ const require2 = createRequire(import.meta.url);
2467
+ const catalogPath = require2.resolve("@lessonkit/react/block-catalog.v3.json");
2468
+ const catalog = JSON.parse(readFileSync3(catalogPath, "utf8"));
2469
+ return (catalog.entries ?? []).map((e) => e.type).filter((t) => typeof t === "string" && t.length > 0);
2470
+ } catch {
2471
+ return [
2472
+ "Course",
2473
+ "Lesson",
2474
+ "Scenario",
2475
+ "Quiz",
2476
+ "KnowledgeCheck",
2477
+ "ProgressTracker",
2478
+ "Reflection",
2479
+ "TrueFalse",
2480
+ "MarkTheWords",
2481
+ "FillInTheBlanks",
2482
+ "DragTheWords",
2483
+ "DragAndDrop",
2484
+ "AssessmentSequence",
2485
+ "Text",
2486
+ "Heading",
2487
+ "Image",
2488
+ "Video",
2489
+ "Page",
2490
+ "InteractiveBook",
2491
+ "Slide",
2492
+ "SlideDeck",
2493
+ "TimedCue",
2494
+ "InteractiveVideo",
2495
+ "Summary",
2496
+ "BranchingScenario",
2497
+ "BranchNode",
2498
+ "BranchChoice",
2499
+ "Embed",
2500
+ "Chart"
2501
+ ];
2502
+ }
2503
+ }
2504
+ function extractIdProp(tagSource, prop) {
2505
+ const re = new RegExp(
2506
+ `\\b${prop}\\s*=\\s*(?:"([^"]*)"|'([^']*)'|\\{\\s*["'\`]([^"'\`]+)["'\`]\\s*\\})`
2507
+ );
2508
+ const match = tagSource.match(re);
2509
+ if (!match) return void 0;
2510
+ return match[1] ?? match[2] ?? match[3];
2511
+ }
2512
+ function parseJsxBlocks(source, blockTypes) {
2513
+ const stripped = stripComments2(source);
2514
+ const tagRe = /<([A-Z][A-Za-z0-9]*)\b([^>]*?)(\/?)>/g;
2515
+ const stack = [];
2516
+ const roots = [];
2517
+ for (const match of stripped.matchAll(tagRe)) {
2518
+ const rawTag = match[1];
2519
+ const attrs = match[2] ?? "";
2520
+ const selfClosing = match[3] === "/";
2521
+ if (rawTag === "Fragment" || rawTag.endsWith("Provider")) continue;
2522
+ const known = blockTypes.has(rawTag);
2523
+ const node = known ? { type: rawTag } : { type: "Unknown", rawTag };
2524
+ for (const prop of ID_PROPS) {
2525
+ const value = extractIdProp(attrs, prop);
2526
+ if (value) node[prop] = value;
2527
+ }
2528
+ if (selfClosing) {
2529
+ if (stack.length) {
2530
+ const parent = stack[stack.length - 1];
2531
+ parent.children = parent.children ?? [];
2532
+ parent.children.push(node);
2533
+ } else {
2534
+ roots.push(node);
2535
+ }
2536
+ continue;
2537
+ }
2538
+ const closeRe = new RegExp(`</${rawTag}>`);
2539
+ const closeMatch = closeRe.exec(stripped.slice((match.index ?? 0) + match[0].length));
2540
+ if (!closeMatch) {
2541
+ if (stack.length) {
2542
+ const parent = stack[stack.length - 1];
2543
+ parent.children = parent.children ?? [];
2544
+ parent.children.push(node);
2545
+ } else {
2546
+ roots.push(node);
2547
+ }
2548
+ continue;
2549
+ }
2550
+ stack.push(node);
2551
+ const nextClose = stripped.indexOf(`</${rawTag}>`, (match.index ?? 0) + match[0].length);
2552
+ const inner = stripped.slice((match.index ?? 0) + match[0].length, nextClose);
2553
+ if (!inner.includes("<")) {
2554
+ stack.pop();
2555
+ if (stack.length) {
2556
+ const parent = stack[stack.length - 1];
2557
+ parent.children = parent.children ?? [];
2558
+ parent.children.push(node);
2559
+ } else {
2560
+ roots.push(node);
2561
+ }
2562
+ }
2563
+ }
2564
+ return roots.length ? roots : stack;
2565
+ }
2566
+ function validateNodeIds(node, pathPrefix, issues) {
2567
+ for (const prop of ID_PROPS) {
2568
+ const value = node[prop];
2569
+ if (value === void 0) continue;
2570
+ const validated = validateId4(value, prop);
2571
+ if (!validated.ok) {
2572
+ issues.push({
2573
+ path: `${pathPrefix}.${prop}`,
2574
+ message: validated.issues[0]?.message ?? `invalid ${prop}`
2575
+ });
2576
+ }
2577
+ }
2578
+ node.children?.forEach((child, index) => {
2579
+ validateNodeIds(child, `${pathPrefix}.children[${index}]`, issues);
2580
+ });
2581
+ }
2582
+ function validateBlockTreeIds(tree) {
2583
+ const issues = [];
2584
+ tree.blocks.forEach((block, index) => {
2585
+ validateNodeIds(block, `blocks[${index}]`, issues);
2586
+ });
2587
+ return issues;
2588
+ }
2589
+ function extractBlockTree(options) {
2590
+ const blockTypes = new Set(loadCatalogBlockTypes(options.blockTypes));
2591
+ const sources = options.appSources ?? collectSourceUnderSrc2(options.projectRoot);
2592
+ const blocks = [];
2593
+ for (const rel of sources) {
2594
+ const abs = join10(options.projectRoot, rel);
2595
+ if (!existsSync4(abs)) continue;
2596
+ const source = readFileSync3(abs, "utf8");
2597
+ const parsed = parseJsxBlocks(source, blockTypes);
2598
+ blocks.push(...parsed);
2599
+ }
2600
+ return {
2601
+ schemaVersion: 1,
2602
+ sources,
2603
+ blocks
2604
+ };
2605
+ }
2606
+
2607
+ // src/lkcourse/export.ts
2608
+ import { mkdir as mkdir3, writeFile } from "fs/promises";
2609
+ import { createRequire as createRequire2 } from "module";
2610
+ import { dirname as dirname4, join as join11, resolve as resolve8 } from "path";
2611
+ import { parseLessonkitInterchange } from "@lxpack/validators";
2612
+ function resolveLessonkitVersion(explicit) {
2613
+ if (explicit?.trim()) return explicit.trim();
2614
+ try {
2615
+ const require2 = createRequire2(import.meta.url);
2616
+ const pkg = require2("../../package.json");
2617
+ return pkg.version ?? "0.0.0";
2618
+ } catch {
2619
+ return "0.0.0";
2620
+ }
2621
+ }
2622
+ async function exportLkcourse(options) {
2623
+ const projectRoot = resolve8(options.projectRoot);
2624
+ const manifest = options.manifest;
2625
+ const spaDistDir = join11(projectRoot, manifest.paths.spaDistDir);
2626
+ try {
2627
+ assertRealPathUnderRoot(projectRoot, spaDistDir);
2628
+ await assertSpaDistContentsSafe({ main: spaDistDir }, projectRoot);
2629
+ } catch (err) {
2630
+ return {
2631
+ ok: false,
2632
+ issues: [
2633
+ {
2634
+ path: manifest.paths.spaDistDir,
2635
+ message: err instanceof Error ? err.message : String(err)
2636
+ }
2637
+ ]
2638
+ };
2639
+ }
2640
+ const interchange = descriptorToInterchange(manifest.course);
2641
+ const interchangeParsed = parseLessonkitInterchange(interchange);
2642
+ if (!interchangeParsed.ok) {
2643
+ return {
2644
+ ok: false,
2645
+ issues: interchangeParsed.issues.map((i) => ({
2646
+ path: `interchange.${i.path ?? ""}`.replace(/\.$/, ""),
2647
+ message: i.message
2648
+ }))
2649
+ };
2650
+ }
2651
+ const validatedInterchange = interchangeParsed.data;
2652
+ const interchangeCourseId = validatedInterchange.course?.id;
2653
+ if (!interchangeCourseId) {
2654
+ return {
2655
+ ok: false,
2656
+ issues: [{ path: "interchange.course.id", message: "missing course id in interchange" }]
2657
+ };
2658
+ }
2659
+ if (manifest.course.courseId !== interchangeCourseId) {
2660
+ return {
2661
+ ok: false,
2662
+ issues: [
2663
+ {
2664
+ path: "course.courseId",
2665
+ message: `descriptor courseId "${manifest.course.courseId}" does not match interchange course.id "${interchangeCourseId}"`
2666
+ }
2667
+ ]
2668
+ };
2669
+ }
2670
+ const zipEntries = /* @__PURE__ */ new Map();
2671
+ const interchangeJson = JSON.stringify(interchange, null, 2);
2672
+ zipEntries.set("interchange.json", utf8ToEntry(interchangeJson));
2673
+ let blockTreeJson;
2674
+ if (options.includeBlockTree) {
2675
+ const blockTree = extractBlockTree({ projectRoot });
2676
+ const blockTreeIssues = validateBlockTreeIds(blockTree);
2677
+ if (blockTreeIssues.length) {
2678
+ return {
2679
+ ok: false,
2680
+ issues: blockTreeIssues.map((issue) => ({
2681
+ path: `block-tree.${issue.path}`,
2682
+ message: issue.message
2683
+ }))
2684
+ };
2685
+ }
2686
+ blockTreeJson = JSON.stringify(blockTree, null, 2);
2687
+ zipEntries.set("block-tree.json", utf8ToEntry(blockTreeJson));
2688
+ }
2689
+ let distEntries;
2690
+ try {
2691
+ distEntries = await collectDistEntries(spaDistDir, manifest.paths.spaDistDir);
2692
+ } catch (err) {
2693
+ return {
2694
+ ok: false,
2695
+ issues: [
2696
+ {
2697
+ path: manifest.paths.spaDistDir,
2698
+ message: err instanceof Error ? err.message : String(err)
2699
+ }
2700
+ ]
2701
+ };
2702
+ }
2703
+ if (!distEntries.has(`${manifest.paths.spaDistDir}/index.html`.replace(/\\/g, "/"))) {
2704
+ return {
2705
+ ok: false,
2706
+ issues: [
2707
+ {
2708
+ path: `${manifest.paths.spaDistDir}/index.html`,
2709
+ message: "dist must contain index.html before export"
2710
+ }
2711
+ ]
2712
+ };
2713
+ }
2714
+ for (const [path, data] of distEntries) {
2715
+ zipEntries.set(path, data);
2716
+ }
2717
+ const entryPaths = [...zipEntries.keys()].sort();
2718
+ const envelope = {
2719
+ format: "lkcourse",
2720
+ schemaVersion: 1,
2721
+ lessonkitVersion: resolveLessonkitVersion(options.lessonkitVersion),
2722
+ exportedAt: (/* @__PURE__ */ new Date()).toISOString(),
2723
+ sourceManifest: manifest,
2724
+ entries: entryPaths
2725
+ };
2726
+ const envelopeCheck = parseLkcourseEnvelope(envelope);
2727
+ if (!envelopeCheck.ok) {
2728
+ return { ok: false, issues: envelopeCheck.issues };
2729
+ }
2730
+ zipEntries.set("manifest.json", utf8ToEntry(JSON.stringify(envelope, null, 2)));
2731
+ const archivePath = resolve8(
2732
+ projectRoot,
2733
+ options.outPath ?? `${manifest.name}.lkcourse`
2734
+ );
2735
+ try {
2736
+ assertRealPathUnderRoot(projectRoot, archivePath);
2737
+ } catch (err) {
2738
+ return {
2739
+ ok: false,
2740
+ issues: [
2741
+ {
2742
+ path: options.outPath ?? `${manifest.name}.lkcourse`,
2743
+ message: err instanceof Error ? err.message : String(err)
2744
+ }
2745
+ ]
2746
+ };
2747
+ }
2748
+ if (!isSafeZipEntryPath(options.outPath ?? `${manifest.name}.lkcourse`)) {
2749
+ return {
2750
+ ok: false,
2751
+ issues: [{ path: "outPath", message: "output path must be a safe relative path" }]
2752
+ };
2753
+ }
2754
+ try {
2755
+ await mkdir3(dirname4(archivePath), { recursive: true });
2756
+ const zipped = createZip(zipEntries);
2757
+ await writeFile(archivePath, zipped);
2758
+ } catch (err) {
2759
+ return {
2760
+ ok: false,
2761
+ issues: [
2762
+ {
2763
+ path: archivePath,
2764
+ message: err instanceof Error ? err.message : String(err)
2765
+ }
2766
+ ]
2767
+ };
2768
+ }
2769
+ return {
2770
+ ok: true,
2771
+ archivePath,
2772
+ fileCount: zipEntries.size,
2773
+ includeBlockTree: Boolean(options.includeBlockTree)
2774
+ };
2775
+ }
2776
+
2777
+ // src/lkcourse/validate.ts
2778
+ import { parseLessonkitInterchange as parseLessonkitInterchange2 } from "@lxpack/validators";
2779
+ function validateLkcourseArchiveEntries(entries, _archiveLabel) {
2780
+ const issues = [];
2781
+ const manifestData = entries.get("manifest.json");
2782
+ if (!manifestData) {
2783
+ return {
2784
+ ok: false,
2785
+ issues: [{ path: "manifest.json", message: "required file missing from archive" }]
2786
+ };
2787
+ }
2788
+ let envelopeRaw;
2789
+ try {
2790
+ envelopeRaw = JSON.parse(entryToUtf8(manifestData));
2791
+ } catch {
2792
+ return {
2793
+ ok: false,
2794
+ issues: [{ path: "manifest.json", message: "invalid JSON" }]
2795
+ };
2796
+ }
2797
+ const envelopeParsed = parseLkcourseEnvelope(envelopeRaw, "manifest.json");
2798
+ if (!envelopeParsed.ok) {
2799
+ return { ok: false, issues: envelopeParsed.issues };
2800
+ }
2801
+ const envelope = envelopeParsed.envelope;
2802
+ const interchangeData = entries.get("interchange.json");
2803
+ if (!interchangeData) {
2804
+ issues.push({ path: "interchange.json", message: "required file missing from archive" });
2805
+ }
2806
+ const spaDistDir = envelope.sourceManifest.paths.spaDistDir.replace(/\\/g, "/");
2807
+ const spaIndexPath = `${spaDistDir}/index.html`;
2808
+ if (!entries.has(spaIndexPath)) {
2809
+ issues.push({ path: spaIndexPath, message: "required file missing from archive" });
2810
+ }
2811
+ for (const entryPath of envelope.entries) {
2812
+ if (!entries.has(entryPath)) {
2813
+ issues.push({
2814
+ path: entryPath,
2815
+ message: "listed in manifest.entries but missing from archive"
2816
+ });
2817
+ }
2818
+ }
2819
+ if (issues.length) return { ok: false, issues };
2820
+ let interchangeRaw;
2821
+ try {
2822
+ interchangeRaw = JSON.parse(entryToUtf8(interchangeData));
2823
+ } catch {
2824
+ return {
2825
+ ok: false,
2826
+ issues: [{ path: "interchange.json", message: "invalid JSON" }]
2827
+ };
2828
+ }
2829
+ const interchangeParsed = parseLessonkitInterchange2(interchangeRaw);
2830
+ if (!interchangeParsed.ok) {
2831
+ return {
2832
+ ok: false,
2833
+ issues: interchangeParsed.issues.map((i) => ({
2834
+ path: `interchange.${i.path ?? ""}`.replace(/\.$/, ""),
2835
+ message: i.message
2836
+ }))
2837
+ };
2838
+ }
2839
+ const interchange = interchangeParsed.data;
2840
+ const interchangeCourseId = interchange.course?.id;
2841
+ if (!interchangeCourseId) {
2842
+ issues.push({
2843
+ path: "interchange.course.id",
2844
+ message: "missing course id in interchange"
2845
+ });
2846
+ } else if (envelope.sourceManifest.course.courseId !== interchangeCourseId) {
2847
+ issues.push({
2848
+ path: "sourceManifest.course.courseId",
2849
+ message: `does not match interchange.course.id (${interchangeCourseId})`
2850
+ });
2851
+ }
2852
+ if (issues.length) return { ok: false, issues };
2853
+ const blockTreeData = entries.get("block-tree.json");
2854
+ if (blockTreeData) {
2855
+ let blockTreeRaw;
2856
+ try {
2857
+ blockTreeRaw = JSON.parse(entryToUtf8(blockTreeData));
2858
+ } catch {
2859
+ return {
2860
+ ok: false,
2861
+ issues: [{ path: "block-tree.json", message: "invalid JSON" }]
2862
+ };
2863
+ }
2864
+ const blockTree = blockTreeRaw;
2865
+ if (Array.isArray(blockTree?.blocks)) {
2866
+ const blockTreeIssues = validateBlockTreeIds(blockTree);
2867
+ if (blockTreeIssues.length) {
2868
+ return {
2869
+ ok: false,
2870
+ issues: blockTreeIssues.map((issue) => ({
2871
+ path: `block-tree.${issue.path}`,
2872
+ message: issue.message
2873
+ }))
2874
+ };
2875
+ }
2876
+ }
2877
+ }
2878
+ return {
2879
+ ok: true,
2880
+ envelope,
2881
+ interchange
2882
+ };
2883
+ }
2884
+ function validateLkcourse(archivePath) {
2885
+ const read = readZip(archivePath);
2886
+ if (!read.ok) return read;
2887
+ return validateLkcourseArchiveEntries(read.entries, archivePath);
2888
+ }
2889
+
2890
+ // src/lkcourse/import.ts
2891
+ import { access as access3, cp as cp2, mkdir as mkdir4, mkdtemp as mkdtemp3, readdir as readdir3, rename as rename2, rm as rm4, writeFile as writeFile2 } from "fs/promises";
2892
+ import { dirname as dirname5, join as join12, resolve as resolve9 } from "path";
2893
+ var IMPORT_ARTIFACTS = ["lessonkit.json", "dist"];
2894
+ async function pathExists2(path) {
2895
+ try {
2896
+ await access3(path);
2897
+ return true;
2898
+ } catch {
2899
+ return false;
2900
+ }
2901
+ }
2902
+ async function renameOrCopy2(from, to, opts) {
2903
+ const renameFn = opts?.renameFn ?? rename2;
2904
+ try {
2905
+ await renameFn(from, to);
2906
+ } catch (err) {
2907
+ const code = err && typeof err === "object" && "code" in err ? String(err.code) : "";
2908
+ if (code !== "EXDEV") throw err;
2909
+ await cp2(from, to, { recursive: true });
2910
+ await rm4(from, { recursive: true, force: true });
2911
+ }
2912
+ }
2913
+ async function writeImportTree(stagingDir, manifest, entries, spaDistDir) {
2914
+ let fileCount = 0;
2915
+ await writeFile2(
2916
+ join12(stagingDir, "lessonkit.json"),
2917
+ `${JSON.stringify(manifest, null, 2)}
2918
+ `,
2919
+ "utf8"
2920
+ );
2921
+ fileCount += 1;
2922
+ for (const [entryPath, data] of entries) {
2923
+ const normalized = entryPath.replace(/\\/g, "/");
2924
+ if (!normalized.startsWith(`${spaDistDir}/`)) continue;
2925
+ const relativeUnderSpa = normalized.slice(spaDistDir.length + 1);
2926
+ const outPath = join12(stagingDir, spaDistDir, relativeUnderSpa);
2927
+ const resolvedOut = resolve9(outPath);
2928
+ assertRealPathUnderRoot(stagingDir, resolvedOut);
2929
+ if (!isSafeZipEntryPath(join12(spaDistDir, relativeUnderSpa))) {
2930
+ throw new Error(`unsafe extraction path: ${entryPath}`);
2931
+ }
2932
+ await mkdir4(dirname5(resolvedOut), { recursive: true });
2933
+ await writeFile2(resolvedOut, data);
2934
+ fileCount += 1;
2935
+ }
2936
+ return fileCount;
2937
+ }
2938
+ async function backupImportArtifacts(targetDir) {
2939
+ const existing = [];
2940
+ for (const name of IMPORT_ARTIFACTS) {
2941
+ if (await pathExists2(join12(targetDir, name))) {
2942
+ existing.push(name);
2943
+ }
2944
+ }
2945
+ if (!existing.length) return void 0;
2946
+ const backupDir = await mkdtemp3(join12(targetDir, ".lkcourse-backup-"));
2947
+ for (const name of existing) {
2948
+ await renameOrCopy2(join12(targetDir, name), join12(backupDir, name));
2949
+ }
2950
+ return backupDir;
2951
+ }
2952
+ async function restoreImportBackup(targetDir, backupDir) {
2953
+ for (const name of IMPORT_ARTIFACTS) {
2954
+ const backupPath = join12(backupDir, name);
2955
+ if (!await pathExists2(backupPath)) continue;
2956
+ const destPath = join12(targetDir, name);
2957
+ if (await pathExists2(destPath)) {
2958
+ await rm4(destPath, { recursive: true, force: true });
2959
+ }
2960
+ await renameOrCopy2(backupPath, destPath);
2961
+ }
2962
+ }
2963
+ async function promoteImportStaging(stagingDir, targetDir) {
2964
+ const entries = await readdir3(stagingDir, { withFileTypes: true });
2965
+ for (const entry of entries) {
2966
+ const srcPath = join12(stagingDir, entry.name);
2967
+ const destPath = join12(targetDir, entry.name);
2968
+ if (entry.isDirectory()) {
2969
+ await cp2(srcPath, destPath, { recursive: true, force: true });
2970
+ } else if (entry.isFile()) {
2971
+ await mkdir4(dirname5(destPath), { recursive: true });
2972
+ await cp2(srcPath, destPath);
2973
+ }
2974
+ }
2975
+ }
2976
+ var promoteImportStagingImpl = promoteImportStaging;
2977
+ async function importLkcourse(options) {
2978
+ const archivePath = resolve9(options.archivePath);
2979
+ const targetDir = resolve9(options.targetDir);
2980
+ const validated = validateLkcourse(archivePath);
2981
+ if (!validated.ok) return validated;
2982
+ const { envelope, interchange } = validated;
2983
+ const manifest = envelope.sourceManifest;
2984
+ const spaDistDir = manifest.paths.spaDistDir.replace(/\\/g, "/");
2985
+ try {
2986
+ await mkdir4(targetDir, { recursive: true });
2987
+ assertRealPathUnderRoot(targetDir, targetDir);
2988
+ } catch (err) {
2989
+ return {
2990
+ ok: false,
2991
+ issues: [
2992
+ {
2993
+ path: targetDir,
2994
+ message: err instanceof Error ? err.message : String(err)
2995
+ }
2996
+ ]
2997
+ };
2998
+ }
2999
+ const read = readZip(archivePath);
3000
+ if (!read.ok) return read;
3001
+ let stagingDir;
3002
+ let backupDir;
3003
+ try {
3004
+ stagingDir = await mkdtemp3(join12(targetDir, ".lkcourse-import-"));
3005
+ const fileCount = await writeImportTree(stagingDir, manifest, read.entries, spaDistDir);
3006
+ backupDir = await backupImportArtifacts(targetDir);
3007
+ try {
3008
+ await promoteImportStagingImpl(stagingDir, targetDir);
3009
+ } catch (promoteError) {
3010
+ if (backupDir) {
3011
+ await restoreImportBackup(targetDir, backupDir);
3012
+ }
3013
+ throw promoteError;
3014
+ }
3015
+ if (backupDir) {
3016
+ await rm4(backupDir, { recursive: true, force: true }).catch(() => void 0);
3017
+ backupDir = void 0;
3018
+ }
3019
+ await rm4(stagingDir, { recursive: true, force: true });
3020
+ stagingDir = void 0;
3021
+ return {
3022
+ ok: true,
3023
+ targetDir,
3024
+ manifest,
3025
+ interchange,
3026
+ fileCount
3027
+ };
3028
+ } catch (err) {
3029
+ if (backupDir) {
3030
+ await restoreImportBackup(targetDir, backupDir).catch(() => void 0);
3031
+ await rm4(backupDir, { recursive: true, force: true }).catch(() => void 0);
3032
+ }
3033
+ if (stagingDir) {
3034
+ await rm4(stagingDir, { recursive: true, force: true }).catch(() => void 0);
3035
+ }
3036
+ return {
3037
+ ok: false,
3038
+ issues: [
3039
+ {
3040
+ path: targetDir,
3041
+ message: err instanceof Error ? err.message : String(err)
3042
+ }
3043
+ ]
3044
+ };
3045
+ }
3046
+ }
1766
3047
  export {
1767
3048
  LESSONKIT_TELEMETRY_EVENTS,
3049
+ assertSpaDistContentsSafe,
1768
3050
  assessmentDescriptorToLxpack,
1769
3051
  buildLessonkitProject,
1770
3052
  buildStagingPackage,
1771
3053
  descriptorToInterchange,
1772
3054
  ensureOutDirParent,
3055
+ escapeShellText,
3056
+ exportLkcourse,
1773
3057
  extractAssessments,
3058
+ extractBlockTree,
3059
+ importLkcourse,
1774
3060
  lessonkitInterchangeSchema,
1775
3061
  loadLessonkitManifestFromFile,
1776
3062
  mapLessonkitIds,
@@ -1778,8 +3064,9 @@ export {
1778
3064
  mapLessonkitTelemetryToLxpack,
1779
3065
  materializeLessonkitProject2 as materializeLessonkitProject,
1780
3066
  packageLessonkitCourse,
1781
- parseLessonkitInterchange,
3067
+ parseLessonkitInterchange3 as parseLessonkitInterchange,
1782
3068
  parseLessonkitManifest,
3069
+ parseLkcourseEnvelope,
1783
3070
  promoteStagingToOutDir,
1784
3071
  remapArtifactPaths,
1785
3072
  resolveSafePackageOutputOverride,
@@ -1789,6 +3076,8 @@ export {
1789
3076
  validateDescriptor,
1790
3077
  validateDescriptorForTarget,
1791
3078
  validateLessonkitProject,
3079
+ validateLkcourse,
3080
+ validateLkcourseArchiveEntries,
1792
3081
  validatePackageInputs,
1793
3082
  validateProjectPaths,
1794
3083
  validateReactManifestParity,