@lessonkit/lxpack 0.9.3 → 1.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.cjs CHANGED
@@ -33,21 +33,29 @@ __export(index_exports, {
33
33
  LESSONKIT_TELEMETRY_EVENTS: () => import_tracking_schema2.LESSONKIT_TELEMETRY_EVENTS,
34
34
  assessmentDescriptorToLxpack: () => assessmentDescriptorToLxpack,
35
35
  buildLessonkitProject: () => buildLessonkitProject,
36
+ buildStagingPackage: () => buildStagingPackage,
36
37
  descriptorToInterchange: () => descriptorToInterchange,
38
+ ensureOutDirParent: () => ensureOutDirParent,
37
39
  extractAssessments: () => extractAssessments,
38
40
  lessonkitInterchangeSchema: () => import_validators2.lessonkitInterchangeSchema,
41
+ loadLessonkitManifestFromFile: () => loadLessonkitManifestFromFile,
39
42
  mapLessonkitIds: () => mapLessonkitIds,
40
43
  mapLessonkitTelemetryToBridgeAction: () => import_tracking_schema2.mapLessonkitTelemetryToBridgeAction,
41
44
  mapLessonkitTelemetryToLxpack: () => import_tracking_schema2.mapLessonkitTelemetryToLxpack,
42
45
  materializeLessonkitProject: () => import_validators2.materializeLessonkitProject,
43
46
  packageLessonkitCourse: () => packageLessonkitCourse,
44
47
  parseLessonkitInterchange: () => import_validators2.parseLessonkitInterchange,
48
+ parseLessonkitManifest: () => parseLessonkitManifest,
49
+ promoteStagingToOutDir: () => promoteStagingToOutDir,
50
+ remapArtifactPaths: () => remapArtifactPaths,
45
51
  resolveSafePackageOutputOverride: () => resolveSafePackageOutputOverride,
46
52
  resolveSpaLessons: () => resolveSpaLessons,
47
53
  telemetryEventToLessonkit: () => telemetryEventToLessonkit,
48
54
  themeToLxpackRuntime: () => themeToLxpackRuntime,
49
55
  validateDescriptor: () => validateDescriptor,
56
+ validateDescriptorForTarget: () => validateDescriptorForTarget,
50
57
  validateLessonkitProject: () => validateLessonkitProject,
58
+ validatePackageInputs: () => validatePackageInputs,
51
59
  validateProjectPaths: () => validateProjectPaths,
52
60
  writeLxpackProject: () => writeLxpackProject
53
61
  });
@@ -57,23 +65,73 @@ module.exports = __toCommonJS(index_exports);
57
65
  var import_core = require("@lessonkit/core");
58
66
 
59
67
  // src/spaPath.ts
68
+ var import_node_fs = require("fs");
60
69
  var import_node_path = require("path");
70
+ function resolveComparablePath(p) {
71
+ if (/^[a-zA-Z]:[/\\]/.test(p)) {
72
+ return import_node_path.win32.resolve(p);
73
+ }
74
+ return (0, import_node_path.resolve)(p);
75
+ }
61
76
  function isSafeRelativeSpaPath(spaPath) {
62
77
  if (!spaPath.length || spaPath.includes("\0")) return false;
63
78
  if (spaPath.startsWith("/") || spaPath.startsWith("\\")) return false;
64
- if (/^[a-zA-Z]:[/\\]/.test(spaPath)) return false;
65
- const segments = spaPath.split(/[/\\]/).filter((s) => s.length > 0);
79
+ if (/^[a-zA-Z]:/.test(spaPath)) return false;
80
+ if (spaPath === "." || spaPath === "./") return false;
81
+ const segments = spaPath.split(/[/\\]/).filter((s) => s.length > 0 && s !== ".");
66
82
  if (segments.some((s) => s === "..")) return false;
67
- return true;
83
+ return segments.length > 0;
68
84
  }
69
85
  function assertResolvedPathUnderRoot(root, target) {
70
- const rootResolved = (0, import_node_path.resolve)(root);
71
- const targetResolved = (0, import_node_path.resolve)(target);
86
+ const rootResolved = resolveComparablePath(root);
87
+ const targetResolved = resolveComparablePath(target);
72
88
  const prefix = rootResolved.endsWith(import_node_path.sep) ? rootResolved : rootResolved + import_node_path.sep;
73
- if (targetResolved !== rootResolved && !targetResolved.startsWith(prefix)) {
89
+ const win32Prefix = rootResolved.endsWith(import_node_path.win32.sep) ? rootResolved : rootResolved + import_node_path.win32.sep;
90
+ if (targetResolved !== rootResolved && !targetResolved.startsWith(prefix) && !targetResolved.startsWith(win32Prefix)) {
74
91
  throw new Error(`unsafe path escapes project root: ${target}`);
75
92
  }
76
93
  }
94
+ function assertRealPathUnderRoot(root, target) {
95
+ const rootResolved = resolveComparablePath(root);
96
+ const targetResolved = resolveComparablePath(target);
97
+ let rootReal;
98
+ try {
99
+ rootReal = (0, import_node_fs.realpathSync)(rootResolved);
100
+ } catch {
101
+ rootReal = rootResolved;
102
+ }
103
+ let targetCheck;
104
+ try {
105
+ targetCheck = (0, import_node_fs.realpathSync)(targetResolved);
106
+ } catch {
107
+ const rel = (0, import_node_path.relative)(rootResolved, targetResolved);
108
+ if (rel.startsWith("..") || rel.includes(`..${import_node_path.sep}`)) {
109
+ throw new Error(`unsafe path escapes project root: ${target}`);
110
+ }
111
+ targetCheck = (0, import_node_path.resolve)(rootReal, rel);
112
+ }
113
+ assertResolvedPathUnderRoot(rootReal, targetCheck);
114
+ }
115
+ function normalizePathForComparison(p) {
116
+ const resolved = resolveComparablePath(p);
117
+ return /^[a-zA-Z]:[/\\]/.test(resolved) ? resolved.toLowerCase() : resolved;
118
+ }
119
+ function relativePathUnderRoot(root, target) {
120
+ const rootResolved = normalizePathForComparison(root);
121
+ const targetResolved = normalizePathForComparison(target);
122
+ if (/^[a-zA-Z]:[/\\]/.test(rootResolved)) {
123
+ return import_node_path.win32.relative(rootResolved, targetResolved);
124
+ }
125
+ return (0, import_node_path.relative)(rootResolved, targetResolved);
126
+ }
127
+ function isResolvedPathUnderRoot(root, target) {
128
+ const rootResolved = normalizePathForComparison(root);
129
+ const targetResolved = normalizePathForComparison(target);
130
+ if (targetResolved === rootResolved) return true;
131
+ const rel = relativePathUnderRoot(root, target);
132
+ if (!rel) return true;
133
+ return !rel.startsWith("..") && !(0, import_node_path.isAbsolute)(rel);
134
+ }
77
135
 
78
136
  // src/theme.ts
79
137
  var import_themes = require("@lessonkit/themes");
@@ -93,6 +151,70 @@ function themeToLxpackRuntime(input) {
93
151
  // src/validateDescriptor.ts
94
152
  var VALID_LAYOUTS = ["single-spa", "per-lesson-spa"];
95
153
  var VALID_THEME_PRESETS = ["default", "light", "dark", "brand"];
154
+ function isRecord(value) {
155
+ return typeof value === "object" && value !== null && !Array.isArray(value);
156
+ }
157
+ function parseLessonDescriptor(raw) {
158
+ if (!isRecord(raw)) {
159
+ return { id: "", title: "" };
160
+ }
161
+ return {
162
+ id: typeof raw.id === "string" ? raw.id : "",
163
+ title: typeof raw.title === "string" ? raw.title : "",
164
+ spaPath: typeof raw.spaPath === "string" ? raw.spaPath : void 0
165
+ };
166
+ }
167
+ function parseAssessmentDescriptor(raw) {
168
+ if (!isRecord(raw)) {
169
+ return { checkId: "", question: "", choices: [], answer: "" };
170
+ }
171
+ return {
172
+ checkId: typeof raw.checkId === "string" ? raw.checkId : "",
173
+ question: typeof raw.question === "string" ? raw.question : "",
174
+ choices: Array.isArray(raw.choices) ? raw.choices.filter((c) => typeof c === "string") : [],
175
+ answer: typeof raw.answer === "string" ? raw.answer : "",
176
+ passingScore: typeof raw.passingScore === "number" ? raw.passingScore : void 0
177
+ };
178
+ }
179
+ function parseCourseDescriptorInput(input) {
180
+ if (!isRecord(input)) return null;
181
+ const trackingRaw = input.tracking;
182
+ let tracking;
183
+ if (isRecord(trackingRaw)) {
184
+ const completionRaw = trackingRaw.completion;
185
+ const xapiRaw = trackingRaw.xapi;
186
+ tracking = {
187
+ completion: isRecord(completionRaw) ? {
188
+ threshold: typeof completionRaw.threshold === "number" ? completionRaw.threshold : void 0
189
+ } : void 0,
190
+ xapi: isRecord(xapiRaw) ? {
191
+ activityIri: typeof xapiRaw.activityIri === "string" ? xapiRaw.activityIri : void 0
192
+ } : void 0
193
+ };
194
+ }
195
+ const themeRaw = input.theme;
196
+ let theme;
197
+ if (isRecord(themeRaw)) {
198
+ theme = {
199
+ preset: typeof themeRaw.preset === "string" ? themeRaw.preset : void 0
200
+ };
201
+ if (isRecord(themeRaw.theme)) {
202
+ theme.theme = themeRaw.theme;
203
+ }
204
+ }
205
+ return {
206
+ courseId: typeof input.courseId === "string" ? input.courseId : "",
207
+ title: typeof input.title === "string" ? input.title : "",
208
+ version: typeof input.version === "string" ? input.version : void 0,
209
+ layout: typeof input.layout === "string" ? input.layout : void 0,
210
+ lessons: Array.isArray(input.lessons) ? input.lessons.map(parseLessonDescriptor) : [],
211
+ assessments: Array.isArray(input.assessments) ? input.assessments.map(parseAssessmentDescriptor) : void 0,
212
+ theme,
213
+ tracking,
214
+ spaDistDir: typeof input.spaDistDir === "string" ? input.spaDistDir : void 0,
215
+ spaLessonId: typeof input.spaLessonId === "string" ? input.spaLessonId : void 0
216
+ };
217
+ }
96
218
  function normalizeDescriptor(input) {
97
219
  const course = (0, import_core.validateId)(input.courseId, "courseId");
98
220
  if (!course.ok) throw new Error("normalizeDescriptor called with invalid courseId");
@@ -126,6 +248,31 @@ function normalizeDescriptor(input) {
126
248
  };
127
249
  }
128
250
  function validateDescriptor(input) {
251
+ const parsed = parseCourseDescriptorInput(input);
252
+ if (parsed === null) {
253
+ return { ok: false, issues: [{ path: "course", message: "must be an object" }] };
254
+ }
255
+ return validateDescriptorParsed(parsed);
256
+ }
257
+ function validateDescriptorForTarget(input, target) {
258
+ const result = validateDescriptor(input);
259
+ if (!result.ok || !target) return result;
260
+ if (target !== "xapi" && target !== "cmi5") return result;
261
+ const activityIri = result.descriptor.tracking?.xapi?.activityIri?.trim();
262
+ if (!activityIri) {
263
+ return {
264
+ ok: false,
265
+ issues: [
266
+ {
267
+ path: "course.tracking.xapi.activityIri",
268
+ message: "tracking.xapi.activityIri is required for xapi and cmi5 export targets"
269
+ }
270
+ ]
271
+ };
272
+ }
273
+ return result;
274
+ }
275
+ function validateDescriptorParsed(input) {
129
276
  const issues = [];
130
277
  const course = (0, import_core.validateId)(input.courseId, "courseId");
131
278
  if (!course.ok) issues.push(...course.issues.map((i) => ({ path: i.path, message: i.message })));
@@ -249,7 +396,7 @@ function validateDescriptor(input) {
249
396
  issues.push({ path: `${path}.answer`, message: "answer must match a choice" });
250
397
  }
251
398
  const passingScore = assessment.passingScore;
252
- if (passingScore !== void 0 && !(passingScore > 0)) {
399
+ if (passingScore !== void 0 && !(Number.isFinite(passingScore) && passingScore > 0)) {
253
400
  issues.push({
254
401
  path: `${path}.passingScore`,
255
402
  message: "passingScore must be greater than 0 (absolute point threshold)"
@@ -271,7 +418,7 @@ function validatePathField(value, fieldPath, projectRoot, issues) {
271
418
  return;
272
419
  }
273
420
  try {
274
- assertResolvedPathUnderRoot(projectRoot, (0, import_node_path2.resolve)(projectRoot, value));
421
+ assertRealPathUnderRoot(projectRoot, (0, import_node_path2.resolve)(projectRoot, value));
275
422
  } catch {
276
423
  issues.push({
277
424
  path: fieldPath,
@@ -301,14 +448,14 @@ function resolveSafePackageOutputOverride(projectRoot, override) {
301
448
  }
302
449
  if ((0, import_node_path2.isAbsolute)(trimmed)) {
303
450
  const resolved2 = (0, import_node_path2.resolve)(trimmed);
304
- assertResolvedPathUnderRoot(root, resolved2);
451
+ assertRealPathUnderRoot(root, resolved2);
305
452
  return resolved2;
306
453
  }
307
454
  if (!isSafeRelativeSpaPath(trimmed)) {
308
455
  throw new Error(`unsafe output path: ${override}`);
309
456
  }
310
457
  const resolved = (0, import_node_path2.resolve)(root, trimmed);
311
- assertResolvedPathUnderRoot(root, resolved);
458
+ assertRealPathUnderRoot(root, resolved);
312
459
  return resolved;
313
460
  }
314
461
 
@@ -355,6 +502,18 @@ function extractAssessments(descriptor) {
355
502
  }
356
503
 
357
504
  // src/interchange.ts
505
+ function mapDescriptorTracking(tracking) {
506
+ if (!tracking) return void 0;
507
+ const mapped = {};
508
+ if (tracking.completion?.threshold !== void 0) {
509
+ mapped.completion = { threshold: tracking.completion.threshold };
510
+ }
511
+ const activityIri = tracking.xapi?.activityIri?.trim();
512
+ if (activityIri) {
513
+ mapped.xapi = { activityIri };
514
+ }
515
+ return Object.keys(mapped).length > 0 ? mapped : void 0;
516
+ }
358
517
  function resolveSpaLessons(descriptor) {
359
518
  const mapped = mapLessonkitIds(descriptor);
360
519
  if (descriptor.layout === "single-spa") {
@@ -392,7 +551,7 @@ function descriptorToInterchange(descriptor) {
392
551
  type: "spa",
393
552
  path: l.path
394
553
  })),
395
- tracking: descriptor.tracking,
554
+ tracking: mapDescriptorTracking(descriptor.tracking),
396
555
  runtime: runtime ? {
397
556
  theme: runtime.theme,
398
557
  cssVariables: runtime.cssVariables
@@ -415,13 +574,18 @@ async function resolveSpaDirs(options) {
415
574
  const spaDistRelative = spaDistDir ?? descriptor.spaDistDir ?? "dist";
416
575
  const srcDist = projectRoot ? (0, import_node_path3.resolve)(projectRoot, spaDistRelative) : (0, import_node_path3.resolve)(spaDistRelative);
417
576
  if (projectRoot) {
418
- assertResolvedPathUnderRoot((0, import_node_path3.resolve)(projectRoot), srcDist);
577
+ assertRealPathUnderRoot((0, import_node_path3.resolve)(projectRoot), srcDist);
419
578
  }
420
579
  try {
421
580
  await (0, import_promises.access)(srcDist);
422
581
  } catch {
423
582
  throw new Error(`spaDistDir not found: ${srcDist}`);
424
583
  }
584
+ try {
585
+ await (0, import_promises.access)((0, import_node_path3.join)(srcDist, "index.html"));
586
+ } catch {
587
+ throw new Error(`spaDistDir must contain index.html: ${(0, import_node_path3.join)(srcDist, "index.html")}`);
588
+ }
425
589
  const lessonId = spaLessons[0]?.id ?? "main";
426
590
  return { [lessonId]: srcDist };
427
591
  }
@@ -434,7 +598,19 @@ async function resolveSpaDirs(options) {
434
598
  }
435
599
  const resolved = projectRoot ? (0, import_node_path3.resolve)(projectRoot, src) : (0, import_node_path3.resolve)(src);
436
600
  if (projectRoot) {
437
- assertResolvedPathUnderRoot((0, import_node_path3.resolve)(projectRoot), resolved);
601
+ assertRealPathUnderRoot((0, import_node_path3.resolve)(projectRoot), resolved);
602
+ }
603
+ try {
604
+ await (0, import_promises.access)(resolved);
605
+ } catch {
606
+ throw new Error(`lessonSpaDirs path not found for lesson "${lesson.id}": ${resolved}`);
607
+ }
608
+ try {
609
+ await (0, import_promises.access)((0, import_node_path3.join)(resolved, "index.html"));
610
+ } catch {
611
+ throw new Error(
612
+ `lessonSpaDirs must contain index.html for lesson "${lesson.id}": ${(0, import_node_path3.join)(resolved, "index.html")}`
613
+ );
438
614
  }
439
615
  dirs[lesson.id] = resolved;
440
616
  }
@@ -452,7 +628,7 @@ async function writeLxpackProject(options) {
452
628
  const descriptor = validation.descriptor;
453
629
  const outDir = (0, import_node_path4.resolve)(options.outDir);
454
630
  if (options.projectRoot) {
455
- assertResolvedPathUnderRoot((0, import_node_path4.resolve)(options.projectRoot), outDir);
631
+ assertRealPathUnderRoot((0, import_node_path4.resolve)(options.projectRoot), outDir);
456
632
  }
457
633
  const spaDirs = await resolveSpaDirs({ ...options, descriptor });
458
634
  const interchange = descriptorToInterchange(descriptor);
@@ -476,68 +652,27 @@ async function writeLxpackProject(options) {
476
652
  }
477
653
 
478
654
  // src/packageCourse.ts
479
- var fsp = __toESM(require("fs/promises"), 1);
655
+ var import_node_path8 = require("path");
656
+ var fsp3 = __toESM(require("fs/promises"), 1);
657
+ var import_api2 = require("@lxpack/api");
658
+
659
+ // src/packaging/validateInputs.ts
480
660
  var import_node_path5 = require("path");
481
- var import_node_os = require("os");
482
- var import_api = require("@lxpack/api");
483
- async function validateLessonkitProject(options) {
484
- return (0, import_api.validateCourse)({
485
- courseDir: (0, import_node_path5.resolve)(options.courseDir),
486
- target: options.target
487
- });
488
- }
489
- async function buildLessonkitProject(options) {
490
- return (0, import_api.buildCourse)({
491
- courseDir: (0, import_node_path5.resolve)(options.courseDir),
492
- target: options.target,
493
- output: options.output,
494
- dir: options.dir,
495
- outputBaseDir: options.outputBaseDir,
496
- assessments: options.assessments
497
- });
498
- }
499
- async function pathExists(path) {
500
- try {
501
- await fsp.access(path);
502
- return true;
503
- } catch {
504
- return false;
505
- }
506
- }
507
- async function promoteStagingToOutDir(stagingDir, outDir) {
508
- const tmpPromote = `${outDir}.tmp-promote`;
509
- const backup = `${outDir}.bak`;
510
- await fsp.rename(stagingDir, tmpPromote);
511
- const hadOutDir = await pathExists(outDir);
512
- if (hadOutDir) {
513
- await fsp.rename(outDir, backup);
514
- }
515
- try {
516
- await fsp.rename(tmpPromote, outDir);
517
- } catch (promoteError) {
518
- if (hadOutDir) {
519
- try {
520
- await fsp.rename(backup, outDir);
521
- } catch (restoreError) {
522
- console.warn(
523
- `[lessonkit/lxpack] failed to restore ${outDir} after promote error:`,
524
- restoreError instanceof Error ? restoreError.message : restoreError
525
- );
526
- }
527
- }
528
- await fsp.rm(tmpPromote, { recursive: true, force: true }).catch(() => void 0);
529
- throw promoteError;
530
- }
531
- if (hadOutDir) {
532
- await fsp.rm(backup, { recursive: true, force: true }).catch(() => void 0);
533
- }
534
- }
535
- async function packageLessonkitCourse(options) {
536
- const { target, output, dir, outputBaseDir, ...writeOpts } = options;
537
- const outDir = (0, import_node_path5.resolve)(writeOpts.outDir);
538
- const projectRoot = writeOpts.projectRoot ? (0, import_node_path5.resolve)(writeOpts.projectRoot) : void 0;
661
+ function validatePackageInputs(options) {
662
+ const { target, output, outputBaseDir } = options;
663
+ const outDir = (0, import_node_path5.resolve)(options.outDir);
664
+ const projectRoot = options.projectRoot ? (0, import_node_path5.resolve)(options.projectRoot) : void 0;
539
665
  if (projectRoot) {
540
- assertResolvedPathUnderRoot(projectRoot, outDir);
666
+ try {
667
+ assertRealPathUnderRoot(projectRoot, outDir);
668
+ } catch (err) {
669
+ return {
670
+ ok: false,
671
+ courseDir: outDir,
672
+ target,
673
+ issues: [{ path: "outDir", message: err instanceof Error ? err.message : String(err) }]
674
+ };
675
+ }
541
676
  }
542
677
  if (outputBaseDir && !isSafeRelativeSpaPath(outputBaseDir)) {
543
678
  return {
@@ -547,10 +682,18 @@ async function packageLessonkitCourse(options) {
547
682
  issues: [{ path: "outputBaseDir", message: `unsafe outputBaseDir: ${outputBaseDir}` }]
548
683
  };
549
684
  }
550
- if (projectRoot && output) {
551
- const resolvedOutput = (0, import_node_path5.resolve)(projectRoot, output);
685
+ if (output && !projectRoot && !isSafeRelativeSpaPath(output)) {
686
+ return {
687
+ ok: false,
688
+ courseDir: outDir,
689
+ target,
690
+ issues: [{ path: "output", message: `unsafe output: ${output}` }]
691
+ };
692
+ }
693
+ if (projectRoot && outputBaseDir) {
694
+ const resolvedOutputBase = (0, import_node_path5.resolve)(projectRoot, outputBaseDir);
552
695
  try {
553
- assertResolvedPathUnderRoot(projectRoot, resolvedOutput);
696
+ assertRealPathUnderRoot(projectRoot, resolvedOutputBase);
554
697
  } catch (err) {
555
698
  return {
556
699
  ok: false,
@@ -558,28 +701,152 @@ async function packageLessonkitCourse(options) {
558
701
  target,
559
702
  issues: [
560
703
  {
561
- path: "output",
704
+ path: "outputBaseDir",
562
705
  message: err instanceof Error ? err.message : String(err)
563
706
  }
564
707
  ]
565
708
  };
566
709
  }
567
710
  }
568
- const descriptorValidation = validateDescriptor(writeOpts.descriptor);
569
- if (!descriptorValidation.ok) {
711
+ if (projectRoot && output) {
712
+ const resolvedOutput = (0, import_node_path5.resolve)(projectRoot, output);
713
+ try {
714
+ assertRealPathUnderRoot(projectRoot, resolvedOutput);
715
+ } catch (err) {
716
+ return {
717
+ ok: false,
718
+ courseDir: outDir,
719
+ target,
720
+ issues: [{ path: "output", message: err instanceof Error ? err.message : String(err) }]
721
+ };
722
+ }
723
+ }
724
+ return { ok: true, outDir, projectRoot };
725
+ }
726
+ function validateArtifactInStaging(stagingRoot, artifactPath, field) {
727
+ if (!artifactPath) return null;
728
+ const resolved = resolveComparablePath(artifactPath);
729
+ if (!isResolvedPathUnderRoot(stagingRoot, resolved)) {
570
730
  return {
571
- ok: false,
572
- courseDir: outDir,
573
- target,
574
- issues: descriptorValidation.issues.map((i) => ({
575
- path: i.path,
576
- message: i.message
577
- }))
731
+ path: field,
732
+ message: `${field} is outside the staging directory: ${artifactPath}`
578
733
  };
579
734
  }
580
- const descriptor = descriptorValidation.descriptor;
581
- const stagingDir = await fsp.mkdtemp((0, import_node_path5.join)((0, import_node_os.tmpdir)(), "lessonkit-lxpack-"));
582
- let promoted = false;
735
+ return null;
736
+ }
737
+ function remapArtifactPaths(stagingRoot, outDir, artifactPath) {
738
+ if (!artifactPath) return void 0;
739
+ const resolved = resolveComparablePath(artifactPath);
740
+ if (!isResolvedPathUnderRoot(stagingRoot, resolved)) {
741
+ return artifactPath;
742
+ }
743
+ const rel = relativePathUnderRoot(stagingRoot, resolved);
744
+ if (rel.startsWith("..") || (0, import_node_path5.isAbsolute)(rel)) {
745
+ return artifactPath;
746
+ }
747
+ if (!rel) return outDir;
748
+ if (/^[a-zA-Z]:[/\\]/.test(outDir)) {
749
+ return import_node_path5.win32.join(outDir, rel.replace(/\//g, import_node_path5.win32.sep));
750
+ }
751
+ return (0, import_node_path5.join)(outDir, rel);
752
+ }
753
+
754
+ // src/packaging/promote.ts
755
+ var fsp = __toESM(require("fs/promises"), 1);
756
+ var import_node_crypto = require("crypto");
757
+ var import_node_path6 = require("path");
758
+ async function pathExists(path) {
759
+ try {
760
+ await fsp.access(path);
761
+ return true;
762
+ } catch {
763
+ return false;
764
+ }
765
+ }
766
+ async function renameOrCopy(from, to) {
767
+ try {
768
+ await fsp.rename(from, to);
769
+ } catch (err) {
770
+ const code = err && typeof err === "object" && "code" in err ? String(err.code) : "";
771
+ if (code !== "EXDEV") throw err;
772
+ await fsp.cp(from, to, { recursive: true });
773
+ await fsp.rm(from, { recursive: true, force: true });
774
+ }
775
+ }
776
+ async function assertNoLegacyPromoteArtifacts(outDir) {
777
+ const legacyTmp = `${outDir}.tmp-promote`;
778
+ const legacyBak = `${outDir}.bak`;
779
+ const stale = [];
780
+ if (await pathExists(legacyTmp)) stale.push(legacyTmp);
781
+ if (await pathExists(legacyBak)) stale.push(legacyBak);
782
+ if (stale.length) {
783
+ throw new Error(
784
+ `[lessonkit/lxpack] cannot promote: remove stale packaging artifacts from a previous failed run: ${stale.join(", ")}`
785
+ );
786
+ }
787
+ }
788
+ async function promoteStagingToOutDir(stagingDir, outDir) {
789
+ await assertNoLegacyPromoteArtifacts(outDir);
790
+ const parent = (0, import_node_path6.dirname)(outDir);
791
+ const tmpPromote = await fsp.mkdtemp((0, import_node_path6.join)(parent, ".lk-promote-"));
792
+ await renameOrCopy(stagingDir, tmpPromote);
793
+ const hadOutDir = await pathExists(outDir);
794
+ const backup = hadOutDir ? await fsp.mkdtemp((0, import_node_path6.join)(parent, ".lk-backup-")) : void 0;
795
+ if (hadOutDir && backup) {
796
+ await renameOrCopy(outDir, backup);
797
+ }
798
+ try {
799
+ await renameOrCopy(tmpPromote, outDir);
800
+ } catch (promoteError) {
801
+ if (hadOutDir && backup) {
802
+ try {
803
+ await renameOrCopy(backup, outDir);
804
+ } catch (restoreError) {
805
+ const failedPromote2 = (0, import_node_path6.join)(parent, `.lk-failed-promote-${(0, import_node_crypto.randomUUID)()}`);
806
+ try {
807
+ await renameOrCopy(tmpPromote, failedPromote2);
808
+ } catch {
809
+ await fsp.rm(tmpPromote, { recursive: true, force: true }).catch(() => void 0);
810
+ }
811
+ const promoteMsg = promoteError instanceof Error ? promoteError.message : String(promoteError);
812
+ const restoreMsg = restoreError instanceof Error ? restoreError.message : String(restoreError);
813
+ throw new Error(
814
+ `[lessonkit/lxpack] promote failed (${promoteMsg}) and could not restore ${outDir} (${restoreMsg}). Recovery: previous output may be in ${backup}; staged package may be in ${failedPromote2}.`
815
+ );
816
+ }
817
+ } else {
818
+ try {
819
+ await renameOrCopy(tmpPromote, stagingDir);
820
+ } catch (restoreError) {
821
+ console.warn(
822
+ `[lessonkit/lxpack] failed to restore ${stagingDir} after promote error:`,
823
+ restoreError instanceof Error ? restoreError.message : restoreError
824
+ );
825
+ await fsp.rm(tmpPromote, { recursive: true, force: true }).catch(() => void 0);
826
+ }
827
+ throw promoteError;
828
+ }
829
+ const failedPromote = (0, import_node_path6.join)(parent, `.lk-failed-promote-${(0, import_node_crypto.randomUUID)()}`);
830
+ try {
831
+ await renameOrCopy(tmpPromote, failedPromote);
832
+ } catch {
833
+ await fsp.rm(tmpPromote, { recursive: true, force: true }).catch(() => void 0);
834
+ }
835
+ throw promoteError;
836
+ }
837
+ if (backup) {
838
+ await fsp.rm(backup, { recursive: true, force: true }).catch(() => void 0);
839
+ }
840
+ }
841
+
842
+ // src/packaging/staging.ts
843
+ var fsp2 = __toESM(require("fs/promises"), 1);
844
+ var import_node_path7 = require("path");
845
+ var import_node_os = require("os");
846
+ var import_api = require("@lxpack/api");
847
+ async function buildStagingPackage(options) {
848
+ const { target, output, dir, outputBaseDir, descriptor, ...writeOpts } = options;
849
+ const stagingDir = await fsp2.mkdtemp((0, import_node_path7.join)((0, import_node_os.tmpdir)(), "lessonkit-lxpack-"));
583
850
  try {
584
851
  let spaDirs;
585
852
  try {
@@ -587,8 +854,7 @@ async function packageLessonkitCourse(options) {
587
854
  } catch (err) {
588
855
  return {
589
856
  ok: false,
590
- courseDir: outDir,
591
- target,
857
+ stagingDir,
592
858
  issues: [
593
859
  {
594
860
  path: "spaDirs",
@@ -599,8 +865,8 @@ async function packageLessonkitCourse(options) {
599
865
  }
600
866
  const interchange = descriptorToInterchange(descriptor);
601
867
  const outputBase = outputBaseDir ?? ".lxpack/out";
602
- await fsp.mkdir((0, import_node_path5.join)(stagingDir, outputBase), { recursive: true });
603
- const defaultOutput = output ?? (dir ? (0, import_node_path5.join)(outputBase, target) : (0, import_node_path5.join)(outputBase, `course-${target}.zip`));
868
+ await fsp2.mkdir((0, import_node_path7.join)(stagingDir, outputBase), { recursive: true });
869
+ const defaultOutput = output ?? (dir ? (0, import_node_path7.join)(outputBase, target) : (0, import_node_path7.join)(outputBase, `course-${target}.zip`));
604
870
  const build = await (0, import_api.packageLessonkit)({
605
871
  interchange,
606
872
  spaDirs,
@@ -613,15 +879,9 @@ async function packageLessonkitCourse(options) {
613
879
  writeAuthoringFiles: true
614
880
  });
615
881
  if (!build.ok) {
616
- const validation2 = {
617
- ok: false,
618
- issues: build.issues
619
- };
620
882
  return {
621
883
  ok: false,
622
- courseDir: outDir,
623
- target,
624
- validation: validation2,
884
+ stagingDir,
625
885
  build,
626
886
  issues: build.issues.map((i) => ({
627
887
  path: i.path,
@@ -630,48 +890,256 @@ async function packageLessonkitCourse(options) {
630
890
  }))
631
891
  };
632
892
  }
633
- const validation = {
893
+ return {
634
894
  ok: true,
635
- manifest: build.manifest,
636
- issues: build.issues
895
+ stagingDir,
896
+ build,
897
+ outputPath: "outputPath" in build ? build.outputPath : void 0,
898
+ outputDir: "outputDir" in build ? build.outputDir : void 0
637
899
  };
638
- const stagingRoot = await fsp.realpath(stagingDir);
639
- const remapArtifactPath = (artifactPath) => {
640
- if (!artifactPath) return void 0;
641
- const resolved = (0, import_node_path5.resolve)(artifactPath);
642
- if (resolved === stagingRoot || resolved.startsWith(`${stagingRoot}/`)) {
643
- return (0, import_node_path5.join)(outDir, resolved.slice(stagingRoot.length + 1));
644
- }
645
- return artifactPath;
900
+ } catch (err) {
901
+ await fsp2.rm(stagingDir, { recursive: true, force: true }).catch(() => void 0);
902
+ throw err;
903
+ }
904
+ }
905
+ async function ensureOutDirParent(outDir) {
906
+ await fsp2.mkdir((0, import_node_path7.dirname)(outDir), { recursive: true });
907
+ }
908
+
909
+ // src/packageCourse.ts
910
+ async function validateLessonkitProject(options) {
911
+ return (0, import_api2.validateCourse)({
912
+ courseDir: (0, import_node_path8.resolve)(options.courseDir),
913
+ target: options.target
914
+ });
915
+ }
916
+ async function buildLessonkitProject(options) {
917
+ const buildOptions = {
918
+ courseDir: (0, import_node_path8.resolve)(options.courseDir),
919
+ target: options.target,
920
+ output: options.output,
921
+ dir: options.dir,
922
+ outputBaseDir: options.outputBaseDir,
923
+ assessments: options.assessments
924
+ };
925
+ return (0, import_api2.buildCourse)(buildOptions);
926
+ }
927
+ async function packageLessonkitCourse(options) {
928
+ const { target, output, dir, outputBaseDir, ...writeOpts } = options;
929
+ const inputValidation = validatePackageInputs({
930
+ target,
931
+ output,
932
+ outputBaseDir,
933
+ outDir: writeOpts.outDir,
934
+ projectRoot: writeOpts.projectRoot
935
+ });
936
+ if (!inputValidation.ok) {
937
+ return {
938
+ ok: false,
939
+ courseDir: inputValidation.courseDir,
940
+ target: inputValidation.target,
941
+ issues: inputValidation.issues
646
942
  };
647
- const remappedOutputPath = remapArtifactPath(
648
- "outputPath" in build ? build.outputPath : void 0
649
- );
650
- const remappedOutputDir = remapArtifactPath("outputDir" in build ? build.outputDir : void 0);
651
- await fsp.mkdir((0, import_node_path5.dirname)(outDir), { recursive: true });
943
+ }
944
+ const outDir = inputValidation.outDir;
945
+ const descriptorValidation = validateDescriptorForTarget(writeOpts.descriptor, target);
946
+ if (!descriptorValidation.ok) {
947
+ return {
948
+ ok: false,
949
+ courseDir: outDir,
950
+ target,
951
+ issues: descriptorValidation.issues.map((i) => ({
952
+ path: i.path,
953
+ message: i.message
954
+ }))
955
+ };
956
+ }
957
+ const descriptor = descriptorValidation.descriptor;
958
+ const staged = await buildStagingPackage({
959
+ ...writeOpts,
960
+ descriptor,
961
+ target,
962
+ output,
963
+ dir,
964
+ outputBaseDir
965
+ });
966
+ if (!staged.ok) {
967
+ await fsp3.rm(staged.stagingDir, { recursive: true, force: true }).catch(() => void 0);
968
+ const validation2 = staged.build ? { ok: false, issues: staged.build.issues } : void 0;
969
+ return {
970
+ ok: false,
971
+ courseDir: outDir,
972
+ target,
973
+ validation: validation2,
974
+ build: staged.build,
975
+ issues: staged.issues
976
+ };
977
+ }
978
+ const { stagingDir, build } = staged;
979
+ const stagingRoot = await fsp3.realpath(stagingDir);
980
+ const artifactIssues = [
981
+ validateArtifactInStaging(stagingRoot, staged.outputPath, "outputPath"),
982
+ validateArtifactInStaging(stagingRoot, staged.outputDir, "outputDir")
983
+ ].filter((issue) => issue != null);
984
+ if (artifactIssues.length > 0) {
985
+ await fsp3.rm(stagingDir, { recursive: true, force: true }).catch(() => void 0);
986
+ return {
987
+ ok: false,
988
+ courseDir: outDir,
989
+ target,
990
+ validation: { ok: true, manifest: build.manifest, issues: build.issues },
991
+ build,
992
+ issues: artifactIssues
993
+ };
994
+ }
995
+ const remappedOutputPath = remapArtifactPaths(stagingRoot, outDir, staged.outputPath);
996
+ const remappedOutputDir = remapArtifactPaths(stagingRoot, outDir, staged.outputDir);
997
+ const validation = {
998
+ ok: true,
999
+ manifest: build.manifest,
1000
+ issues: build.issues
1001
+ };
1002
+ try {
1003
+ await ensureOutDirParent(outDir);
652
1004
  await promoteStagingToOutDir(stagingDir, outDir);
653
- promoted = true;
654
- const remappedBuild = { ...build };
655
- if ("outputPath" in remappedBuild && remappedOutputPath !== void 0) {
656
- remappedBuild.outputPath = remappedOutputPath;
657
- }
658
- if ("outputDir" in remappedBuild && remappedOutputDir !== void 0) {
659
- remappedBuild.outputDir = remappedOutputDir;
660
- }
1005
+ } catch (err) {
661
1006
  return {
662
- ok: true,
1007
+ ok: false,
663
1008
  courseDir: outDir,
664
1009
  target,
665
- outputPath: remappedOutputPath,
666
- outputDir: remappedOutputDir,
667
- fileCount: build.fileCount,
668
1010
  validation,
669
- build: remappedBuild
1011
+ build,
1012
+ issues: [
1013
+ {
1014
+ path: "promote",
1015
+ message: err instanceof Error ? err.message : String(err)
1016
+ }
1017
+ ]
670
1018
  };
671
- } finally {
672
- if (!promoted) {
673
- await fsp.rm(stagingDir, { recursive: true, force: true }).catch(() => void 0);
1019
+ }
1020
+ const remappedBuild = { ...build };
1021
+ if ("outputPath" in remappedBuild && remappedOutputPath !== void 0) {
1022
+ remappedBuild.outputPath = remappedOutputPath;
1023
+ }
1024
+ if ("outputDir" in remappedBuild && remappedOutputDir !== void 0) {
1025
+ remappedBuild.outputDir = remappedOutputDir;
1026
+ }
1027
+ return {
1028
+ ok: true,
1029
+ courseDir: outDir,
1030
+ target,
1031
+ outputPath: remappedOutputPath,
1032
+ outputDir: remappedOutputDir,
1033
+ fileCount: build.fileCount,
1034
+ validation,
1035
+ build: remappedBuild
1036
+ };
1037
+ }
1038
+
1039
+ // src/manifest.ts
1040
+ var DEFAULT_PATHS = {
1041
+ spaDistDir: "dist",
1042
+ lxpackOutDir: ".lxpack/course",
1043
+ outputBaseDir: ".lxpack/out"
1044
+ };
1045
+ function parseLessonkitManifest(raw, label = "lessonkit.json", projectRoot) {
1046
+ if (!raw || typeof raw !== "object") {
1047
+ return { ok: false, issues: [{ path: label, message: "must be a JSON object" }] };
1048
+ }
1049
+ const config = raw;
1050
+ const issues = [];
1051
+ let schemaVersion = config.schemaVersion;
1052
+ if (schemaVersion === "1") {
1053
+ schemaVersion = 1;
1054
+ }
1055
+ if (schemaVersion !== 1) {
1056
+ issues.push({
1057
+ path: "schemaVersion",
1058
+ message: `must be 1 (got ${String(config.schemaVersion)})`
1059
+ });
1060
+ }
1061
+ const nameRaw = config.name;
1062
+ const name = typeof nameRaw === "string" ? nameRaw.trim() : "";
1063
+ if (!name) {
1064
+ issues.push({ path: "name", message: "must be a non-empty string" });
1065
+ }
1066
+ const courseRaw = config.course;
1067
+ if (Array.isArray(courseRaw)) {
1068
+ issues.push({ path: "course", message: "must be an object, not an array" });
1069
+ return { ok: false, issues };
1070
+ }
1071
+ if (!courseRaw || typeof courseRaw !== "object") {
1072
+ issues.push({ path: "course", message: "must be an object" });
1073
+ return { ok: false, issues };
1074
+ }
1075
+ const courseObj = courseRaw;
1076
+ if (courseObj.lessons !== void 0 && !Array.isArray(courseObj.lessons)) {
1077
+ issues.push({ path: "course.lessons", message: "must be an array" });
1078
+ }
1079
+ if (courseObj.assessments !== void 0 && !Array.isArray(courseObj.assessments)) {
1080
+ issues.push({ path: "course.assessments", message: "must be an array" });
1081
+ }
1082
+ if (issues.length) return { ok: false, issues };
1083
+ const validation = validateDescriptor(courseRaw);
1084
+ if (!validation.ok) {
1085
+ for (const i of validation.issues) {
1086
+ issues.push({
1087
+ path: i.path.startsWith("course.") ? i.path : `course.${i.path}`,
1088
+ message: i.message
1089
+ });
1090
+ }
1091
+ } else if (validation.descriptor.layout === "per-lesson-spa") {
1092
+ issues.push({
1093
+ path: "course.layout",
1094
+ message: "per-lesson-spa is not supported by lessonkit package yet. Use single-spa or package via @lessonkit/lxpack directly."
1095
+ });
1096
+ }
1097
+ const paths = { ...DEFAULT_PATHS };
1098
+ const pathsRaw = config.paths;
1099
+ if (pathsRaw !== void 0 && (typeof pathsRaw !== "object" || pathsRaw === null)) {
1100
+ issues.push({ path: "paths", message: "must be an object" });
1101
+ } else if (pathsRaw && typeof pathsRaw === "object") {
1102
+ const p = pathsRaw;
1103
+ for (const key of ["spaDistDir", "lxpackOutDir", "outputBaseDir"]) {
1104
+ if (p[key] !== void 0) {
1105
+ if (typeof p[key] !== "string" || !p[key].trim()) {
1106
+ issues.push({ path: `paths.${key}`, message: "must be a non-empty string" });
1107
+ } else {
1108
+ paths[key] = p[key].trim();
1109
+ }
1110
+ }
1111
+ }
1112
+ }
1113
+ const courseSpaDistDir = validation.ok ? validation.descriptor.spaDistDir?.trim() : void 0;
1114
+ if (courseSpaDistDir && courseSpaDistDir !== paths.spaDistDir) {
1115
+ issues.push({
1116
+ path: "course.spaDistDir",
1117
+ message: `"course.spaDistDir" (${courseSpaDistDir}) differs from "paths.spaDistDir" (${paths.spaDistDir}). Use paths.spaDistDir for CLI build and package.`
1118
+ });
1119
+ }
1120
+ if (projectRoot) {
1121
+ const pathIssues = validateProjectPaths(projectRoot, paths);
1122
+ for (const pi of pathIssues) {
1123
+ issues.push({ path: pi.path, message: pi.message });
1124
+ }
1125
+ }
1126
+ if (issues.length) return { ok: false, issues };
1127
+ if (!validation.ok) return { ok: false, issues };
1128
+ return {
1129
+ ok: true,
1130
+ manifest: {
1131
+ schemaVersion: 1,
1132
+ name,
1133
+ course: validation.descriptor,
1134
+ paths
674
1135
  }
1136
+ };
1137
+ }
1138
+ async function loadLessonkitManifestFromFile(readJson, label = "lessonkit.json", projectRoot) {
1139
+ try {
1140
+ return parseLessonkitManifest(await readJson(), label, projectRoot);
1141
+ } catch {
1142
+ return { ok: false, issues: [{ path: label, message: "failed to read or parse JSON" }] };
675
1143
  }
676
1144
  }
677
1145
 
@@ -681,6 +1149,15 @@ var import_tracking_schema2 = require("@lxpack/tracking-schema");
681
1149
  // src/telemetry.ts
682
1150
  var import_tracking_schema = require("@lxpack/tracking-schema");
683
1151
  var SUPPORTED = new Set(import_tracking_schema.LESSONKIT_TELEMETRY_EVENTS);
1152
+ function isQuizAnsweredData(data) {
1153
+ return typeof data === "object" && data !== null && typeof data.checkId === "string";
1154
+ }
1155
+ function isQuizCompletedData(data) {
1156
+ return typeof data === "object" && data !== null && typeof data.checkId === "string";
1157
+ }
1158
+ function isInteractionData(data) {
1159
+ return typeof data === "object" && data !== null;
1160
+ }
684
1161
  function telemetryEventToLessonkit(event) {
685
1162
  if (!SUPPORTED.has(event.name)) {
686
1163
  return null;
@@ -692,16 +1169,16 @@ function telemetryEventToLessonkit(event) {
692
1169
  };
693
1170
  if (name === "quiz_completed" || name === "quiz_answered") {
694
1171
  const data = event.data;
695
- mapped.assessmentId = data?.checkId;
696
- if (data && "score" in data) {
697
- mapped.score = data.score;
698
- mapped.maxScore = data.maxScore;
699
- mapped.passingScore = data.passingScore;
700
- }
701
- if (data) {
1172
+ if (isQuizAnsweredData(data) || isQuizCompletedData(data)) {
1173
+ mapped.assessmentId = data.checkId;
1174
+ if ("score" in data) {
1175
+ mapped.score = data.score;
1176
+ mapped.maxScore = data.maxScore;
1177
+ mapped.passingScore = data.passingScore;
1178
+ }
702
1179
  mapped.data = data;
703
1180
  }
704
- } else if (name === "interaction" && event.data) {
1181
+ } else if (name === "interaction" && event.data && isInteractionData(event.data)) {
705
1182
  mapped.data = event.data;
706
1183
  }
707
1184
  return mapped;
@@ -714,21 +1191,29 @@ var import_validators2 = require("@lxpack/validators");
714
1191
  LESSONKIT_TELEMETRY_EVENTS,
715
1192
  assessmentDescriptorToLxpack,
716
1193
  buildLessonkitProject,
1194
+ buildStagingPackage,
717
1195
  descriptorToInterchange,
1196
+ ensureOutDirParent,
718
1197
  extractAssessments,
719
1198
  lessonkitInterchangeSchema,
1199
+ loadLessonkitManifestFromFile,
720
1200
  mapLessonkitIds,
721
1201
  mapLessonkitTelemetryToBridgeAction,
722
1202
  mapLessonkitTelemetryToLxpack,
723
1203
  materializeLessonkitProject,
724
1204
  packageLessonkitCourse,
725
1205
  parseLessonkitInterchange,
1206
+ parseLessonkitManifest,
1207
+ promoteStagingToOutDir,
1208
+ remapArtifactPaths,
726
1209
  resolveSafePackageOutputOverride,
727
1210
  resolveSpaLessons,
728
1211
  telemetryEventToLessonkit,
729
1212
  themeToLxpackRuntime,
730
1213
  validateDescriptor,
1214
+ validateDescriptorForTarget,
731
1215
  validateLessonkitProject,
1216
+ validatePackageInputs,
732
1217
  validateProjectPaths,
733
1218
  writeLxpackProject
734
1219
  });